npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

express-zod-router

v1.0.0

Published

FastAPI-style routing layer for Express using Zod schemas for validation, types, and OpenAPI generation.

Readme

express-zod-router

Declare once, validate everywhere.

A FastAPI-style routing layer for Express that eliminates boilerplate by using Zod schemas as a single source of truth for validation, types, and API documentation.

The Problem

Building Express APIs is verbose and error-prone:

// ❌ Traditional Express (keep all in sync manually)
app.post(
  '/users',
  validateBody(UserSchema), // validation
  validateAuth, // middleware
  (req, res) => {
    // handler
    const user = req.body; // type: unknown
    res.json({ ...user }); // hope it matches OpenAPI
  },
);

// Separate JSDoc/OpenAPI for docs
/**
 * @route POST /users
 * @param {UserSchema} body
 */

Problems:

  • Request/response validation separate from handler
  • TypeScript types don't match runtime validation
  • OpenAPI docs require JSDoc comments or external config
  • Middleware scattered throughout the codebase
  • Adding validation + auth + docs = 3x the code

The Solution

express-zod-router solves this in one declaration:

// ✅ express-zod-router (single source of truth)

// Generic style
api.route({
  method: 'post',
  path: '/users',
  body: UserSchema,
  response: UserSchema,
  middleware: [authenticate],
  handler: (req) => {
    const user = req.body; // type: { id, name, email }
    return user;
  },
});

// Or with HTTP-method convenience shorthand
api.post('/users', {
  body: UserSchema,
  response: UserSchema,
  middleware: [authenticate],
  handler: (req) => req.body,
});

Benefits:

  • One declaration → validation, types, OpenAPI docs
  • Full TypeScript inference → safe refactoring
  • Auto-generated OpenAPI → live Swagger UI
  • Router groups & middleware → clean organization
  • Express compatible → drop-in replacement

Install

npm install express-zod-router express zod @asteasolutions/zod-to-openapi swagger-ui-express

Testing

Run the test suite with Vitest:

npm test
npm run test:watch

For a quick compile check without the full test run:

npm run build

Quick start

import express from 'express';
import { createApiRouter } from 'express-zod-router';
import { todoRoutes } from './routes/todo.routes';

const app = express();
app.use(express.json());

const api = createApiRouter({ prefix: '/api' });

api.routes([todoRoutes]);

api.docs({
  info: { title: 'My API', version: '1.0.0' },
  servers: [{ url: 'http://localhost:3000' }],
});

api.mount(app);

app.listen(3000, () => {
  console.log('API:  http://localhost:3000/api');
  console.log('Docs: http://localhost:3000/api-docs');
});
// routes/todo.routes.ts
import { z, ApiError, type ApiRouter } from 'express-zod-router';

const TodoSchema = z
  .object({
    id: z.string(),
    title: z.string().min(1),
    completed: z.boolean(),
  })
  .openapi('Todo');

export function todoRoutes(api: ApiRouter) {
  const todo = api.createRouter('/todos', ['Todos']);

  // Convenience method style
  todo.get('/:id', {
    params: z.object({ id: z.string() }),
    responses: {
      200: { schema: TodoSchema, description: 'Todo found' },
      404: { description: 'Todo not found' },
    },
    handler: (req) => {
      const found = todos.find((t) => t.id === req.params.id);
      if (!found) throw new ApiError(404, 'Todo not found');
      return found;
    },
  });
}

Why express-zod-router?

| Aspect | Express | express-zod-router | FastAPI | | ---------------- | -------------------------- | ------------------ | -------------- | | Schema | Manual JSDoc/TS interfaces | Zod schema | Pydantic | | Validation | Separate middleware | Built-in | Built-in | | Type Safety | ⚠️ Manual | ✅ Automatic | ✅ Automatic | | OpenAPI Docs | External config | Auto-generated | Auto-generated | | Middleware | Global only | Global + scoped | Built-in | | Perfect for | Minimal APIs | Async full-stack | Python async |


Core Concepts

1. Single Declaration, Three Jobs

Every route is one object: schema, validation, and documentation together. No separate app.get() + JSDoc + type interface.

const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1),
  email: z.string().email(),
});

