Create a Django and React app with shared, auto-generated 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 Python models to TypeScript components.
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
**** CONT here. FInish testing then recrdo.
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.