A concise, practical reference for using @asteasolutions/zod-to-openapi to:
- maintain one source of truth (Zod schemas) for both runtime validation and OpenAPI docs.
- generate an OpenAPI v3 document from your Zod schemas.
- use the same schemas to validate Express requests.
This file is meant to be used as a quick reference or instruction set for Copilot / Cursor / Kiro assistants. Keep schemas central, descriptive, and versioned.
- Installation
- Project structure (recommended)
- Setup: enable OpenAPI helpers in Zod
- Example: reusable user schemas
- Use schemas for Express validation
- Build OpenAPI spec (registry + paths)
- Serve docs with Swagger UI
- Generate JSON/YAML in a build/CI step
- Best practices and tips
- Quick reference table
- Copilot / Cursor / Kiro instruction rules
npm install zod @asteasolutions/zod-to-openapi express zod-express-middleware swagger-ui-express
# or
yarn add zod @asteasolutions/zod-to-openapi express zod-express-middleware swagger-ui-expressYou may already have express in the project. Install zod-express-middleware (or similar) to validate incoming requests with the same Zod schemas.
src/
├─ validations/
│ └─ user.validation.ts # all user-related schemas live here
├─ routes/
│ └─ user.route.ts
├─ openapi/
│ └─ registry.ts # register schemas and path definitions
├─ server.ts
package.json
Keep all validation schemas in one place so they can be shared by runtime code and the OpenAPI generator.
Call extendZodWithOpenApi(z) once at application startup or in the validation module so you can annotate schemas with .openapi(...).
// src/validations/_zod-openapi-setup.ts
import { z } from "zod";
import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi";
extendZodWithOpenApi(z);
export { z };Then import z from this file in your validation files. This avoids repeating the extension step.
Create schemas that describe body, params, query, etc. Export the Zod objects so both the middleware and the OpenAPI registry can use them.
// src/validations/user.validation.ts
import { z } from "../validations/_zod-openapi-setup"; // our extended z
export const CreateUserBody = z.object({
name: z.string().min(2).openapi({ example: "Jane Doe", description: "Full name" }),
email: z.string().email().openapi({ example: "[email protected]" }),
password: z.string().min(6).openapi({ description: "Plain-text password (hashed on server)" }),
});
export const LoginUserBody = z.object({
email: z.string().email().openapi({ example: "[email protected]" }),
password: z.string().min(6),
});
// For zod-express-middleware it's convenient to wrap with top-level keys
export const CreateUserSchema = z.object({ body: CreateUserBody });
export const LoginUserSchema = z.object({ body: LoginUserBody });
export default {
CreateUserSchema,
LoginUserSchema,
CreateUserBody,
LoginUserBody,
};Notes:
- Keep
body,params, andqueryseparate so OpenAPI and runtime validators can reference the exact sub-schema. - Use
.openapi({ example, description })to add examples and descriptions that appear in Swagger UI.
zod-express-middleware accepts a Zod schema describing body, params or query. Use the same exported schemas.
// src/routes/user.route.ts
import express from "express";
import { validateRequest, setResponseValidationErrorHandler } from "zod-express-middleware";
import UserValidation from "../validations/user.validation";
const router = express.Router();
// Optional: global custom error format
setResponseValidationErrorHandler((err, req, res, next) => {
return res.status(400).json({
success: false,
message: "Validation failed",
details: err.errors.map(e => ({ field: e.path.join('.'), message: e.message })),
});
});
router.post("/register", validateRequest(UserValidation.CreateUserSchema), (req, res) => {
// At this point req.body is typed and validated
const { name, email } = req.body;
res.json({ success: true, user: { name, email } });
});
router.post("/login", validateRequest(UserValidation.LoginUserSchema), (req, res) => {
res.json({ success: true });
});
export default router;Behavior when validation fails:
- Default response:
{ status: "failed", errors: [ ... ] }with a400status code. - You can override the format with
setResponseValidationErrorHandler.
Use OpenAPIRegistry to register schemas and registerPath to register operations. Then generate the document with OpenApiGeneratorV3.
// src/openapi/registry.ts
import { OpenAPIRegistry, OpenApiGeneratorV3 } from "@asteasolutions/zod-to-openapi";
import UserValidation from "../validations/user.validation";
const registry = new OpenAPIRegistry();
// Register component schemas (use sub-schemas, e.g. the body object)
registry.register("CreateUser", UserValidation.CreateUserBody);
registry.register("LoginUser", UserValidation.LoginUserBody);
// Register path with request/response schemas
registry.registerPath({
method: "post",
path: "/api/users/register",
request: {
body: {
content: {
"application/json": { schema: UserValidation.CreateUserBody },
},
},
},
responses: {
200: { description: "User registered" },
400: { description: "Bad Request" },
},
});
registry.registerPath({
method: "post",
path: "/api/users/login",
request: {
body: {
content: {
"application/json": { schema: UserValidation.LoginUserBody },
},
},
},
responses: { 200: { description: "Logged in" } },
});
const generator = new OpenApiGeneratorV3(registry.definitions);
export const openApiDocument = generator.generateDocument({
openapi: "3.0.0",
info: { title: "My API", version: "1.0.0" },
});
export default registry;Important notes:
- Register component schemas with
registry.register(name, schema)when you want them to appear incomponents/schemas. - When adding references in
registerPath, you can pass the same Zod schema. The generator will use the definition.
// src/server.ts
import express from "express";
import swaggerUi from "swagger-ui-express";
import userRoutes from "./routes/user.route";
import { openApiDocument } from "./openapi/registry";
const app = express();
app.use(express.json());
app.use("/api/users", userRoutes);
app.use("/docs", swaggerUi.serve, swaggerUi.setup(openApiDocument));
app.listen(4000, () => console.log("Server running http://localhost:4000 docs at /docs"));Now /docs will show a Swagger UI built from your Zod schemas.
Add a script to generate and write the OpenAPI document to disk so CI or other tools can pick it up.
// scripts/generate-openapi.ts
import fs from "fs";
import { openApiDocument } from "../src/openapi/registry";
fs.writeFileSync("./openapi.json", JSON.stringify(openApiDocument, null, 2), "utf8");
console.log("openapi.json generated");Add to package.json:
"scripts": {
"build:openapi": "ts-node scripts/generate-openapi.ts"
}Run npm run build:openapi in your pipeline and commit the generated file or upload it to your API portal.
- One source of truth: Keep Zod schemas in a single folder and import them where needed.
- Annotate schemas: Use
.openapi({ example, description, nullable })for useful Swagger UI docs. - Prefer small sub-schemas: Export
CreateUserBodyseparately rather than embedding nested objects only inside the route schema. - Type awareness: When using TypeScript, derive
type CreateUser = z.infer<typeof CreateUserBody>for typed service layers. - Register before serving: Ensure
registry.register(...)calls run before generating the document. - Version your API: Add
info.versionand considerserversin the generated document. - Reuse components: Register shared objects like
ErrorResponseorPaginationso they appear undercomponents/schemas.
Example shared error schema:
// src/openapi/components.ts
import { z } from "../validations/_zod-openapi-setup";
export const ErrorResponse = z.object({
status: z.literal("error"),
message: z.string(),
});Register it with registry.register("ErrorResponse", ErrorResponse); and reference it in responses.
| Task | API / helper | Notes |
|---|---|---|
| Add OpenAPI metadata to a Zod field | .openapi({ example, description }) |
Call after extendZodWithOpenApi(z) |
| Expose schema for Express validation | export z.object({ body: ... }) |
Use with validateRequest(schema) |
| Register schema to components | registry.register(name, schema) |
Name appears under components/schemas |
| Add a path | registry.registerPath({ method, path, request, responses }) |
request.body.content[application/json].schema accepts a Zod schema |
| Generate document | new OpenApiGeneratorV3(registry.definitions).generateDocument(...) |
Produces OpenAPI v3 document |
Use these short rules when an AI assistant edits code or suggests changes:
-
Always import Zod from
src/validations/_zod-openapi-setup.ts. This ensures.openapi()helpers are available. -
Do not duplicate schemas. If a route needs
CreateUser, importCreateUserBodyorCreateUserSchemafromvalidations/user.validation.ts. -
When adding a new route:
- add or reuse a Zod sub-schema (
body,params,query) invalidations. - register the sub-schema with
registry.register("Name", schema)if it should appear incomponents. - add
registry.registerPath(...)with the Zod schema for request/response shape.
- add or reuse a Zod sub-schema (
-
Documentation fields: Always add
descriptionandexamplewhen introducing new properties. Keep examples realistic. -
Type exports: Generate and export
typealiases usingz.inferfor service and controller layers. -
Validation error format: Use
setResponseValidationErrorHandlerinroutesor once in bootstrap to normalize client-facing errors. -
CI step: Keep a script
scripts/generate-openapi.tsto produce a stableopenapi.json. Run it before releasing documentation or API clients.
- Missing examples in Swagger: Ensure
.openapi(...)was called and the schema used inregisterPathis the same Zod object. - Schemas not appearing under components: Call
registry.register("Name", schema)before generating the document. - Type mismatch at runtime: Use
z.infer<typeof Schema>to ensure TypeScript types match runtime schema.
# src/validations/_zod-openapi-setup.ts
import { z } from "zod";
import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi";
extendZodWithOpenApi(z);
export { z };
# src/validations/user.validation.ts
import { z } from "./_zod-openapi-setup";
export const CreateUserBody = z.object({ name: z.string().min(2).openapi({ example: 'Jane' }), email: z.string().email().openapi({ example: '[email protected]' }), password: z.string().min(6) });
export const CreateUserSchema = z.object({ body: CreateUserBody });
# src/openapi/registry.ts
import { OpenAPIRegistry, OpenApiGeneratorV3 } from "@asteasolutions/zod-to-openapi";
import { CreateUserBody } from "../validations/user.validation";
const registry = new OpenAPIRegistry();
registry.register("CreateUser", CreateUserBody);
registry.registerPath({ method: 'post', path: '/api/users/register', request: { body: { content: { 'application/json': { schema: CreateUserBody } } } }, responses: { 200: { description: 'User registered' } } });
export const openApiDocument = new OpenApiGeneratorV3(registry.definitions).generateDocument({ openapi: '3.0.0', info: { title: 'API', version: '1.0.0' } });
# src/server.ts
import express from 'express';
import swaggerUi from 'swagger-ui-express';
import userRoutes from './routes/user.route';
import { openApiDocument } from './openapi/registry';
const app = express();
app.use(express.json());
app.use('/api/users', userRoutes);
app.use('/docs', swaggerUi.serve, swaggerUi.setup(openApiDocument));
app.listen(4000);
If you want, I can also:
- generate a ready-to-drop-in
user.validation.ts,registry.ts, andserver.tsin TypeScript to paste into your repo; or - create the
scripts/generate-openapi.tsand a GitHub Actions example to publishopenapi.jsonon merge.
Tell me which you prefer and I will create the files.