api.route({
  method: 'post',
  path: '/users',
  body: UserSchema, // ← Validates request
  response: UserSchema, // ← Validates response + generates OpenAPI
  handler: (req) => {
    // ← req.body is typed as { id, name, email }
    return req.body;
  },
});

One schema, three results:

  1. ✅ Runtime validation (Zod at request/response time)
  2. ✅ TypeScript types (inferred from schema)
  3. ✅ OpenAPI documentation (auto-generated)

2. Zod as the Source of Truth

All request/response validation uses Zod schemas. This means:

  • Single source of truth — one place to change validation rules
  • Runtime safety — Zod validates at runtime, not just type-check time
  • Type inference — TypeScript automatically types req.body, req.params, req.query
  • OpenAPI generation — schemas feed directly into Swagger docs

3. Handlers Return Data, Not Responses

Unlike Express handlers, you don't call res.json(). Just return the data:

handler: (req) => {
  return { id: '1', title: 'Buy milk', completed: false };
};

The framework:

  1. Validates the return value against your response schema
  2. Sends back 200 OK with JSON
  3. Handles errors & validation failures automatically

You can still call res.send(), res.status(), etc. when you need full control (redirects, streaming, 204 No Content).

4. Middleware at Multiple Levels

Middleware can be attached globally, to a router group, or to a single route:

// Global: runs on all routes
const api = createApiRouter({ middleware: [requestId(), logger()] });

// Router group: runs on all routes in /auth/*
const auth = api.createRouter({
  path: '/auth',
  middleware: [rateLimiter()],
});

// Single route: runs only on this route
api.route({
  method: 'post',
  path: '/users',
  middleware: [authenticate, auditLog],
  handler: (req) => ({ ... }),
});

Middleware executes in order: global → router → route → validation → handler → response validation.


Features at a Glance

  • Type Safety — Full TypeScript inference from Zod schemas
  • Request Validation — Zod validation for body, params, query
  • Response Validation — Ensure responses match your schema
  • Auto-Generated OpenAPI — Live Swagger UI from your routes
  • Router Groups — Organize routes with createRouter(prefix, tags)
  • Multi-Level Middleware — Global, router-scoped, and route-level middleware
  • Error Handling — Unified error handler with custom ApiError
  • Express Compatible — Works with standard Express middleware
  • Zero Breaking Changes — Backwards compatible with Express
  • Production Ready — Used in production APIs

Getting Started in 5 Minutes

1. Define your schemas

import { z } from 'express-zod-router';

export const UserSchema = z
  .object({
    id: z.string().uuid(),
    name: z.string().min(1),
    email: z.string().email(),
  })
  .openapi('User');

export const CreateUserSchema = UserSchema.omit({ id: true });

2. Create route modules

// routes/users.routes.ts
import { z, type ApiRouter } from 'express-zod-router';
import { UserSchema, CreateUserSchema } from '../schemas';

export function userRoutes(api: ApiRouter) {
  const users = api.createRouter({
    path: '/users',
    tags: ['Users'],
    middleware: [authenticate], // optional
  });

  users.get('/:id', {
    params: z.object({ id: z.string().uuid() }),
    response: UserSchema,
    handler: (req) => getUserById(req.params.id),
  });

  users.post('/', {
    body: CreateUserSchema,
    response: UserSchema,
    handler: (req) => createUser(req.body),
  });
}

3. Mount and run

// main.ts
import express from 'express';
import { createApiRouter } from 'express-zod-router';
import { userRoutes } from './routes/users.routes';

const app = express();
app.use(express.json());

const api = createApiRouter({
  prefix: '/api',
  middleware: [requestId(), logger()],
});

api.routes([userRoutes]);

api.docs({
  info: { title: 'My API', version: '1.0.0' },
  servers: [{ url: 'http://localhost:3000' }],
});

api.mount(app);
app.listen(3000);

Visit:

  • API: http://localhost:3000/api
  • Docs: http://localhost:3000/api-docs

API Reference

createApiRouter(options?)

Creates a router instance with its own OpenAPI registry and optional global middleware.

const api = createApiRouter({
  prefix: '/api', // optional
  middleware: [requestId(), logger()], // optional
  openapi: {
    operationId: {
      strategy: 'rest', // 'rest' | 'handler' | 'explicit'
    },
  },
  version: {
    defaultVersion: 'v1',
    supportedVersions: ['v1', 'v2'],
    autoTag: true,
  },
  securitySchemes: {
    bearerAuth: {
      type: 'http',
      scheme: 'bearer',
      bearerFormat: 'JWT',
    },
  },
});

