Swagger and OpenAPI 3.1 in Node.js — API Documentation Guide 2026

Sanjeev SharmaSanjeev Sharma
5 min read

Advertisement

Introduction

Why OpenAPI Documentation Matters

Undocumented APIs are black boxes. OpenAPI 3.1 provides a machine-readable contract that powers interactive Swagger UI, generates client SDKs in any language, enables API gateway configuration, and integrates with Postman collections automatically.

The best API documentation is generated from code — it stays in sync without manual updates.

Approach 1 — swagger-jsdoc (JSDoc Comments)

npm install swagger-jsdoc swagger-ui-express
npm install --save-dev @types/swagger-jsdoc @types/swagger-ui-express
import swaggerJsdoc from 'swagger-jsdoc';
import swaggerUi    from 'swagger-ui-express';
import { Express }  from 'express';
 
const spec = swaggerJsdoc({
  definition: {
    openapi: '3.1.0',
    info: {
      title:       'My API',
      version:     '2.0.0',
      description: 'REST API built with Node.js and TypeScript',
      contact: { name: 'API Team', email: 'api@example.com' },
    },
    servers: [
      { url: 'https://api.example.com/v2', description: 'Production' },
      { url: 'http://localhost:3000/api',  description: 'Development' },
    ],
    components: {
      securitySchemes: {
        bearerAuth: {
          type:         'http',
          scheme:       'bearer',
          bearerFormat: 'JWT',
        },
      },
    },
    security: [{ bearerAuth: [] }],
  },
  apis: ['./src/routes/**/*.ts', './src/schemas/**/*.ts'],
});
 
export function setupSwagger(app: Express) {
  app.use('/docs', swaggerUi.serve, swaggerUi.setup(spec, {
    customSiteTitle: 'My API Docs',
    swaggerOptions: { persistAuthorization: true },
  }));
  app.get('/docs.json', (_req, res) => res.json(spec));
}
/**
 * @openapi
 * /api/users:
 *   get:
 *     tags: [Users]
 *     summary: List all users
 *     description: Returns paginated list of users
 *     parameters:
 *       - in: query
 *         name: page
 *         schema: { type: integer, minimum: 1, default: 1 }
 *       - in: query
 *         name: limit
 *         schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
 *     responses:
 *       200:
 *         description: Successful response
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 data:
 *                   type: array
 *                   items: { $ref: '#/components/schemas/User' }
 *                 meta:
 *                   $ref: '#/components/schemas/Pagination'
 *       401:
 *         $ref: '#/components/responses/Unauthorized'
 */
router.get('/users', authenticate, getUsers);

Generate OpenAPI schemas directly from your Zod validation schemas — single source of truth:

npm install @asteasolutions/zod-to-openapi zod
import { extendZodWithOpenApi, OpenApiGeneratorV31, OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
import { z } from 'zod';
 
extendZodWithOpenApi(z);
 
const registry = new OpenAPIRegistry();
 
// Define schemas with OpenAPI metadata
const UserSchema = registry.register(
  'User',
  z.object({
    id:        z.number().openapi({ example: 1 }),
    name:      z.string().min(2).openapi({ example: 'Alice Smith' }),
    email:     z.string().email().openapi({ example: 'alice@example.com' }),
    role:      z.enum(['user', 'admin']).openapi({ example: 'user' }),
    createdAt: z.string().datetime().openapi({ example: '2026-03-01T00:00:00Z' }),
  })
);
 
const CreateUserSchema = registry.register(
  'CreateUser',
  z.object({
    name:     z.string().min(2).openapi({ example: 'Alice Smith' }),
    email:    z.string().email().openapi({ example: 'alice@example.com' }),
    password: z.string().min(8).openapi({ example: 'secureP@ss1' }),
  })
);
 
const PaginationSchema = registry.register(
  'Pagination',
  z.object({
    total: z.number(),
    page:  z.number(),
    limit: z.number(),
    pages: z.number(),
  })
);
 
// Register bearer auth
registry.registerComponent('securitySchemes', 'bearerAuth', {
  type:         'http',
  scheme:       'bearer',
  bearerFormat: 'JWT',
});
 
// Register endpoints
registry.registerPath({
  method:  'get',
  path:    '/api/users',
  tags:    ['Users'],
  summary: 'List users',
  request: {
    query: z.object({
      page:  z.string().optional().openapi({ example: '1' }),
      limit: z.string().optional().openapi({ example: '20' }),
    }),
  },
  responses: {
    200: {
      description: 'Paginated user list',
      content: {
        'application/json': {
          schema: z.object({ data: z.array(UserSchema), meta: PaginationSchema }),
        },
      },
    },
  },
});
 
registry.registerPath({
  method:   'post',
  path:     '/api/users',
  tags:     ['Users'],
  summary:  'Create user',
  request:  { body: { content: { 'application/json': { schema: CreateUserSchema } } } },
  responses: {
    201: { description: 'User created', content: { 'application/json': { schema: z.object({ user: UserSchema }) } } },
    409: { description: 'Email already exists' },
  },
});
 
// Generate spec
const generator = new OpenApiGeneratorV31(registry.definitions);
export const openApiSpec = generator.generateDocument({
  openapi: '3.1.0',
  info: { title: 'My API', version: '2.0.0' },
  servers: [{ url: 'https://api.example.com' }],
});
// Mount swagger UI
import swaggerUi from 'swagger-ui-express';
import { openApiSpec } from './openapi';
 
app.use('/docs', swaggerUi.serve, swaggerUi.setup(openApiSpec));
app.get('/openapi.json', (_req, res) => res.json(openApiSpec));

Reusing Schemas for Validation

import { z } from 'zod';
 
// Same schema used for OpenAPI docs AND request validation
export const createUserSchema = z.object({
  name:     z.string().min(2),
  email:    z.string().email(),
  password: z.string().min(8),
});
 
export type CreateUserInput = z.infer<typeof createUserSchema>;
 
// Route uses schema for validation
router.post('/users', async (req, res) => {
  const result = createUserSchema.safeParse(req.body);
  if (!result.success) {
    return res.status(400).json({ errors: result.error.flatten() });
  }
  const user = await userService.create(result.data);
  res.status(201).json({ user });
});

Common Mistakes

  • Writing OpenAPI specs manually in YAML — they drift from implementation immediately
  • Not documenting error responses — clients cannot handle 401, 404, 422 without knowing the shape
  • Exposing /docs in production without authentication — your internal API surface becomes public
  • Using any types in schemas — defeats the purpose of typed documentation
  • Generating docs on every request — generate once at startup and cache the spec object

Best Practices

  • Use zod-to-openapi to derive OpenAPI schemas from Zod schemas — single source of truth
  • Protect /docs in production behind an IP allowlist or basic auth middleware
  • Serve openapi.json at a stable URL so Postman, gateway configs, and CI tools can consume it
  • Add example values to all schema fields — they appear in Swagger UI and make the API self-explanatory
  • Run swagger-parser in CI to validate the spec on every PR: openapi-schema-validator

Key Takeaways

  • OpenAPI 3.1 is the current standard for REST API documentation — prefer it over older 3.0 or Swagger 2.0
  • swagger-jsdoc generates specs from JSDoc comments; zod-to-openapi derives specs from Zod schemas
  • Zod-to-OpenAPI is the recommended approach in 2026 — one schema definition serves both validation and docs
  • Interactive Swagger UI at /docs lets frontend teams explore endpoints without reading source code
  • Generated openapi.json enables automatic client SDK generation in TypeScript, Python, Go, etc.
  • Always document error responses with shapes — not just 200 OK
  • Protect docs endpoints in production to avoid exposing your API surface to attackers
  • Validate the generated spec in CI with a linter to catch schema errors before deployment

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading