SaaS Starter
Architecture

API contract & OpenAPI

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

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