Skip to content
Huy Nguyen's Blog
Go back

Schema-First API Development with DRF + React: OpenAPI as the Contract

Every team that splits backend and frontend eventually meets the same class of bug: the backend renames a field, the frontend keeps sending the old one, and nothing complains until a user hits the broken flow in production. Mistyped params, outdated payloads, response shapes that drifted — none of them are hard bugs individually, but they’re silent, and silence is what makes them expensive.

For the past few years, my team has been running a setup where this class of bug essentially doesn’t exist: the OpenAPI schema is the protocol between backend and frontend. The backend generates it, the frontend generates from it, and type checkers on both sides refuse to compile anything that violates it. We haven’t faced an outdated or mistyped API call since.

This post is the full recipe for Django REST Framework + React — but the pattern generalizes to any stack. You need exactly three ingredients:

  1. An API contract (OpenAPI here, could be AsyncAPI, GraphQL SDL, protobuf)
  2. A type checker on each side (mypy/pyright + TypeScript)
  3. Lint + tests wired into git so nothing changes silently

Table of contents

Open Table of contents

The big picture

DRF serializers ──▶ drf-spectacular ──▶ /api/schema/ (the live contract)

                          ┌──────────────────┤
                          ▼                  ▼
                   zod schemas         TypeScript types
                          │                  │
                          ▼                  ▼
                   zodios API client   auto-generated forms

With drf-spectacular you can serve the schema directly as an endpoint (SpectacularAPIView at /api/schema/), so the frontend can fetch the contract straight from the API — local or staging — always in sync with the code by construction. In stricter environments you might prefer exporting a schema file and committing it instead; either works, it’s up to your workflow. We fetch live, and commit only the generated frontend files.

The critical property: nothing downstream is written by hand. When a serializer changes, you regenerate, and TypeScript immediately tells you every place the frontend just became wrong. The contract change is loud, visible in the diff of the generated files, and impossible to merge accidentally.

Backend: serializers are the contract

Why DRF (and not Django Ninja)

Django Ninja is great, and its FastAPI-style ergonomics are genuinely pleasant. We still prefer DRF for schema-first work, for one main reason: ViewSets model an API kind, not a function.

A ViewSet groups the list/retrieve/create/update/destroy operations of one resource, and that group carries shared configuration: reusable permission classes (instead of manually decorating every function), throttling, filtering, plus OpenAPI tags and descriptions that keep the generated schema organized. When your generated client is grouped by tag — one file per resource — that structure pays off directly on the frontend.

class ProjectViewSet(ModelViewSet[Project]):
    queryset = Project.objects.all()
    serializer_class = ProjectSerializer
    permission_classes = [IsAuthenticated, IsProjectMember]  # reused, not repeated

    def get_serializer_class(self) -> type[BaseSerializer[Project]]:
        if self.action == "list":
            return ProjectListSerializer  # lighter payload for lists
        return super().get_serializer_class()

The habit: always define serializer_class (and queryset), and override get_serializer_class() when an action needs a different shape. drf-spectacular reads these to build the schema — a view without a declared serializer is a hole in your contract.

Strictly follow the schema — it’s a skill

Here’s the honest part: writing serializers that produce a correct OpenAPI schema is hard at first. Read-only fields, write-only fields, nested reads with flat writes, defaults, nullability — each needs to be expressed so the schema says exactly what the API does. You will fight it for the first few weeks.

But it’s a skill that converges. Once you’re familiar, you always know how to define an API correctly on the first pass, and the payoff compounds: every serializer you write generates a client, types, and a form for free.

Some hard-won drf-spectacular settings that matter:

SPECTACULAR_SETTINGS = {
    # Split request/response components. Without this, read-only fields
    # (id, timestamps) leak into request schemas and break FE mutations.
    "COMPONENT_SPLIT_REQUEST": True,

    # snake_case on the wire is a Python-ism; camelCase for JS consumers.
    "CAMELIZE_NAMES": True,

    # Name your enums, or generic fields like `status` collide
    # in the component registry across models.
    "ENUM_NAME_OVERRIDES": {
        "TaskStatusEnum": "app.models.Task.Status.choices",
    },

    # Custom postprocessing hooks — ordering matters (enums resolve
    # before discriminator checks).
    "POSTPROCESSING_HOOKS": [...],
}

Two gotchas worth calling out:

Serializer patterns that come up constantly

A few recurring shapes once you commit to “the schema must be exactly right”:

Nested read, flat write. The UI wants the full related object when reading, but should send only an ID when writing. A small custom related field expresses this once, and the schema shows both shapes correctly (this is also exactly what COMPONENT_SPLIT_REQUEST exists for):

class ProjectSerializer(ModelSerializer[Project]):
    owner = NestedReadFlatWriteField(serializer=UserSummarySerializer)
    # read:  {"owner": {"id": 7, "name": "..."}}
    # write: {"owner": 7}

Defaults must be declared on the serializer. Model-level defaults don’t automatically reach the OpenAPI schema. Declaring them via extra_kwargs (or explicit serializer fields) exports them into the contract, and the frontend picks them up as ZodDefault — which auto-forms then use to prefill fields:

class Meta:
    model = Task
    fields = ["id", "title", "priority"]
    extra_kwargs = {"priority": {"default": Priority.MEDIUM}}

