Type-Safe API Integration
Achieve end-to-end type safety between your Django REST API and React frontend using OpenAPI code generation. When you change a serializer field in Django, TypeScript immediately flags any frontend code that needs updating.
Overview
The integration pipeline works as follows:
Django Serializers define your API data structures
drf-spectacular generates an OpenAPI 3.0 schema from your serializers and viewsets
@hey-api/openapi-ts generates TypeScript types and React Query hooks from that schema
Your React components use those generated hooks with full type safety
Your frontend stays in sync with your backend. No more guessing field names, no more runtime type errors from API changes, and no manual type definitions to maintain.
Backend Setup
The template configures drf-spectacular automatically when use_drf is enabled. Here’s what gets set up:
Django Settings
In config/settings/base.py, drf-spectacular is configured as the default schema class:
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": (
"rest_framework.authentication.SessionAuthentication",
"rest_framework.authentication.TokenAuthentication",
),
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
"DEFAULT_PAGINATION_CLASS": "{project_slug}.core.pagination.DefaultPagination",
}
SPECTACULAR_SETTINGS = {
"TITLE": "My Project API",
"DESCRIPTION": "Documentation of API endpoints",
"VERSION": "1.0.0",
"SERVE_PERMISSIONS": ["rest_framework.permissions.IsAdminUser"],
"SCHEMA_PATH_PREFIX": "/api/",
}
URL Configuration
The OpenAPI schema is served at /api/schema/ in config/urls.py:
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
urlpatterns += [
path("api/", include("config.api_router")),
path("api/schema/", SpectacularAPIView.as_view(), name="api-schema"),
path(
"api/docs/",
SpectacularSwaggerView.as_view(url_name="api-schema"),
name="api-docs",
),
]
Example ViewSet and Serializer
drf-spectacular automatically generates schema from your serializers. No decorators required for basic cases:
# {project_slug}/tasks/api/serializers.py
from rest_framework import serializers
from {project_slug}.tasks.models import Task
class TaskSerializer(serializers.ModelSerializer):
class Meta:
model = Task
fields = ["id", "title", "description", "status", "created_at"]
read_only_fields = ["id", "created_at"]
# {project_slug}/tasks/api/views.py
from rest_framework import viewsets
from {project_slug}.tasks.models import Task
from .serializers import TaskSerializer
class TaskViewSet(viewsets.ModelViewSet):
serializer_class = TaskSerializer
queryset = Task.objects.all()
This generates TypeScript types like:
export type Task = {
readonly id: number;
title: string;
description?: string;
status: string;
readonly created_at: string;
};
Frontend Setup
The frontend uses @hey-api/openapi-ts to generate a typed API client from Django’s OpenAPI schema.
Configuration
Each React app has an openapi-ts.config.ts configuration file:
// apps/{project_slug}/openapi-ts.config.ts
import { defaultPlugins, defineConfig } from '@hey-api/openapi-ts';
export default defineConfig({
input: 'http://localhost:8000/api/schema',
output: 'src/services/{project_slug}',
plugins: [
...defaultPlugins,
'@hey-api/client-fetch',
{
name: '@tanstack/react-query',
infiniteQueryOptions: false,
},
],
});
Key configuration options:
input: URL to your Django OpenAPI schema endpointoutput: Directory where generated code will be writtenplugins: Enables fetch client and React Query integration
Dependencies
Your package.json needs these dependencies:
{
"dependencies": {
"@hey-api/client-fetch": "^0.9.0",
"@tanstack/react-query": "^5.90.10"
},
"devDependencies": {
"@hey-api/openapi-ts": "0.87.5"
}
}
Running Code Generation
With Django running, generate the client:
cd apps/{project_slug}
pnpm openapi-ts
This creates several files in src/services/{project_slug}/:
types.gen.ts- TypeScript interfaces for all API typessdk.gen.ts- Low-level API functionsclient.gen.ts- Configured HTTP client@tanstack/react-query.gen.ts- React Query hooks and mutation factories
Using Generated Code
App Setup
Configure React Query in your app root:
// src/App.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
retry: 1,
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourAppContent />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
Client Configuration
Configure the API client with your backend URL and CSRF handling:
// src/lib/api-client.ts
import { client } from '@/services/{project_slug}/client.gen';
// Set base URL based on environment
const apiBaseUrl = import.meta.env.PROD
? window.location.origin
: 'http://localhost:8000';
client.setConfig({ baseUrl: apiBaseUrl });
// Add CSRF token to mutating requests
client.interceptors.request.use((request) => {
if (request.method !== 'GET') {
const csrfToken = document.querySelector('meta[name="csrf-token"]')
?.getAttribute('content');
if (csrfToken) {
request.headers.set('X-CSRFToken', csrfToken);
}
}
return request;
});
Import this file early in your app initialization to ensure the client is configured before any API calls.
Query Hooks
The generated code provides *Options functions for queries. These return configuration objects for useQuery().
Basic Query
Using the generated hook directly:
import { useQuery } from '@tanstack/react-query';
import { tasksListOptions } from '@/services/{project_slug}/@tanstack/react-query.gen';
function TaskList() {
const { data, isLoading, error } = useQuery(tasksListOptions());
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<ul>
{data?.results?.map((task) => (
<li key={task.id}>{task.title}</li>
))}
</ul>
);
}
Custom Hook Pattern
For reusability and cleaner component code, wrap generated hooks in custom hooks:
// src/features/tasks/hooks/useTasks.ts
import { useQuery } from '@tanstack/react-query';
import { tasksListOptions } from '@/services/{project_slug}/@tanstack/react-query.gen';
interface UseTasksProps {
search?: string;
page?: number;
pageSize?: number;
status?: string;
}
export const useTasks = ({
search,
page,
pageSize = 10,
status,
}: UseTasksProps = {}) => {
const queryOptions = tasksListOptions({
query: {
search,
page,
page_size: pageSize,
status,
},
});
const { data, isLoading, error, ...rest } = useQuery(queryOptions);
return {
tasks: data?.results ?? [],
totalCount: data?.count ?? 0,
hasNextPage: !!data?.next,
hasPreviousPage: !!data?.previous,
isLoading,
error,
...rest,
};
};
Now components can use the simpler interface:
function TaskList() {
const { tasks, isLoading, totalCount } = useTasks({
status: 'pending',
pageSize: 20,
});
// Clean, typed access to tasks
}
Single Item Query
For retrieving a single item by ID:
// src/features/tasks/hooks/useTask.ts
import { useQuery } from '@tanstack/react-query';
import { tasksRetrieveOptions } from '@/services/{project_slug}/@tanstack/react-query.gen';
export const useTask = (id: number) => {
return useQuery({
...tasksRetrieveOptions({ path: { id } }),
enabled: !!id, // Only fetch when ID is provided
});
};
Mutation Hooks
The generated code provides *Mutation functions that return configuration objects for useMutation().
Basic Mutations
Create, update, and delete operations follow the same pattern:
// src/features/tasks/hooks/useTaskMutations.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
tasksCreateMutation,
tasksPartialUpdateMutation,
tasksDestroyMutation,
tasksListQueryKey,
tasksRetrieveQueryKey,
} from '@/services/{project_slug}/@tanstack/react-query.gen';
import type { Task } from '@/services/{project_slug}/types.gen';
export const useCreateTask = () => {
const queryClient = useQueryClient();
return useMutation({
...tasksCreateMutation(),
onSuccess: () => {
// Invalidate list to refetch with new item
queryClient.invalidateQueries({ queryKey: tasksListQueryKey() });
},
});
};
export const useUpdateTask = () => {
const queryClient = useQueryClient();
return useMutation({
...tasksPartialUpdateMutation(),
onSuccess: (data: Task) => {
// Invalidate both list and the specific item
queryClient.invalidateQueries({ queryKey: tasksListQueryKey() });
queryClient.invalidateQueries({
queryKey: tasksRetrieveQueryKey({ path: { id: data.id } }),
});
},
});
};
export const useDeleteTask = () => {
const queryClient = useQueryClient();
return useMutation({
...tasksDestroyMutation(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: tasksListQueryKey() });
},
});
};
Using Mutations in Components
function CreateTaskForm() {
const createTask = useCreateTask();
const handleSubmit = (formData: { title: string; description: string }) => {
createTask.mutate(
{ body: formData },
{
onSuccess: () => {
// Navigate or show success message
},
onError: (error) => {
// Handle error
},
}
);
};
return (
<form onSubmit={handleSubmit}>
{/* Form fields */}
<button type="submit" disabled={createTask.isPending}>
{createTask.isPending ? 'Creating...' : 'Create Task'}
</button>
</form>
);
}
Common Patterns
Error Handling
React Query provides error state out of the box. Combine with a toast notification system:
import { toast } from 'sonner';
export const useCreateTask = () => {
const queryClient = useQueryClient();
return useMutation({
...tasksCreateMutation(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: tasksListQueryKey() });
toast.success('Task created successfully');
},
onError: (error) => {
toast.error(`Failed to create task: ${error.message}`);
},
});
};
Loading States
Use React Query’s loading states for UI feedback:
function TaskList() {
const { tasks, isLoading, isFetching, error } = useTasks();
// isLoading: true on first load (no cached data)
// isFetching: true when fetching (including background refetch)
if (isLoading) {
return <LoadingSkeleton />;
}
return (
<div>
{isFetching && <RefreshIndicator />}
<ul>
{tasks.map((task) => (
<TaskItem key={task.id} task={task} />
))}
</ul>
</div>
);
}
Development Workflow
The typical development cycle when working with the API:
Start Django with
just upordocker compose upModify Django API - Add or change serializers, viewsets, or fields
Regenerate client - Run
pnpm openapi-tsin your frontend appTypeScript catches mismatches - Your IDE will immediately show errors where component code doesn’t match the new types
Update React code - Fix any type errors and adapt to API changes
When to regenerate:
After adding new API endpoints
After changing serializer fields
After modifying URL patterns
After changing pagination or filter options
Tip: Consider adding a pre-commit hook or CI check to ensure the generated client stays in sync with the backend schema.
Alternative: Orval for Full Client Generation
While @hey-api/openapi-ts provides types and query hooks, Orval is an alternative that generates more comprehensive clients with additional features.
Comparison
Feature |
@hey-api/openapi-ts |
Orval |
|---|---|---|
TypeScript types |
Yes |
Yes |
React Query hooks |
Yes |
Yes |
Runtime overhead |
Minimal |
Slightly more |
MSW mocks |
No |
Yes (built-in) |
Zod schemas |
No |
Yes (optional) |
Custom templates |
Limited |
Extensive |
Choose @hey-api/openapi-ts when:
You want minimal runtime overhead
You’re comfortable writing your own fetch wrappers
You prefer simpler generated code
Choose Orval when:
You want MSW mocks generated automatically for testing
You need Zod schemas for runtime validation
You want more customization of generated code
Orval Configuration Example
// orval.config.ts
import { defineConfig } from "orval";
export default defineConfig({
api: {
input: {
target: "http://localhost:8000/api/schema/",
},
output: {
client: "react-query",
target: "./src/lib/api/generated.ts",
mock: true, // Generates MSW handlers
override: {
mutator: {
path: "./src/lib/api/custom-fetch.ts",
name: "customFetch",
},
},
},
},
});
The generated MSW mocks can be used directly in tests:
// src/mocks/handlers.ts
import { getTasksMock, getTasksHandler } from "../lib/api/generated.msw";
export const handlers = [
getTasksHandler(),
// Add more handlers...
];
Breaking Change Detection with oasdiff
API changes can break frontend clients. oasdiff detects breaking changes by comparing OpenAPI schemas.
Installation
# macOS
brew install oasdiff
# Or via Go
go install github.com/tufin/oasdiff@latest
# Or via Docker
docker pull tufin/oasdiff
Local Usage
Compare your current schema against a baseline:
# Save current schema as baseline
curl http://localhost:8000/api/schema/ > api-schema-baseline.json
# After making changes, check for breaking changes
oasdiff breaking api-schema-baseline.json http://localhost:8000/api/schema/
Breaking changes include:
Removing or renaming endpoints
Adding required parameters
Removing response fields
Changing field types
CI Integration
Add oasdiff to your CI pipeline to catch breaking changes before they’re merged:
# .github/workflows/ci.yml
- name: Check for breaking API changes
run: |
# Fetch schema from main branch
git fetch origin main
git show origin/main:api-schema.json > base-schema.json || echo '{}' > base-schema.json
# Generate current schema
docker compose -f docker-compose.local.yml run --rm django \
python manage.py spectacular --file /tmp/schema.json
docker compose -f docker-compose.local.yml cp django:/tmp/schema.json ./current-schema.json
# Compare schemas
oasdiff breaking base-schema.json current-schema.json --fail-on-diff
The --fail-on-diff flag causes the command to exit with a non-zero code if breaking changes are detected.
Versioning Schema in Git
Track your API schema in version control for easy diffing:
# .github/workflows/update-schema.yml
name: Update API Schema
on:
push:
branches: [main]
paths:
- "**/serializers.py"
- "**/views.py"
- "**/api_router.py"
jobs:
update-schema:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Generate schema
run: |
docker compose -f docker-compose.local.yml run --rm django \
python manage.py spectacular --file api-schema.json
- name: Commit updated schema
run: |
git config user.name "GitHub Actions"
git config user.email "actions@github.com"
git add api-schema.json
git diff --staged --quiet || git commit -m "chore: update API schema"
git push
Summary
drf-spectacular generates OpenAPI schemas automatically from your DRF serializers
@hey-api/openapi-ts generates TypeScript types and React Query hooks from that schema
Query hooks use
*Optionsfunctions withuseQuery()for fetching dataMutation hooks use
*Mutationfunctions withuseMutation()and invalidate caches on successCustom hooks wrap generated code for cleaner component interfaces
Regenerate the client after any backend API changes to keep types in sync