| Option | Type | Description | | ----------------- | ------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | | prefix | string (optional) | Prepended to every route path | | middleware | Middleware[] (opt) | Global middleware applied to all routes | | openapi | { operationId?: { strategy?: 'rest' \| 'handler' \| 'explicit' } } (optional) | OpenAPI generation options, including operationId strategy | | version | { defaultVersion?: ApiVersion, supportedVersions?: ApiVersion[], autoTag?: boolean } (optional) | Global API versioning defaults and validation | | securitySchemes | Record<string, SecuritySchemeObject> (optional) | Registers OpenAPI components.securitySchemes and enables typed security references |

Returns an ApiRouter with methods: route(), createRouter(), routes(), docs(), mount(), use(), and registry.


api.route(config)

Registers a single endpoint directly on the router (no sub-prefix).

api.route({
  method: 'get',
  path: '/health',
  response: z.object({ status: z.string() }),
  handler: () => ({ status: 'ok' }),
});

Config options:

| Option | Type | Description | | --------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | method | "get" \| "post" \| "put" \| "patch" \| "delete" | HTTP method | | path | string | Route path, e.g. /users/:id | | operationId | string (optional) | Manual OpenAPI operation ID override. If omitted, a REST-aware ID is generated automatically | | summary | string (optional) | Short label shown in Swagger UI | | description | string (optional) | Longer description shown in Swagger UI | | deprecated | boolean (optional) | Mark route as deprecated in OpenAPI spec | | version | ApiVersion \| false (optional) | Route version. Inherits global/router defaults. false disables version inheritance for this route. ApiVersion is "2", "10", "v2", etc. | | tags | string[] (optional) | Groups the route in Swagger UI | | body | ZodType \| { schema: ZodType; example?: unknown } (optional) | Validates & types req.body. When using the object form, schema is required and example appears in OpenAPI | | upload | { type: 'single'; field: string } \| { type: 'multiple'; field: string; maxFiles?: number } (optional) | Declares multipart uploads and generates multipart/form-data requestBody in OpenAPI while remaining middleware-library agnostic | | params | ZodType (optional) | Validates & types req.params | | query | ZodType (optional) | Validates & types req.query (supports z.coerce) | | security | (SecuritySchemeName \| SecurityRequirement)[] (optional) | Route-level OpenAPI security metadata. Example: ['bearerAuth'] or [{ oauth2: ['users:read'] }] | | response | ZodType \| { schema: ZodType; description?: string; example?: unknown } (optional) | Single-response shorthand — validates the return value, documents it under status | | status | number (optional) | Status code used with response. Defaults to 200 | | responseDescription | string (optional) | Swagger description used with response. Defaults to "Success" | | responses | Record<number, ResponseConfig> (optional) | Multi-response map — declare every status code the route can return (see below). Takes priority over response/status for documentation | | openapi | OpenApiOperationOverrides (optional) | Custom OpenAPI operation metadata (summary, tags, externalDocs, etc.). Merged with auto-generated content | | handler | (req, res) => any | Return the payload directly, or call res.send()/res.json() yourself |

Returns the ApiRouter instance, so calls can be chained.


api.get / post / put / patch / delete(path, config)

Convenience shorthands for api.route() that eliminate the redundant method field. Each method accepts the same config as api.route() minus method and path:

// These two are exactly equivalent
api.route({ method: 'get', path: '/users', response: UserSchema, handler: listUsers });
api.get('/users', { response: UserSchema, handler: listUsers });

All five HTTP verbs are available:

api.get('/users', { response: UserSchema.array(), handler: listUsers });
api.post('/users', { body: CreateUserSchema, response: UserSchema, handler: createUser });
api.put('/users/:id', { params: IdParams, body: UserSchema, response: UserSchema, handler: replaceUser });
api.patch('/users/:id', { params: IdParams, body: UserSchema.partial(), response: UserSchema, handler: updateUser });
api.delete('/users/:id', { params: IdParams, response: z.object({ success: z.boolean() }), handler: deleteUser });

Full type inference is preserved — req.body, req.params, req.query, and the return type are all inferred from the schemas you pass, identical to api.route().

The same convenience methods are also available on scoped routers returned by createRouter() — see the createRouter section below.

Automatic operationId

express-zod-router generates REST-aware, deterministic operation IDs automatically.

Examples:

  • GET /userslistUsers
  • GET /users/:idgetUser
  • POST /userscreateUser
  • PUT /users/:idreplaceUser
  • PATCH /users/:idupdateUser
  • DELETE /users/:iddeleteUser
  • GET /users/:id/postslistUserPosts
  • POST /users/:id/postscreateUserPost
  • GET /users/:id/posts/:postIdgetUserPost

You can override it manually:

api.route({
  method: 'get',
  path: '/users/:id',
  operationId: 'fetchUserById',
  response: UserSchema,
  handler: getUser,
});

Duplicate operationId values are rejected during registration so the generated spec stays valid.

You can configure the global operation ID strategy:

const api = createApiRouter({
  openapi: {
    operationId: {
      strategy: 'rest', // default
    },
  },
});
  • rest (default): derives IDs from method + path (contract-first)
  • handler: uses handler function name when available; falls back to REST naming
  • explicit: requires every route to set operationId

OpenAPI Metadata

Deprecated

Mark routes as deprecated in the OpenAPI spec:

api.route({
  method: 'get',
  path: '/users/old-endpoint',
  description: 'Old endpoint - use /users/v2 instead',
  deprecated: true,
  response: UserSchema.array(),
  handler: (req, res) => {
    res.json([]);
  },
});

The deprecated: true flag appears in the OpenAPI spec, warning API consumers via Swagger UI.

Request & Response Examples

Add realistic examples to requests/responses for better API documentation:

// Request example
api.route({
  method: 'post',
  path: '/users',
  body: {
    schema: z.object({
      name: z.string(),
      email: z.string().email(),
    }),
    example: {
      name: 'John Doe',
      email: '[email protected]',
    },
  },
  response: UserSchema,
  handler: (req, res) => {
    res.json({ id: 1, ...req.body });
  },
});

// Response example
api.route({
  method: 'get',
  path: '/users/:id',
  params: z.object({ id: z.coerce.number() }),
  response: {
    schema: UserSchema,
    example: {
      id: 1,
      name: 'Jane Smith',
      email: '[email protected]',
    },
  },
  handler: (req, res) => {
    res.json({ id: req.params.id, name: 'Jane', email: '[email protected]' });
  },
});

Examples appear in Swagger UI's "Example Value" sections, helping developers understand expected formats.

Custom OpenAPI Overrides

Fine-tune OpenAPI operation metadata beyond what config sugar provides:

api.route({
  method: 'get',
  path: '/users/search',
  query: z.object({ q: z.string() }),
  response: UserSchema.array(),
  openapi: {
    summary: 'Custom Summary Override',
    tags: ['search', 'users'], // merge with route tags
    externalDocs: {
      url: 'https://example.com/api/search',
      description: 'Learn more about user search',
    },
  },
  handler: (req, res) => {
    res.json([]);
  },
});

The openapi field supports any OpenAPI operation metadata (summary, tags, externalDocs, etc.). Values are merged with auto-generated content.


api.createRouter(prefix, tags?) / api.createRouter(options)

Returns a scoped route-registration function with a path prefix and default tags baked in — equivalent to FastAPI's APIRouter(prefix=..., tags=[...]).

Scoped routers support both the generic callable style and HTTP-method convenience methods:

const todo = api.createRouter('/todos', ['Todos']);

// Generic callable style
todo({
  method: 'get',
  path: '/:id', // resolves to {api prefix}/todos/:id
  handler: (req) => ({ id: req.params.id }),
});

// Convenience method style (equivalent)
todo.get('/:id', {
  handler: (req) => ({ id: req.params.id }),
});

All five HTTP methods are available on scoped routers:

const users = api.createRouter({ path: '/users', tags: ['Users'] });

users.get('/', { response: UserSchema.array(), handler: listUsers });
users.get('/:id', { params: IdParams, response: UserSchema, handler: getUser });
users.post('/', { body: CreateUserSchema, response: UserSchema, handler: createUser });
users.put('/:id', { params: IdParams, body: UserSchema, response: UserSchema, handler: replaceUser });
users.patch('/:id', { params: IdParams, body: UserSchema.partial(), response: UserSchema, handler: updateUser });
users.delete('/:id', { params: IdParams, response: z.object({ success: z.boolean() }), handler: deleteUser });

Convenience methods on scoped routers inherit the router's prefix, tags, middleware, security, version, and deprecated settings exactly as the generic callable does.

You can also pass router-level middleware and security defaults:

const todo = api.createRouter({
  version: 'v1',
  path: '/todos',
  tags: ['Todos'],
  security: ['bearerAuth'],
  deprecated: true,
  description: 'Todo endpoints',
  externalDocs: {
    url: 'https://example.com/docs/todos',
    description: 'Todo API docs',
  },
});

todo({
  method: 'get',
  path: '/private',
  response: z.object({ ok: z.boolean() }),
  handler: () => ({ ok: true }),
});

todo({
  method: 'get',
  path: '/public',
  version: false,
  security: [],
  response: z.object({ ok: z.boolean() }),
  handler: () => ({ ok: true }),
});

createRouter(options) also supports:

  • version: ApiVersion | false
    • Use version: 'v2' (or '2') to mount under that version prefix.
    • Use version: false to disable inherited versioning for this router.
  • deprecated: boolean
    • Inherited by routes in this scoped router unless route-level deprecated is explicitly set.
  • description: string
    • Applied to the OpenAPI tag metadata for each tag in tags (shows at the Swagger group header level, not as per-route description).
  • externalDocs: { url: string; description?: string }
    • Applied to OpenAPI tag metadata for each tag in tags.

When global version.autoTag is true, a version tag (like v2) is auto-added only when the route has no explicit tags. If tags are already present (for example ['Users']), the route keeps those tags and avoids duplicate grouping under both Users and v2.

Router-level metadata example:

const users = api.createRouter({
  path: '/users',
  tags: ['Users'],
  version: '2',
  deprecated: true,
  description: 'Endpoints for managing users',
  externalDocs: {
    url: 'https://example.com/docs/users',
    description: 'Users API docs',
  },
});

users({
  method: 'get',
  path: '/:id',
  response: UserSchema,
  handler: (req) => ({ id: req.params.id }),
});

In this example:

  • The route is marked deprecated unless overridden on the route.
  • description and externalDocs appear in OpenAPI tags metadata for Users.

api.version(version, options?)

Convenience helper that creates a scoped router bound to a version.

const v1 = api.version('v1', {
  tags: ['Users'],
  security: ['bearerAuth'],
});

v1({
  method: 'get',
  path: '/profile',
  response: z.object({ ok: z.boolean() }),
  handler: async () => ({ ok: true }),
});

With prefix: '/api', this route resolves to /api/v1/profile.


api.routes(modules)

Registers multiple route modules at once. A route module is any function of shape (api: ApiRouter) => void.

api.routes([userRoutes, todoRoutes, authRoutes]);

This is the recommended way to organize a real app — one file per resource, each exporting a function that registers its own routes via api.createRouter(...).


api.docs(options?)

Configures and enables OpenAPI + Swagger UI. Must be called before api.mount(app) for docs to be served.

api.docs({
  path: '/docs', // default: "/api-docs"
  jsonPath: '/docs.json', // default: "/api-docs.json"
  info: {
    title: 'My API',
    version: '1.0.0',
    description: 'My Express API',
  },
  servers: [{ url: 'http://localhost:3000', description: 'Local development' }],
  swagger: {
    explorer: true,
    customSiteTitle: 'My API Documentation',
    options: {
      swaggerOptions: {
        persistAuthorization: true,
        displayRequestDuration: true,
        filter: true,
        deepLinking: true,
        docExpansion: 'list',
        displayOperationId: true,
        tryItOutEnabled: true,
      },
    },
  },
});

| Option | Type | Description | | ---------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------- | | path | string (optional) | Swagger UI route. Default /api-docs | | jsonPath | string (optional) | Raw OpenAPI JSON route (useful for client codegen). Default /api-docs.json | | info | { title, version, description? } | OpenAPI info block | | servers | { url, description? }[] | OpenAPI servers block | | openapi | object (optional) | Raw overrides merged into the generated document | | swagger | object (optional) | Passed through to swagger-ui-express (explorer, customCss, customSiteTitle, customfavIcon, options) |

If docs() is never called, no documentation routes are mounted — useful for disabling docs in production:

if (process.env.NODE_ENV !== 'production') {
  api.docs({ info: { title: 'My API', version: '1.0.0' } });
}

api.mount(app)

Attaches every registered route (and docs, if configured) onto an Express app. Call this last, after all route() / createRouter() / routes() / docs() calls.

api.mount(app);
app.listen(3000);

api.registry

Direct access to the underlying OpenAPIRegistry instance, for advanced cases (e.g. registering shared component schemas manually).


Multipart file uploads

express-zod-router supports first-class multipart upload metadata while staying compatible with existing Express middleware (for example Multer).

Single file

import multer from 'multer';

const upload = multer({ storage: multer.memoryStorage() });

api.post('/users/avatar', {
  upload: {
    type: 'single',
    field: 'avatar',
  },
  middleware: [upload.single('avatar')],
  response: z.object({ filename: z.string(), size: z.number() }),
  handler: (req) => {
    if (!req.file) throw new ApiError(400, 'Avatar file is required');
    return {
      filename: req.file.originalname,
      size: req.file.size,
    };
  },
});

Multiple files

import multer from 'multer';

const upload = multer({ storage: multer.memoryStorage() });

api.post('/documents', {
  upload: {
    type: 'multiple',
    field: 'files',
    maxFiles: 5,
  },
  middleware: [upload.array('files', 5)],
  response: z.object({ count: z.number() }),
  handler: (req) => {
    const files = Array.isArray(req.files) ? req.files : [];
    return { count: files.length };
  },
});

File + form fields

Use upload with body when the same multipart request contains files and validated form fields:

import multer from 'multer';

const upload = multer({ storage: multer.memoryStorage() });

api.post('/products/import', {
  upload: {
    type: 'single',
    field: 'image',
  },
  body: z.object({
    name: z.string(),
    price: z.coerce.number(),
  }),
  middleware: [upload.single('image')],
  response: z.object({ ok: z.boolean() }),
  handler: (req) => {
    if (!req.file) throw new ApiError(400, 'Image is required');
    return { ok: req.body.name.length > 0 && req.body.price > 0 };
  },
});

OpenAPI behavior

When upload is configured, the route request body is documented as multipart/form-data and Swagger UI renders upload controls.

  • single creates a binary file field (type: string, format: binary)
  • multiple creates a binary array field (type: array, items: binary)
  • body + upload combines file and form schemas under multipart content

The library does not force a specific upload library at runtime.


Multiple responses per route (responses)

For endpoints that can return different shapes depending on outcome — the FastAPI responses={200: ..., 404: ...} pattern — use responses instead of response:

todo({
  method: 'patch',
  path: '/:id',
  params: TodoIdParams,
  body: CreateTodoSchema.partial(),
  responses: {
    200: { schema: TodoSchema, description: 'Todo updated successfully' },
    404: { description: 'Todo not found' },
  },
  handler: (req) => {
    const found = todos.find((t) => t.id === req.params.id);
    if (!found) throw new ApiError(404, 'Todo not found');
    Object.assign(found, req.body);
    return found;
  },
});

Each entry in responses accepts:

| Field | Type | Description | | ------------- | -------------------- | ------------------------------------------------------------ | | schema | ZodType (optional) | Documents and — for the success path — validates the payload | | description | string (optional) | Shown in Swagger UI. Defaults to "Success" | | contentType | string (optional) | Defaults to application/json |

Swagger UI will render an example for every declared status code, not just the happy path.

reply(status, body?) helper

When using responses, you can return plain success data directly. It maps to the first declared 2xx status (typically 200):

todo({
  method: 'get',
  path: '/:id',
  params: TodoIdParams,
  responses: {
    200: { schema: TodoSchema, description: 'Todo found' },
    404: { description: 'Todo not found' },
  },
  handler: (req) => {
    const found = todos.find((t) => t.id === req.params.id);
    if (!found) throw new ApiError(404, 'Todo not found');
    return found; // default success path -> 200
  },
});

Use reply(...) when you want to set a non-default status explicitly:

import { reply } from 'express-zod-router';

