ZyVOP Logo
Content That Connects
SeriesCategoriesTags
ZyVOP Logo
Content That Connects

Empowering developers and creators with cutting-edge insights, comprehensive tutorials, and innovative solutions for the digital future.

Content

  • Tags
  • Write Article

Company

  • About Us
  • Contact

Connect

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

© 2026 ZyVOP. Crafted with care for the developer community.

Made with ❤️ by the ZyVOP team
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.

#OpenAPI Node.js 2026#zod-to-openapi#OpenAPI TypeScript code-first#Scalar API reference#openapi-typescript client#OpenAPI Express#auto-generate OpenAPI spec#TypeScript API documentation
Z
ZyVOP

Senior Developer

May 27, 2026
7 min read
0 views
OpenAPI Documentation From Code: Auto-Generate, Never Let It Drift

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

Z

ZyVOP

Passionate developer sharing knowledge about modern web technologies and best practices.

Comments (0)

Login to post a comment.

Stay Updated

Get the latest articles delivered to your inbox.

We respect your privacy. Unsubscribe anytime.

Popular Tags

#.env.example Node.js#0x profiling#12-factor#AI agents#AI code security#AI coding tools 2026#AI-assisted development#AI-generated vulnerabilities#ALTER TABLE no lock#API Design