SaaS Starter
Architecture

API contract & OpenAPI

How endpoints declare themselves, how the OpenAPI spec is generated, and when you must regenerate.

Last updated May 1, 2026

How an endpoint registers

Every route declares its OpenAPI shape via apiRoute(...) from apps/server/src/infrastructure/http/openapi.ts:

import { apiRoute } from '@/infrastructure/http/openapi.js';
import { user } from '@app/contracts';

apiRoute({
  method: 'post',
  path: '/users',
  tags: ['users'],
  summary: 'Create a user',
  body: user.CreateUserInput,
  responses: {
    201: { description: 'Created', schema: user.UserDTO },
    409: { description: 'Email already exists' },
  },
  security: 'session',
});

apiRoute registers the path with a Zod-to-OpenAPI registry. A 422 validation-failure response is added by default to every route, with the standard { code, message, issues } shape — don't redeclare it.

The same Zod schema validates the request via validateRequest({ body, params, query }) middleware. Schema drift is impossible: there is one schema.

Generating the spec

bun run generate:api

This runs three steps:

  1. Boots the server's container in spec-only mode and walks the openApiRegistry to emit apps/server/openapi.json.
  2. Runs openapi-typescript to produce apps/client/lib/api/openapi-types.ts.
  3. Formats both with prettier.

Both files are checked into git. The CI job api-types-fresh re-runs the generator and fails the PR if either file would change. This is a hard gate, not a warning.

When you must regenerate

The CLAUDE.md rule:

  • Adding, removing, or renaming a route.
  • Changing a request or response Zod schema (including adding/removing fields).
  • Modifying permission/auth requirements (the OpenAPI security scheme reflects these).
  • Adding/removing values to enums that flow through the API surface — e.g. permission strings in packages/shared/src/permissions, status enums on aggregates exposed via DTOs.

Skip only if the change is pure infrastructure with no controller-visible effect (a Prisma index, a private helper). When in doubt: regenerate. It's idempotent.

Workflow

  1. Make the endpoint or schema change.
  2. From the repo root: bun run generate:api.
  3. git diff apps/server/openapi.json apps/client/lib/api/openapi-types.ts — confirm the changes are scoped to your edit. Surprise drift means you're regenerating against a dirty tree; clean it up before committing.
  4. Commit both regenerated files in the same commit (or as a chore(<scope>): regenerate OpenAPI commit attached to the same PR).
  5. Push. CI re-runs the freshness check.

Consuming the typed client

The frontend calls the server through openapi-fetch:

import createClient from 'openapi-fetch';
import type { paths } from '@/lib/api/openapi-types';

export const api = createClient<paths>({
  baseUrl: process.env.NEXT_PUBLIC_API_URL,
  credentials: 'include',          // cookies — see /docs/architecture/contracts
});

const { data, error } = await api.GET('/api/users/{id}', {
  params: { path: { id: userId } },
});

If the server changes the path or the body shape, this call fails to compile.

Swagger UI

/docs (on the server) serves Swagger UI against the live spec. Useful for poking at endpoints during development; not a replacement for the typed client in app code.

On this page