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

OptionTypeDescription
titlestringAPI title (required)
versionstringAPI version (required)
descriptionstring?API description
servers{ url, description? }[]?Server URLs
securitySchemesRecord<string, OpenAPISecurityScheme>?Auth schemes
securityRecord<string, string[]>[]?Global security requirements
jsonMediaTypestring?Media type for JSON bodies (default: "application/json")

What gets generated

The generator extracts from each contract:

Schemas are placed in components/schemas and referenced via $ref to avoid duplication.