API Calls & Type Generation
Quick reference for making type-safe API calls and managing code generation in your frontend.
Code generation creates TypeScript types from your backend's OpenAPI schema. This gives you:
- Type safety: Catch API errors at compile time
- Auto-completion: IDE suggests available endpoints and fields
- Documentation: Types serve as API documentation
- Refactoring safety: Changes to backend immediately show as TypeScript errors
It's like having a contract between frontend and backend
For comprehensive documentation, see the OpenAPI Fetch Documentation
How do I generate API types from the backend?
Run codegen after every backend change:
- New endpoints won't appear in your IDE
- Changed field names will cause runtime errors
- Required fields might become optional (or vice versa)
- Response shapes can change silently
Set up automatic type generation:
- Make sure your backend is running (required for schema access)
- From project root:
cd apps/frontend && npm run codegen - The generated types will be in
apps/frontend/src/api/:requests/- Type-safe API clientqueries/- TanStack Query hooks
Check these files to verify your endpoints appear correctly!
How do I make API calls?
TanStack Query vs Zustand?
- TanStack Query hooks (auto-generated): Best for server state (API data)
- Zustand stores: For client state (UI state, user preferences)
- Custom hooks: For combining multiple queries or adding business logic
Modern best practice: Let TanStack Query handle all API calls!
Basic CRUD operations with TanStack Query:
// Using auto-generated hooks
import { useGetTasksMyTasks, usePostTasksMyTasks } from '@/api/queries'
import { useMutation, useQueryClient } from '@tanstack/react-query'
// Component using TanStack Query
export function TaskList() {
// Auto-fetching with caching
const { data: tasks, isLoading, error } = useGetTasksMyTasks()
// Mutations with automatic refetching
const queryClient = useQueryClient()
const createMutation = useMutation({
mutationFn: async (taskData: CreateTaskDTO) => {
const { data, error } = await TasksService.postTasksMyTasks({ body: taskData })
if (error) throw error
return data
},
onSuccess: () => {
// Automatically refetch tasks list
queryClient.invalidateQueries({ queryKey: ['/tasks/my-tasks'] })
}
})
const deleteMutation = useMutation({
mutationFn: async (taskId: string) => {
const { error } = await TasksService.deleteTasksId({ path: { id: taskId } })
if (error) throw error
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/tasks/my-tasks'] })
}
})
if (isLoading) return <div>Loading...</div>
if (error) return <div>Error loading tasks</div>
return (
<div>
<button
onClick={() => createMutation.mutate({
title: 'New Task',
description: 'Task description'
})}
disabled={createMutation.isPending}
>
{createMutation.isPending ? 'Creating...' : 'Add Task'}
</button>
{tasks?.map(task => (
<div key={task.id}>
<h3>{task.title}</h3>
<button
onClick={() => deleteMutation.mutate(task.id)}
disabled={deleteMutation.isPending}
>
Delete
</button>
</div>
))}
</div>
)
}
// Benefits of TanStack Query:
// ✅ Automatic caching - No duplicate requests
// ✅ Background refetching - Always fresh data
// ✅ Loading/error states - Built-in
// ✅ Optimistic updates - Instant UI feedback
// ✅ Request deduplication - Multiple components, one request
How do I handle query parameters?
With generated types, query parameters are fully type-checked:
- Required vs optional: TypeScript will enforce which params are needed
- Valid values: Enums and unions are properly typed
- Correct types: Numbers, booleans, strings are validated
No more guessing what parameters an endpoint accepts
API calls with filters and pagination:
// hooks/useTaskFilters.ts
import { apiClient } from '@/types/openapi'
import type { TaskDTO } from '@/types/openapi'
interface TaskFilters {
completed?: boolean
priority?: 'low' | 'medium' | 'high'
search?: string
page?: number
size?: number
}
export function useTaskFilters() {
const [loading, setLoading] = useState(false)
const getFilteredTasks = async (filters: TaskFilters = {}): Promise<{
tasks: TaskDTO[]
total: number
page: number
pages: number
}> => {
try {
setLoading(true)
// Type-safe query parameters
const { data, error } = await apiClient.GET('/tasks/', {
params: {
query: {
completed: filters.completed,
priority: filters.priority,
search: filters.search,
page: filters.page || 1,
size: filters.size || 20
}
}
})
if (error) {
throw new Error('Failed to fetch tasks')
}
return {
tasks: data?.tasks || [],
total: data?.pagination?.total || 0,
page: data?.pagination?.page || 1,
pages: data?.pagination?.pages || 0
}
} finally {
setLoading(false)
}
}
return { getFilteredTasks, loading }
}
// Usage in component
export function TaskList() {
const [filters, setFilters] = useState<TaskFilters>({})
const [tasks, setTasks] = useState<TaskDTO[]>([])
const { getFilteredTasks, loading } = useTaskFilters()
const loadTasks = async () => {
const result = await getFilteredTasks(filters)
setTasks(result.tasks)
}
const handleFilterChange = (newFilters: Partial<TaskFilters>) => {
setFilters(prev => ({ ...prev, ...newFilters }))
}
useEffect(() => {
loadTasks()
}, [filters])
return (
<div>
{/* Filter controls */}
<div className="flex gap-2 mb-4">
<select
onChange={(e) => handleFilterChange({
completed: e.target.value === 'all' ? undefined : e.target.value === 'true'
})}
>
<option value="all">All Tasks</option>
<option value="false">Pending</option>
<option value="true">Completed</option>
</select>
<select
onChange={(e) => handleFilterChange({
priority: e.target.value as any || undefined
})}
>
<option value="">Any Priority</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
{/* Task list */}
{loading ? (
<div>Loading...</div>
) : (
tasks.map(task => <TaskItem key={task.id} task={task} />)
)}
</div>
)
}
How do I set up request/response interceptors?
Good for:
- Authentication headers (tokens)
- Global error handling (401, 500s)
- Request/response logging
- Request timing/performance monitoring
Avoid for:
- Complex business logic (use stores instead)
- UI-specific error handling (handle in components)
- Data transformation (use hooks or services)
Add authentication and logging:
// services/apiClient.ts
import createClient from 'openapi-fetch'
import type { paths } from '@/types/openapi'
// Create custom client with interceptors
function createAPIClient() {
const client = createClient<paths>({
baseUrl: '/api'
})
// Request interceptor - add auth headers
client.use({
onRequest({ request }) {
// Add auth token if available
const token = localStorage.getItem('auth_token')
if (token) {
request.headers.set('Authorization', `Bearer ${token}`)
}
// Log requests in development only
if (process.env.NODE_ENV === 'development') {
console.log(`🚀 ${request.method} ${request.url}`)
// Store start time for response timing
;(request as any).startTime = performance.now()
}
return request
},
onResponse({ response, request }) {
// Log response in development
if (process.env.NODE_ENV === 'development') {
const duration = performance.now() - (request as any).startTime
console.log(`${request.method} ${request.url} - ${response.status} (${duration.toFixed(2)}ms)`)
}
// Handle global errors
if (response.status === 401) {
// Redirect to login
window.location.href = '/login'
return
}
if (response.status >= 500) {
// Log server errors to monitoring
console.error('Server error:', response.status, response.statusText)
}
return response
}
})
return client
}
export const apiClient = createAPIClient()
Troubleshooting
Type errors that don't make sense?
- Usually means types are stale - run codegen again
- Check the backend actually has the endpoint you're trying to use
"Property does not exist" errors?
- Backend field might be renamed, removed, or optional
- Check the actual API response in browser dev tools
Types not updating after backend changes?
- Restart your backend server (should be already restarted)
- From project root:
cd apps/frontend && npm run codegen - Restart your Next.js dev server if needed
API calls failing with CORS errors?
CORS is a browser security feature that blocks requests from different domains.
If you have issues locally, check your next.config.ts proxy settings:
// next.config.ts
const nextConfig: NextConfig = {
async rewrites() {
return [
{
source: '/api/:path*',
destination: 'http://localhost:8000/:path*',
},
]
},
}
This redirects all calls from http://localhost:3000/api/* (Next.js) to http://localhost:8000/ (FastAPI backend)