OpenAPI Documentation From Code: Auto-Generate, Never Let It Drift
Generate OpenAPI 3.1 specs directly from Zod schemas in Node.js, with live API docs, typed TypeScript clients, runtime validation, and CI checks that prevent spec drift.
Senior Developer

API documentation that is maintained separately from the API is documentation that is wrong. Every refactor that renames a field, every new endpoint that gets added in a hurry, every validation rule that changes — these all widen the gap between what the docs say and what the API does.
The only documentation that stays accurate is documentation generated from the source of truth: the code itself. This guide covers generating an OpenAPI 3.1 spec from your Node.js API, serving a live interactive UI, generating TypeScript client SDKs automatically, and validating that your implementation matches the spec in CI.
The Two Approaches
Spec-first: You write the OpenAPI YAML/JSON by hand, then generate route stubs and validation from it. Good for APIs designed by committee or consumed by many external clients. Hard to maintain in fast-moving teams.
Code-first: You annotate your routes with schema definitions, and a library generates the spec. The spec is always in sync because it is derived from the code. This guide implements code-first.
Setup with Zod + @asteasolutions/zod-to-openapi
The cleanest code-first approach in 2026: define your Zod schemas once, and they generate both your runtime validation and your OpenAPI spec. No duplication.
npm install @asteasolutions/zod-to-openapi zod @scalar/express-api-reference// src/lib/openapi.ts
import {
OpenAPIRegistry,
OpenApiGeneratorV31,
extendZodWithOpenApi,
} from '@asteasolutions/zod-to-openapi';
import { z } from 'zod';
// Extend Zod with OpenAPI metadata methods
extendZodWithOpenApi(z);
export const registry = new OpenAPIRegistry();Defining Schemas With OpenAPI Metadata
// src/schemas/userSchemas.ts
import { z } from 'zod';
import { registry } from '../lib/openapi';
// Register reusable schemas — they appear in the $components section
export const UserSchema = registry.register(
'User',
z.object({
id: z.string().uuid().openapi({ example: '550e8400-e29b-41d4-a716-446655440000' }),
email: z.string().email().openapi({ example: 'jane@example.com' }),
fullName: z.string().openapi({ example: 'Jane Smith' }),
role: z.enum(['user', 'admin']).openapi({ example: 'user' }),
createdAt: z.string().datetime().openapi({ example: '2026-01-15T08:00:00Z' }),
}).openapi('User')
);
export const CreateUserSchema = registry.register(
'CreateUser',
z.object({
email: z.string().email().openapi({ example: 'jane@example.com' }),
fullName: z.string().min(1).max(100).openapi({ example: 'Jane Smith' }),
password: z.string().min(10).openapi({ example: 'SecurePass123!' }),
}).openapi('CreateUser')
);
export const PaginatedUsersSchema = registry.register(
'PaginatedUsers',
z.object({
data: z.array(UserSchema),
pagination: z.object({
hasNextPage: z.boolean(),
nextCursor: z.string().nullable(),
}),
}).openapi('PaginatedUsers')
);
// Error schema — reused across all error responses
export const ErrorSchema = registry.register(
'Error',
z.object({
error: z.object({
message: z.string().openapi({ example: 'Resource not found' }),
code: z.string().openapi({ example: 'NOT_FOUND' }),
requestId: z.string().openapi({ example: 'req_abc123' }),
}),
}).openapi('Error')
);Registering Routes
Register each route's schema in the OpenAPI registry alongside the route definition:
// src/routes/users.ts
import { registry } from '../lib/openapi';
import {
UserSchema, CreateUserSchema,
PaginatedUsersSchema, ErrorSchema,
} from '../schemas/userSchemas';
// Register the route with full OpenAPI metadata
registry.registerPath({
method: 'get',
path: '/api/users',
summary: 'List users',
description: 'Returns a paginated list of users for the current tenant.',
tags: ['Users'],
security: [{ bearerAuth: [] }],
request: {
query: z.object({
limit: z.coerce.number().int().min(1).max(100).default(20)
.openapi({ description: 'Number of results per page' }),
cursor: z.string().optional()
.openapi({ description: 'Pagination cursor from previous response' }),
}),
},
responses: {
200: {
description: 'Paginated list of users',
content: {
'application/json': { schema: PaginatedUsersSchema },
},
},
401: {
description: 'Not authenticated',
content: { 'application/json': { schema: ErrorSchema } },
},
},
});
// The actual route handler — same Zod schemas for runtime validation
router.get('/', authenticate, validate(ListUsersQuerySchema, 'query'), async (req, res) => {
const users = await listUsers(req.tenant.id, req.query);
res.json(users);
});
// POST /users
registry.registerPath({
method: 'post',
path: '/api/users',
summary: 'Create user',
tags: ['Users'],
security: [{ bearerAuth: [] }],
request: {
body: {
content: {
'application/json': { schema: CreateUserSchema },
},
required: true,
},
},
responses: {
201: {
description: 'User created',
content: { 'application/json': { schema: UserSchema } },
},
400: {
description: 'Validation error',
content: { 'application/json': { schema: ErrorSchema } },
},
409: {
description: 'Email already exists',
content: { 'application/json': { schema: ErrorSchema } },
},
},
});
router.post('/', authenticate, validate(CreateUserSchema), async (req, res) => {
const user = await createUser(req.tenant.id, req.body);
res.status(201).json(user);
});Generating and Serving the Spec
// src/lib/generateSpec.ts
import { OpenApiGeneratorV31 } from '@asteasolutions/zod-to-openapi';
import { registry } from './openapi';
export function generateOpenAPISpec() {
const generator = new OpenApiGeneratorV31(registry.definitions);
return generator.generateDocument({
openapi: '3.1.0',
info: {
title: 'Your API',
version: process.env.APP_VERSION || '1.0.0',
description: 'Complete API reference for Your App',
contact: {
name: 'API Support',
email: 'api@yourapp.com',
},
},
servers: [
{
url: process.env.API_URL || 'http://localhost:3000',
description: process.env.NODE_ENV === 'production' ? 'Production' : 'Development',
},
],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'Access token obtained from /api/auth/login',
},
},
},
});
}// src/routes/docs.ts
import { generateOpenAPISpec } from '../lib/generateSpec';
import { apiReference } from '@scalar/express-api-reference';
// Serve the raw spec as JSON
router.get('/openapi.json', (req, res) => {
// Only expose in non-production, or behind auth in production
if (process.env.NODE_ENV === 'production' && !req.user?.role === 'admin') {
return res.status(404).end();
}
res.json(generateOpenAPISpec());
});
// Serve Scalar's interactive API reference UI
router.use('/docs',
apiReference({
spec: { url: '/openapi.json' },
theme: 'purple',
})
);Navigate to /docs — you get a full interactive API reference where every endpoint is documented with request/response schemas, example values, and a "Try it" button that fires real requests.
Generating TypeScript Client SDKs
With an accurate OpenAPI spec, you can auto-generate a TypeScript client that your frontend can import directly:
npm install -D openapi-typescript openapi-fetch# Generate TypeScript types from the spec
npx openapi-typescript http://localhost:3000/openapi.json -o src/types/api.tsThis generates a complete TypeScript representation of your API:
// Generated — do not edit manually
export interface paths {
'/api/users': {
get: {
parameters: {
query?: { limit?: number; cursor?: string };
};
responses: {
200: { content: { 'application/json': components['schemas']['PaginatedUsers'] } };
401: { content: { 'application/json': components['schemas']['Error'] } };
};
};
post: {
requestBody: {
content: { 'application/json': components['schemas']['CreateUser'] };
};
responses: {
201: { content: { 'application/json': components['schemas']['User'] } };
};
};
};
// ... all other paths
}Use it with openapi-fetch for a fully typed HTTP client:
// Frontend — fully typed, no manual type definitions
import createClient from 'openapi-fetch';
import type { paths } from '../types/api';
const client = createClient<paths>({
baseUrl: 'https://yourapi.com',
headers: { Authorization: `Bearer ${getToken()}` },
});
// TypeScript knows the exact response shape
const { data, error } = await client.GET('/api/users', {
params: { query: { limit: 20 } },
});
// data is PaginatedUsers — fully typed, no casting needed
console.log(data?.data[0].fullName);Validating the Spec in CI
Add a step that regenerates the spec and fails if it differs from what is committed. This catches undocumented API changes before they reach production.
# In CI
npm run generate:spec
git diff --exit-code src/openapi.json
# Fails if the spec has changed but the file was not updated// package.json scripts
{
"generate:spec": "tsx src/scripts/generateSpec.ts > src/openapi.json",
"validate:spec": "git diff --exit-code src/openapi.json"
}// src/scripts/generateSpec.ts
import { generateOpenAPISpec } from '../lib/generateSpec';
// Import all routes to ensure registrations run
import '../routes/users';
import '../routes/orders';
import '../routes/products';
process.stdout.write(JSON.stringify(generateOpenAPISpec(), null, 2));What Good API Documentation Gets You
A living OpenAPI spec is not just documentation. It is:
A contract — your frontend team knows exactly what to expect from every endpoint
A mock server — tools like Prism can serve a mock API from the spec before the backend is built
A test suite — Schemathesis and Dredd can run conformance tests that verify your implementation matches the spec
A client generator — SDKs for any language from a single source of truth
An onboarding tool — new developers understand the entire API surface in an afternoon
The investment is the time to register your schemas. The return is every one of the above, automatically maintained.
Comments (0)
Login to post a comment.