OpenAPI
The @contract-kit/openapi package generates an OpenAPI 3.1 document from your contracts.
OpenAPI generation currently requires Zod v4 schemas. Core contracts, the server, and the client work with any Standard Schema-compatible library, but OpenAPI needs Zod so it can convert schemas to JSON Schema.
bun add @contract-kit/openapi zod
Generating a spec
Pass your contracts and metadata to contractsToOpenAPI:
import { contractsToOpenAPI } from "@contract-kit/openapi";
import { getTodo, createTodo, listTodos } from "./contracts";
const spec = contractsToOpenAPI(
[getTodo, createTodo, listTodos],
{
title: "Todo API",
version: "1.0.0",
description: "A simple todo API",
},
);
The result is a plain JavaScript object conforming to the OpenAPI 3.1 specification. Serialize it to JSON or YAML as needed.
If a contract uses non-Zod schemas, keep using it with the server and client, but leave it out of OpenAPI generation or provide an equivalent Zod contract for documented HTTP routes.
Serving from a route
Expose the spec as a JSON endpoint:
// app/api/openapi/route.ts
import { createOpenAPIHandler } from "@contract-kit/next";
import * as contracts from "@/contracts";
export const GET = createOpenAPIHandler(Object.values(contracts), {
title: "My API",
version: "1.0.0",
});
Operation metadata
Use .openapi(...) on contracts to customize generated operation metadata.
export const getTodo = todos
.get("/api/todos/:id")
.pathParams(z.object({ id: z.string() }))
.responses({
200: TodoSchema,
404: ErrorSchema,
})
.openapi({
summary: "Get a todo",
description: "Fetch one todo by ID.",
tags: ["todos"],
operationId: "getTodo",
});
The generated operationId defaults to the contract name. Set it explicitly when external clients need a stable identifier.
Security
Pass global security schemes to contractsToOpenAPI, then attach operation-level security with .openapi(...).
const spec = contractsToOpenAPI(contracts, {
title: "Todo API",
version: "1.0.0",
securitySchemes: {
bearerAuth: {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
},
},
security: [{ bearerAuth: [] }],
});
export const publicHealth = system
.get("/api/health")
.responses({ 200: HealthSchema })
.openapi({
summary: "Health check",
security: [{}],
});
Use security: [{}] to mark a route as public when the document has global security.
Deprecated operations
export const oldGetTodo = todos
.get("/api/v1/todos/:id")
.pathParams(z.object({ id: z.string() }))
.responses({ 200: TodoSchema })
.openapi({
summary: "Get a todo using the old route",
deprecated: true,
});
Options
| Option | Type | Description |
|---|---|---|
title | string | API title (required) |
version | string | API version (required) |
description | string? | API description |
servers | { url, description? }[]? | Server URLs |
securitySchemes | Record<string, OpenAPISecurityScheme>? | Auth schemes |
security | Record<string, string[]>[]? | Global security requirements |
jsonMediaType | string? | Media type for JSON bodies (default: "application/json") |
What gets generated
The generator extracts from each contract:
- Path parameters →
parameterswithin: "path" - Query parameters →
parameterswithin: "query" - Request body →
requestBodywith JSON schema - Responses → status codes with JSON schema (or empty for 204)
- Metadata →
tags,summary,description,operationIdfrom contract metadata
Schemas are placed in components/schemas and referenced via $ref to avoid duplication.