Skip to main content

API Calls & Type Generation

Quick reference for making type-safe API calls and managing code generation in your frontend.

What is Code Generation?

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

tip

For comprehensive documentation, see the OpenAPI Fetch Documentation

How do I generate API types from the backend?

Always Keep Types Fresh!

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:

  1. Make sure your backend is running (required for schema access)
  2. From project root: cd apps/frontend && npm run codegen
  3. The generated types will be in apps/frontend/src/api/:
    • requests/ - Type-safe API client
    • queries/ - TanStack Query hooks

Check these files to verify your endpoints appear correctly!

How do I make API calls?

API Call Patterns

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?

Type-Safe 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?

When to Use 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

Common Issues

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?

  1. Restart your backend server (should be already restarted)
  2. From project root: cd apps/frontend && npm run codegen
  3. 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)