A lightweight schema variant. All those help_text descriptions are great for API docs but dead weight in a client bundle. Serving a second schema endpoint with descriptions stripped (a tiny AutoSchema subclass) and generating the client from that cut our generated-code size dramatically, while the full-fat schema stays available for the docs UI.

Backend typing: mypy + pyright

We run both mypy (strict) and pyright on the backend:

[mypy]
strict = true
plugins = mypy_django_plugin.main, mypy_drf_plugin.main

[mypy.plugins.django-stubs]
django_settings_module = "config.settings.dev"

Django and DRF are metaprogramming-heavy — model managers, objects, serializer fields — so the mypy plugin from django-stubs is what makes types actually work rather than being Any soup. It’s slow, which is why we run pyright too for fast in-editor feedback, and we’re watching ty with interest — once it grows the analysis hooks that Django’s metaprogramming needs, the speed story gets much better.

Small habits that keep the noise down: type serializers as ModelSerializer[Project], and prefer cast(User, request.user) at boundaries over sprinkling # type: ignore.

Frontend: generate everything, write nothing

The client

There are several generators, but we settled on openapi-zod-client (with zodios) because it produces zod schemas, not just types — and zod schemas do double duty (more below).

# With the backend dev server running, one command
# regenerates the whole contract surface from /api/schema/:
pnpm gen:all
# → src/schemas/backend/   zod schemas, grouped one file per OpenAPI tag
# → src/types/backend/     extracted TypeScript interfaces
# → src/services/backend/  zodios API clients

Useful flags: --group-strategy tag-file (one module per resource — this is where the ViewSet tags pay off), --export-types (TS interfaces alongside the zod schemas), and Handlebars templates if you need to control the output shape.

We eventually needed more than upstream offered, so we maintain an improved fork of openapi-zod-client. The main upgrades, all born from real usage:

The client is fully typed end to end, and zodios can validate requests/responses against the zod schemas at runtime in dev:

const projectsApi = createApiClient(baseUrl, {
  axiosInstance: authAxios,
  validate: "request",
});

// Params, body, and response are all typed from the schema.
// Rename a field on the backend, regenerate, and this line
// turns red before you even run anything.
const project = await projectsApi.projectsRetrieve({ params: { id } });

Forms that update themselves

This is my favorite part. Because the generated schemas are zod, they plug straight into form validation — and one step further, into auto-generated forms. Several libraries render a form directly from a zod schema (shadcn’s AutoForm ecosystem and friends); we pass the generated schema to an APIForm component that:

In practice it looks like this:

<APIForm
  schema={schemas.TaskRequest}     // generated zod schema
  onSubmit={values => tasksApi.tasksCreate(values)}
  hiddenFields={["workspace"]}     // small per-form overrides
/>

The result: when the backend adds a field to a serializer, the frontend form grows the field automatically on the next regenerate. No manually synced form definitions, no forgotten validation rules — the serializer is the form definition.

The whole loop: adding one field

To make this concrete, here’s everything that happens when a feature needs a new due_date on tasks:

  1. Model + migration — add the field, makemigrations.
  2. Serializer — add due_date to fields (plus extra_kwargs if it has a default). This is the only contract-relevant edit in the entire feature.
  3. Regeneratepnpm gen:all against the running dev server.
  4. TypeScript speaks — the generated TaskRequest schema now includes dueDate. Any code constructing task payloads without it (if required) fails to compile, at the exact call sites.
  5. Forms update themselves — the task form renders a date field on next reload, defaults prefilled, validation attached.
  6. The diff tells the story — the PR shows the serializer change and the regenerated client files. Reviewers see the contract change explicitly; nobody needs to announce it in a meeting.

Total hand-written code: the model field and one serializer line. Everything else is derived — which is exactly why the contract can’t drift.

On learning to love types

I’ll be honest about the arc, because I lived it on both sides.

I didn’t like TypeScript initially. On the backend, strict mypy felt like bureaucracy. You will hate this setup for the first month — that’s the normal part of the curve, and the fix is the boring one: read a good book, practice, push through. Somewhere along the way it flips, and then it flips hard:

Now I occasionally catch myself thanking the type checker out loud for a bug that would have been a 2 a.m. production incident.

Why this matters for teams

For team work, I’d upgrade these three from “nice to have” to must have: the schema contract, the tests, and the types. Together they eliminate the silent, unspoken change — the most corrosive failure mode in a multi-person codebase.

Every contract change shows up in the generated files, so git sees it and reviewers see it. Lint and type checks fail if someone forgot to regenerate (a pre-commit hook or CI step that diffs freshly generated output catches drift automatically). Nobody has to remember to tell the frontend team — the toolchain tells them.

Why this matters for AI-assisted development

An underrated effect: this setup makes AI coding agents dramatically more effective, using less time and fewer tokens.

When an agent works end-to-end on a feature, the schema pipeline removes the whole “update the API layer by hand” category of work — and of risk. Agents do sometimes mis-update one of the several places an API shape lives; here there’s exactly one place (the serializer), and everything downstream regenerates. The generated typed client plus TypeScript means an agent that hallucinates an endpoint or a param gets an immediate compile error instead of a runtime surprise you have to debug later.

Contract + types is essentially machine-checkable documentation — which is exactly what agents (and new teammates) need.

Takeaways


Share this post:


Next Post
nplus1: A Modern N+1 Query Detector for Django, SQLAlchemy, and Peewee