ZyVOP Logo
Content That Connects
SeriesAI NewsWhy ZyVOPJoin Discord
LoginGet Started
ZyVOP Logo
Content That Connects

The Developer Publishing Hub. Write once, cross-post to Dev.to, Medium, Hashnode, WordPress & Bluesky with automated canonical source tags and zero paywalls.

Content

  • Categories
  • Tags
  • Badges
  • Leaderboard
  • Write Article
  • Newsletter

Company

  • About Us
  • Why ZyVOP
  • Developer API & CLI
  • Write for Us
  • Contact

Connect

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • DMCA Policy
  • Code of Conduct

© 2026 ZyVOP. Developer Publishing Hub.

Zero paywalls · Full content ownership
All systems operational
HomeOpenAPI Documentation From Code: Auto-Generate, Never Let It Drift

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.

ZyVOP
ZyVOP
Senior Developer
May 27, 2026
6 min read
OpenAPI Documentation From Code: Auto-Generate, Never Let It Drift
#zod-to-openapi#auto-generate OpenAPI spec#OpenAPI Express#OpenAPI Node.js 2026#TypeScript API documentation#OpenAPI TypeScript code-first#openapi-typescript client#Scalar API reference
👍1

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: '[email protected]' }),
    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: '[email protected]' }),
    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: '[email protected]',
      },
    },
    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.ts

This 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.

ZyVOP
ZyVOP

Founder of Zyvop 🚀 | Building AI-driven tools & premium insights for software engineers, CTOs, and tech leaders. Obsessed with automating workflows and exploring the frontier of AI.

Subscribe to ZyVOP's Newsletter

More from ZyVOP

View profile

Debian Adopts "Responsible Use of Generative AI" After Nine-Way Condorcet Vote

Debian's General Resolution 2026-002 closed on August 28 with "Responsible Use of Generative AI" beating eight rival proposals, including a Social Contract ban, by a clear Condorcet margin, per the project secretary's published beat matrix.

3 minAug 30

How I Built a Real-Time Developer Trend Radar Into My SEO Growth Engine

An AI-powered content intelligence system that streams live developer conversations from Hacker News, Dev.to, Google Search, and GitHub — and turns them into ready-to-write blog opportunities with one click.

12 minAug 29

Qwen3.8-Flash-Next Cost Efficiency, OpenExecutive Satire, and Multi-Vector Retrieval Advances

This week's digest covers Qwen3.8-Flash-Next's push for ultimate cost-efficiency, the viral OpenExecutive project, and the technical release of MultiVectorEncoder in Sentence-Transformers v6.0.

4 minAug 28

Anthropic’s Pricing Shock, Granite 4.2 Open‑Source Leap, and AI‑Powered Security & Policy Shifts

From Anthropic’s flagship model losing steam to IBM’s 512 K‑token Granite 4.2, plus a new wave of AI‑driven security exploits and policy alarms, this week’s digest maps the technical and market forces you need to act on now.

3 minAug 26

Introducing Questions and Discussions: A New Way to Connect!

We are thrilled to announce a major update to how you can interact and share content on our platform! Up until now, sharing your thoughts meant writing a standa...

2 minAug 1