Create a Django and React app with auto-generated Django types
Building a Type-Safe Django + React Stack
In this, we'll create a full Django and React app, complete with automatically generated end-to-end type safety from Django models to TypeScript components.
And here's a video guide (following the below written guide) featuring me :
Why This Stack is useful
The type system ensures that any changes to your Python models automatically propagate to your TypeScript frontend when you regenerate the types, providing a robust, type-safe development experience. This means:
- Zero runtime type errors due to frontend and backend differences
- Automatic code completion in your IDE for API responses
- Compile-time guarantees that your frontend matches your backend schema
- Refactoring confidence - change a model field and TypeScript will tell you everywhere that needs updating
How our Type Propagation works
- Define your data model in Django
- Create Pydantic schemas that match your models
- Django Ninja generates OpenAPI from your schemas automatically
- openapi-typescript converts the OpenAPI spec to TypeScript types
- Your React components get full autocomplete and type checking
The Complete Tech Stack
Category | Technology | Why |
---|---|---|
Backend | Django 5 + Django Ninja | Type-safe REST API with auto-generated docs |
Frontend | React + TypeScript + Vite | Fast development with full type safety |
Type Generation | openapi-typescript | Converts OpenAPI to TypeScript types |
Data Fetching | Native React hooks | Simple async state management |
Validation | Pydantic | Runtime validation with static type inference |
Type Checking | mypy + TypeScript | Strict type checking on both ends |
Package Management | uv (Python) + pnpm (Node) | Fast, reliable dependency management |
Code Quality | ruff + ESLint | Consistent formatting and linting |
1) Backend Setup with Django + Ninja
Create the Project Environment
# Create and activate virtual environment
uv venv .venv
source .venv/bin/activate
# Install dependencies with uv (faster than pip)
uv pip install django django-ninja pydantic mypy "django-stubs[compatible-mypy]" ruff django-cors-headers
# Create Django project
django-admin startproject backend .
Create the API App
python manage.py startapp api
Configure Django Settings
Create backend/settings.py
:
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = "dev-only"
DEBUG = True
ALLOWED_HOSTS = ["*"]
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"corsheaders",
"api", # Our API app
]
MIDDLEWARE = [
"corsheaders.middleware.CorsMiddleware",
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "backend.urls"
TEMPLATES = [{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
]},
}]
WSGI_APPLICATION = "backend.wsgi.application"
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3"
}
}
STATIC_URL = "static/"
# CORS for local Vite development
CORS_ALLOW_ALL_ORIGINS = True
Define Your Models
Create api/models.py
:
from django.db import models
class Book(models.Model):
title = models.CharField(max_length=200)
year = models.PositiveIntegerField()
author = models.CharField(max_length=120)
def __str__(self) -> str:
return f"{self.title} ({self.year})"
Create Auto-Generated Schemas
Create api/schemas.py
:
from ninja import ModelSchema
from .models import Book
class BookIn(ModelSchema):
class Meta:
model = Book
exclude = ['id']
class BookOut(ModelSchema):
class Meta:
model = Book
fields = '__all__'
Build Type-Safe API Views
Create api/views.py
:
from django.db.models import QuerySet
from django.http import HttpRequest
from django.shortcuts import get_object_or_404
from ninja import NinjaAPI
from .models import Book
from .schemas import BookIn, BookOut
api = NinjaAPI(title="Sample API for a library")
@api.get("/books", response=list[BookOut])
def list_books(request: HttpRequest) -> QuerySet[Book]:
return Book.objects.all().order_by("id")
@api.post("/books", response=BookOut)
def create_book(request: HttpRequest, payload: BookIn) -> Book:
return Book.objects.create(**payload.model_dump())
@api.get("/books/{book_id}", response=BookOut)
def get_book(request: HttpRequest, book_id: int) -> Book:
return get_object_or_404(Book, pk=book_id)
Wire Up URLs
Create backend/urls.py
:
from django.contrib import admin
from django.urls import path
from api.views import api
urlpatterns = [
path("admin/", admin.site.urls),
path("api/", api.urls),
]
Run Migrations and Start Server
python manage.py makemigrations
python manage.py migrate
python manage.py runserver
Your API is now live:
- API docs: http://127.0.0.1:8000/api/docs
- OpenAPI spec: http://127.0.0.1:8000/api/openapi.json
2) Type Checking Configuration
Setup mypy for Django
Create mypy.ini
in your root folder:
[mypy]
python_version = 3.12
plugins = mypy_django_plugin.main
strict = True
ignore_missing_imports = False
warn_unused_ignores = True
warn_redundant_casts = True
warn_return_any = True
disallow_any_generics = True
no_implicit_optional = True
[mypy.plugins.django-stubs]
django_settings_module = backend.settings
[mypy-*.migrations.*]
ignore_errors = True
Setup ruff for Code Quality
Create ruff.toml
in your root folder:
target-version = "py312"
line-length = 100
[lint]
select = ["E","F","I","UP","B"]
[lint.per-file-ignores]
"*/migrations/*" = ["E501"]
Verify Type Safety
# Run type checking
uv run mypy api backend
# Run linting
uv run ruff check . --fix
3) Frontend Setup with React + TypeScript
Create React App with Vite
# Use pnpm for faster package management
pnpm create vite@latest frontend -- --template react-ts
cd frontend
pnpm install
# Install type generation and API client dependencies
pnpm add -D openapi-typescript
pnpm add openapi-fetch
Generate TypeScript Types from OpenAPI
This is where the magic happens.
# Generate types from your Django API
pnpm exec openapi-typescript http://127.0.0.1:8000/api/openapi.json --output src/lib/api-types.ts
This creates fully-typed interfaces that match your Pydantic schemas exactly:
Open api-types.ts
to see:
// Generated automatically from your Django API
export interface BookOut {
id: number
title: string
year: number
author: string
}
export interface BookIn {
title: string
year: number
author: string
}
Create a Typed API Client
In order to use our types when fetching data from the backend, we will add a typed client to requests to the backend.
Create src/lib/client.ts
:
import createClient from 'openapi-fetch'
import type { paths } from './api-types'
export const api = createClient<paths>({
baseUrl: 'http://127.0.0.1:8000',
})
That's it! Just 3 lines for a fully typed client. The openapi-fetch
library handles all the complex type extraction automatically.
Setup React App
Update src/main.tsx
:
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
Build Type-Safe React Components
Update src/App.tsx
:
import { useCallback, useEffect, useState } from "react";
import type { components } from "./lib/api-types";
import { api } from "./lib/client";
type Book = components["schemas"]["BookOut"];
export default function App() {
const [books, setBooks] = useState<Book[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadBooks = useCallback(async () => {
try {
const { data } = await api.GET("/api/books");
setBooks(data || []);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load books");
} finally {
setLoading(false);
}
}, []);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const title = String(formData.get("title") || "");
const author = String(formData.get("author") || "");
const year = Number(formData.get("year") || 0);
try {
await api.POST("/api/books", {
body: { title, author, year },
});
e.currentTarget?.reset();
loadBooks(); // Refresh the list
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create book");
}
};
useEffect(() => {
loadBooks();
}, [loadBooks]);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return (
<div style={{ padding: 24 }}>
<h1>Books</h1>
<form onSubmit={handleSubmit}>
<input name="title" placeholder="Title" required />
<input name="author" placeholder="Author" required />
<input name="year" type="number" placeholder="Year" required />
<button type="submit">Add</button>
</form>
<ul>
{books.map((book) => (
<li key={book.id || 0}>
{book.title} by {book.author} ({book.year})
</li>
))}
</ul>
</div>
);
}
Start the Frontend
pnpm run dev
Your frontend is now running with full type safety:
- Frontend: http://localhost:5173
4) Testing & Validation
Add Django Tests
Create api/tests.py
:
from django.test import TestCase
class ApiTest(TestCase):
def test_list_and_create(self) -> None:
r = self.client.post(
"/api/books",
data={"title": "Dune", "year": 1965, "author": "Herbert"},
content_type="application/json",
)
self.assertEqual(r.status_code, 200)
self.assertEqual(r.json()["title"], "Dune")
r2 = self.client.get("/api/books")
self.assertEqual(r2.status_code, 200)
self.assertEqual(len(r2.json()), 1)
Run All Checks
# Test your Django API
python manage.py test
# Type check your Python code
mypy api backend
# Lint your Python code
ruff check .
# Build your frontend (includes TypeScript checking)
cd frontend && pnpm run build
5) The Type Safety Magic
Example: Adding a New Field
Let's add a description
field to our Book model:
Step 1: Update the Django model
class Book(models.Model):
title = models.CharField(max_length=200)
year = models.PositiveIntegerField()
author = models.CharField(max_length=120)
description = models.TextField(blank=True) # New field!
Step 2: Schemas automatically update
The ModelSchema
automatically picks up the new field - no manual schema updates needed!
Step 2: Regenerate TypeScript types
pnpm exec openapi-typescript http://127.0.0.1:8000/api/openapi.json --output src/lib/api-types.ts
Step 3: TypeScript now knows about the new field!
Your IDE will autocomplete description
and TypeScript will enforce that you handle it properly.
Automation Script
Create a script to automate type generation:
#!/bin/bash
# scripts/update-types.sh
cd frontend
pnpm exec openapi-typescript http://127.0.0.1:8000/api/openapi.json --output src/lib/api-types.ts
echo "TypeScript types updated!"
6) Production Considerations
Security
- Replace
CORS_ALLOW_ALL_ORIGINS = True
with specific origins - Use environment variables for secrets
- Add authentication/authorization as needed
Automated Type Generation
Add this to your deployment script or pre-commit hook:
# Automate type generation on deploy or precommit
python manage.py runserver --noreload &
SERVER_PID=$!
sleep 5
cd frontend && pnpm exec openapi-typescript http://127.0.0.1:8000/api/openapi.json --output src/lib/api-types.ts
kill $SERVER_PID
That's it!
As mentioned in my article, having a reliable development experience with strong typing makes a huge difference in productivity and confidence when shipping features.
Happy building.