Last verified: 2026-05-05
The HTTP contract is generated from TypeScript route contract metadata and Zod schemas. Domain runtime layers own their route contracts because routes are the HTTP boundary, and the generated frontend client imports only client-safe domain types. The generated client also exposes TanStack Query option factories so UI code can share query keys, query functions, mutation keys, and mutation functions without hand-written wrappers.
| What | Where |
|---|---|
| OpenAPI document builder | src/providers/openapi/ |
| App route contract registry | src/api-contracts.ts |
| Example domain route contracts | src/domains/example/runtime/contract.ts |
| Generated OpenAPI spec | src/generated/openapi.generated.json |
| Generated frontend client and TanStack Query helpers | src/generated/api-client.generated.ts |
| Command | Purpose |
|---|---|
pnpm api:generate |
Regenerate the OpenAPI JSON document and frontend client |
pnpm api:check |
Fail when generated API artifacts are stale |
pnpm build runs pnpm api:check before TypeScript and Vite so stale generated API artifacts fail CI.
- Add or update request, response, and parameter schemas in the domain
types/layer. Use JSON-serializable response schemas for HTTP payloads; for example, date-time fields should be strings in response schemas even if service-layer domain entities useDate. - Add or update the route contract in the domain
runtime/contract.ts. - Implement the Fastify route in the domain
runtime/routes.ts. - Run
pnpm api:generate. - Use
apiQueries,apiMutations, andapiQueryKeysfromsrc/generated/api-client.generated.tsin UI code instead of hand-writtenfetch,queryKey,queryFn, ormutationFnwrappers.
Each domain that exposes HTTP routes should have a runtime/contract.ts file that exports one readonly array named after the domain, such as itemRouteContracts. The array must satisfy readonly ApiRouteContract[].
import type { ApiRouteContract } from "@providers/openapi/index.js";
import { z } from "zod";
import {
CreateThingSchema,
ThingIdSchema,
ThingResponseSchema,
} from "../types/index.js";
const ErrorResponseSchema = z.object({
error: z.string(),
});
const ThingParamsSchema = z.object({
id: ThingIdSchema,
});
const thingTypeImports = [
{
kind: "type",
module: "../domains/example/types/index.js",
names: ["CreateThing", "ThingResponse"],
},
] as const;
const thingResponseSchemaImports = [
{
kind: "value",
module: "../domains/example/types/index.js",
names: ["ThingResponseSchema"],
},
] as const;
export const thingRouteContracts = [
{
method: "post",
operationId: "createThing",
path: "/api/things",
requestBody: CreateThingSchema,
responses: {
201: { description: "Created thing", schema: ThingResponseSchema },
400: { description: "Invalid thing data", schema: ErrorResponseSchema },
},
summary: "Create thing",
tags: ["things"],
client: {
functionName: "createThing",
imports: [...thingTypeImports, ...thingResponseSchemaImports],
requestBodyType: "CreateThing",
responseParser: "ThingResponseSchema",
responseType: "ThingResponse",
},
},
] as const satisfies readonly ApiRouteContract[];| Field | Required | Purpose |
|---|---|---|
method |
Yes | Lowercase HTTP method: get, post, put, patch, or delete. |
operationId |
Yes | Stable OpenAPI operation name. Use a verb phrase like createItem; do not reuse within the app. |
path |
Yes | Fastify-style route path. Use :id path parameters; the OpenAPI builder converts them to {id}. |
pathParams |
When the path has params | Zod object for path params. Keys must match every :param segment in path. |
requestBody |
For JSON body routes | Zod schema for the request JSON body. Omit for bodyless routes. |
responses |
Yes | Map of HTTP status codes to response descriptions and optional Zod response schemas. Omit schema for empty responses like 204. |
summary |
Yes | Short human-readable OpenAPI summary. |
tags |
Recommended | OpenAPI grouping tags, usually the domain or resource name. |
client |
For browser-callable routes | Metadata used to generate the frontend client function. Omit only for routes that should not be called from browser UI. |
client controls the generated function in src/generated/api-client.generated.ts.
| Field | Required | Purpose |
|---|---|---|
functionName |
Yes | Method name on apiClient, such as apiClient.createItem. |
imports |
When client types or parsers are referenced | Type/value imports emitted into the generated client. Paths are relative to src/generated/api-client.generated.ts. |
pathParamsType |
When the path has params | TypeScript type for the generated params argument, usually { id: string }. |
requestBodyType |
When requestBody exists |
TypeScript type for the generated body argument. |
responseType |
Yes | Promise result type for the generated client method. Use void for 204-only success responses. |
responseParser |
For JSON responses | Zod parser expression used by the generated client at the browser boundary, such as ItemResponseSchema or ItemResponseSchema.array(). |
Use kind: "type" imports for TypeScript-only names and kind: "value" imports for Zod schemas referenced by responseParser.
client: {
functionName: "listItems",
imports: [...itemTypeImports, ...itemResponseSchemaImports],
responseParser: "ItemResponseSchema.array()",
responseType: "ItemResponse[]",
}For a DELETE route that returns 204, omit responseParser and use responseType: "void".
The generator emits three TanStack-oriented surfaces:
| Export | Purpose |
|---|---|
apiQueryKeys |
Stable query key factories for GET routes. Use these for invalidation and cache reads. |
apiQueries |
queryOptions(...) factories for GET routes. Pass these directly to useQuery, useSuspenseQuery, prefetchQuery, or useQueries. |
apiMutations |
mutationOptions(...) factories for non-GET routes. Pass or spread these into useMutation. |
Use these helpers in UI code:
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
apiMutations,
apiQueries,
apiQueryKeys,
} from "../../../generated/api-client.generated.js";
const itemsQuery = useQuery(apiQueries.listItems());
const createMutation = useMutation({
...apiMutations.createItem(),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: apiQueryKeys.listItems() });
},
});GET routes generate apiQueries.<functionName>(). If the route has path params, the params become the first argument:
useQuery(apiQueries.getItem({ id }));Non-GET routes generate apiMutations.<functionName>(). Mutation variables match the generated client method shape:
- body-only routes use the body value as mutation variables
- path-param-only routes use the params object as mutation variables
- routes with both path params and body use
{ params, body } - bodyless and paramless routes use no mutation variables
createMutation.mutate({ name: "New item", status: "draft" });
deleteMutation.mutate({ id });The generated helpers call the generated apiClient, so response parsing and ApiClientError behavior stay centralized.
- Request schemas should describe the raw JSON sent by clients.
- Response schemas should describe the JSON returned over HTTP, not necessarily the service-layer domain entity. If the service uses
Date, the response schema should usually usez.iso.datetime()because JSON carries strings. - Error responses should use explicit schemas, commonly
z.object({ error: z.string() }), so OpenAPI documents failure shapes too. - Path parameter schemas should reuse domain value schemas such as
ItemIdSchema. - Do not import
repo,service, oruicode fromruntime/contract.ts; contracts should depend ontypesand provider contract types only.
After adding a domain contract, register it in src/api-contracts.ts.
import { itemRouteContracts } from "./domains/example/runtime/contract.js";
import { thingRouteContracts } from "./domains/thing/runtime/contract.js";
export const apiRouteContracts = [...itemRouteContracts, ...thingRouteContracts] as const;The server and generator both consume apiRouteContracts, so this is the single app-level registry for OpenAPI and client generation.
The contract does not register Fastify handlers. Keep handler implementation in the domain runtime/routes.ts, and make sure the actual route behavior matches the contract:
- same
methodandpath - same success status codes
- same request body validation
- same path parameter validation
- same error response shape
/openapi.json is served by src/app-server.ts from the registered contracts.
When changing contracts, verify all of the following:
runtime/contract.tshas co-located tests for operation IDs, client function names, and any serialization-sensitive schemas.runtime/routes.tshas tests or integration coverage for boundary validation and status codes.pnpm api:generateupdates both generated files.pnpm api:checkpasses without rewriting artifacts.- UI code imports
apiQueries,apiMutations,apiQueryKeys,apiClient, or generated exported types fromsrc/generated/api-client.generated.ts.