todo({
  method: 'get',
  path: '/:id',
  params: TodoIdParams,
  responses: {
    200: { schema: TodoSchema, description: 'Todo found' },
    404: { description: 'Todo not found' },
  },
  handler: (req) => {
    const found = todos.find((t) => t.id === req.params.id);
    if (!found) return reply(404);
    return reply(200, found);
  },
});

You can also write directly to res (return the Response):

todo({
  method: 'get',
  path: '/:id',
  params: TodoIdParams,
  responses: {
    200: { schema: TodoSchema, description: 'Todo found' },
    404: { description: 'Todo not found' },
  },
  handler: (req, res) => {
    const found = todos.find((t) => t.id === req.params.id);
    if (!found) {
      return res.status(404).json({ error: 'Todo not found' });
    }
    return res.status(200).json(found);
  },
});

If you use responses, every code path should either:

  • return plain success body (maps to first 2xx response), or
  • return reply(status, body?), or
  • return res.status(...).json(...).

This keeps TypeScript strict, so missing success paths (for example a missing 200 return) are caught at compile time.

Returning res.status(...).json(...)

Returning Express Response is supported in responses mode, as long as you return it explicitly:

todo({
  method: 'get',
  path: '/:id',
  params: TodoIdParams,
  responses: {
    200: { schema: TodoSchema, description: 'Todo found' },
    404: { description: 'Todo not found' },
  },
  handler: (req, res) => {
    const found = todos.find((t) => t.id === req.params.id);
    if (!found) {
      return res.status(404).json({ error: 'Todo not found' });
    }
    return res.status(200).json(found); // supported
  },
});

If you call res.status(...).json(...) without return, TypeScript will flag the missing return path.


Handling 204 No Content

Routes that return no body are handled explicitly — return nothing and call res.status(204).send() yourself:

todo({
  method: 'delete',
  path: '/:id',
  params: TodoIdParams,
  responses: {
    204: { description: 'Todo deleted successfully' },
    404: { description: 'Todo not found' },
  },
  handler: (req, res) => {
    const index = todos.findIndex((t) => t.id === req.params.id);
    if (index === -1) throw new ApiError(404, 'Todo not found');
    todos.splice(index, 1);
    res.status(204).send();
  },
});

Errors — ApiError

Throw ApiError inside any handler for a typed, structured error response. It's caught automatically — no try/catch needed in the handler itself.

import { ApiError } from 'express-zod-router';

throw new ApiError(404, 'Todo not found');
throw new ApiError(403, 'Forbidden', { reason: 'insufficient_role' }); // optional details

How errors resolve, in order:

| Error type | Response | | ---------------------------------------- | ------------------------------------------------------- | | Zod validation error (body/params/query) | 400 { error: "Validation failed", details: [...] } | | ApiError | { status } you passed, { error: message, details? } | | Any other Error | 500 { error: error.message } | | Non-Error thrown value | passed to Express's default error handling via next() |


Query coercion

Express query params always arrive as strings. Use z.coerce so numeric/boolean query params are typed and parsed correctly:

const PaginationQuery = z.object({
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce.number().int().positive().max(100).default(20),
});

?page=2&limit=50req.query is typed as { page: number; limit: number }, already converted — no Number(req.query.page) in the handler.


Typed handlers in a separate file (controller pattern)

TypedRequest lets you write handler functions outside the route file with full type inference, for the classic route / controller / service split:

// controllers/todo.controller.ts
import type { TypedRequest } from 'express-zod-router';
import { CreateTodoSchema } from '../schemas/todo.schema';
import { todoService } from '../services/todo.service';

export async function createTodo(req: TypedRequest<typeof CreateTodoSchema>) {
  // req.body is typed as { title: string; completed: boolean }
  return todoService.create(req.body);
}
// routes/todo.routes.ts
todo({
  method: 'post',
  path: '',
  body: CreateTodoSchema,
  response: TodoSchema,
  status: 201,
  handler: createTodo,
});

Recommended folder layout for larger apps:

schemas/      Zod schemas — single source of truth
services/     business logic, no Express/Zod imports
controllers/  thin handlers, typed via TypedRequest
routes/       wires path + schema + controller together, exports an ApiRouteModule

Client codegen from the generated spec

Since api.docs() produces a real OpenAPI document, you can generate a fully typed client for your frontend:

npx openapi-typescript http://localhost:3000/api-docs.json -o client-types.ts

License

MIT