


Start your API server with I/O schema validation and custom middlewares in minutes.
- Overview
- How it works
- Quick start — Fast Track
- Basic features
- Routing including static file serving
- Middlewares
- Endpoints factory
- Context
- Using native express middlewares
- Refinements
- Query string parser
- Transformations
- Top level transformations and mapping
- Dealing with dates
- Pagination
- Cross-Origin Resource Sharing (CORS)
- Enabling HTTPS
- Enabling compression
- Customizing logger
- Child logger
- Advanced features
- Customizing input sources
- Headers as an input source
- Cookies
- Caching
- Rate limiting
- Response customization
- Empty response
- Non-JSON response including file downloads
- Error handling
- Production mode
- HTML Forms (URL encoded)
- File uploads
- Connect to your own express app
- Testing endpoints
- Testing middlewares
- Integration and Documentation
- Zod Plugin
- End-to-End Type Safety
- Creating documentation
- Tagging the endpoints
- Deprecated schemas and routes
- Customizable brands handling
- Special needs
- Different responses for different status codes
- Array response for migrating legacy APIs
- Accepting raw data
- Profiling
- Graceful shutdown
- Subscriptions
- Caveats
- Excessive properties in endpoint output
- Your input to my output
See also Changelog and automated migration.
I made this framework because of the often repetitive tasks of starting web server APIs with the need to validate input
data. It integrates and provides the capabilities of popular web server, logging, validation, and documenting solutions.
Therefore, many basic tasks can be achieved faster and easier, in particular:
- You can describe web server routes as a hierarchical object.
- You can keep the endpoint's input and output type declarations right next to its handler.
- All input and output data types are validated, so it ensures you won't have an empty string, null or undefined where
you expect a number.
- Variables within an endpoint handler have types according to the declared schema, so your IDE and TypeScript will
provide you with necessary hints to focus on bringing your vision to life.
- All of your endpoints can process requests and respond consistently.
- The expected endpoint input and response types can be exported to the frontend, giving you end-to-end type safety
so you don't get confused about the field names when you implement the client for your API.
- You can generate your API documentation in OpenAPI 3.2 and JSON Schema compatible format.
These people contributed to the improvement of the framework by reporting bugs, making changes, and suggesting ideas:

The API operates object schemas for input and output validation.
The object being validated is the combination of certain request properties.
It is available to the endpoint handler as the input parameter.
Middlewares have access to all request properties, they can provide endpoints with ctx (context).
The object returned by the endpoint handler is called output. It goes to the ResultHandler which is
responsible for transmitting consistent responses containing the output or possible error.
Much can be customized to fit your needs.

- Typescript first.
- Web server — Express.js v5.
- Schema validation — Zod 4.x;
- Supports any logger having
info(), debug(), error() and warn() methods;
- Built-in console logger with colorful and pretty inspections by default.
- Generators:
- File uploads — Express-FileUpload
(based on Busboy).
Install the framework, its peer dependencies and type assistance packages using your favorite
package manager.
# example for pnpm:
pnpm add express-zod-api express zod http-errors
pnpm add -D @types/express @types/node @types/http-errors
Enable the following compilerOptions in your tsconfig.json to make it work as expected:
{
"compilerOptions": {
"strict": true,
"skipLibCheck": true
}
}
Create a minimal configuration. Find out all configurable options
in sources.
import { createConfig } from "express-zod-api";
const config = createConfig({
http: { listen: 8090 }, // port, UNIX socket or Net::ListenOptions
cors: false, // decide whether to enable CORS
});
Create your first endpoint
Use the default factory to make an endpoint that responds with "Hello, World" or "Hello, {name}" depending on inputs.
Learn how to add middlewares or customize responses.
import { defaultEndpointsFactory } from "express-zod-api";
import { z } from "zod";
const helloWorldEndpoint = defaultEndpointsFactory.build({
// method: "get" (default) or array ["get", "post", ...]
input: z.object({
name: z.string().optional(),
}),
output: z.object({
greetings: z.string(),
}),
handler: async ({ input: { name }, ctx, logger }) => {
logger.debug("Context:", ctx); // middlewares provide ctx
return { greetings: `Hello, ${name || "World"}. Happy coding!` };
},
});
Connect your endpoint to the /v1/hello route:
import type { Routing } from "express-zod-api";
const routing: Routing = {
v1: {
hello: helloWorldEndpoint,
},
};
See the complete implementation example.
import { createServer } from "express-zod-api";
createServer(config, routing);
Start your application and execute the following command:
curl -L -X GET 'localhost:8090/v1/hello?name=Rick'
You should receive the following response:
{ "status": "success", "data": { "greetings": "Hello, Rick. Happy coding!" } }
The framework offers flexible ways to define your routes, supporting both nested and flat syntaxes, dynamic path
parameters, method-based routing, and static file serving. This example brings together all supported routing styles
in one place, illustrating how you can structure your API using whichever method best fits your application’s
architecture — or even mix them seamlessly.
import { Routing, ServeStatic } from "express-zod-api";
const routing: Routing = {
// flat syntax — /v1/users
"/v1/users": listUsersEndpoint,
// nested syntax
v1: {
// the way to have both — /v1/path and /v1/path/subpath
path: endpointA.nest({
subpath: endpointB,
}),
// path parameters — /v1/user/:id
user: {
":id": getUserEndpoint,
},
// mixed syntax with explicit method — /v1/user/:id
"delete /user/:id": deleteUserEndpoint,
// method-based routing — /v1/account
account: {
get: endpointA,
delete: endpointA,
post: endpointB,
patch: endpointB,
},
},
// static file serving — /public serves files from ./assets
public: new ServeStatic("assets", {
/** @see https://expressjs.com/en/5x/api.html#express.static */
dotfiles: "deny",
index: false,
redirect: false,
}),
};
The same Endpoint can be reused on different routes or handle multiple methods. Path parameters (the :id above)
should be declared in the endpoint’s input schema. Properties assigned with Endpoint can explicitly declare a method.
If no method is specified, the methods supported by the endpoint are used (or get as a fallback).
Middlewares preprocess the incoming requests. For example, authenticate using aggregated input or headers. Middleware
returns become a Context available for Endpoint as ctx, and its input schema augments the Endpoint's one.
import { z } from "zod";
import createHttpError from "http-errors";
import { Middleware } from "express-zod-api";
const authMiddleware = new Middleware({
security: {
and: [
{ type: "input", name: "key" },
{ type: "header", name: "token" },
],
}, // this information is optional and used for generating Documentation
statusCode: 401, // for Documentation: Middleware may interrupt handling this way
input: z.object({
key: z.string().min(1),
}),
handler: async ({ input: { key }, request, logger }) => {
logger.debug("Checking the key and token");
const user = await db.Users.findOne({ key });
if (!user) throw createHttpError(401, "Invalid key");
if (request.headers.token !== user.token)
throw createHttpError(401, "Invalid token");
return { user }; // provides endpoints with ctx.user
},
}); // connect it using EndpointsFactory::addMiddleware()
EndpointsFactory accumulates a sequence of Middlewares and holds a ResultHandler enabling the response
consistency: formatting outputs and errors. Use the .build() (or .buildVoid() for no output) methods to create
Endpoint that inherits all the middlewares and the ResultHandler from the factory. Thus, every Endpoint produced by
the same factory shares the same preprocessing logic, Context, and response shape. The
defaultEndpointsFactory uses the defaultResultHandler. You can derive specialized
factories by adding middlewares: each call creates a new factory, retaining the original one unchanged:
import { defaultEndpointsFactory } from "express-zod-api";
const authedFactory = defaultEndpointsFactory
.addMiddleware(authMiddleware)
.addMiddleware({/* another one, can also define it inline */});
const endpointA = authedFactory.build({/* ... */});
const endpointB = authedFactory.build({/* ... */}); // both share middlewares and defaultResultHandler
const endpointC = defaultEndpointsFactory // or inline in a single chain:
.addMiddleware(authMiddleware) // provides ctx.user
.buildVoid({ handler: async ({ ctx: { user } }) => {} });
Context (ctx) is information or tools that complement the request beyond the aggregated input. Regularly, Context is
the aggregation of returns from the Middlewares connected to the EndpointsFactory
where the Endpoint is built. You can also equip Context with Endpoint-specific entities unrelated to the request itself
by using the addContext() shorthand. If such an entity is absolutely static, it could just be a const in your code.
import { defaultEndpointsFactory } from "express-zod-api";
import jwt from "jsonwebtoken";
const signingFactory = defaultEndpointsFactory.addContext(async () => {
const privateKey = "..."; // loaded from config or secret manager
return {
signToken: (payload: Record<string, unknown>) =>
jwt.sign(payload, privateKey, { expiresIn: "1h" }),
};
});
const issueToken = signingFactory.build({
input: z.object({ userId: z.string() }),
output: z.object({ token: z.string() }),
handler: async ({ input: { userId }, ctx: { signToken } }) => ({
token: signToken({ sub: userId }),
}),
});
Using native express middlewares
There are two ways of connecting the native express middlewares depending on their nature and your objective.
In case it's middleware establishing and serving its own routes, or somehow globally modifying the behavior, use the
beforeRouting or beforeParsers hooks. Note that CORS, cookies, compression, and
body parsing are already available as separate config options.
import { createConfig } from "express-zod-api";
import ui from "swagger-ui-express";
const config = createConfig({
beforeRouting: ({ app, getLogger }) => {
const logger = getLogger();
logger.info("Serving the API documentation at https://example.com/docs");
app.use("/docs", ui.serve, ui.setup(documentation));
app.use("/custom", (req, res, next) => {
const childLogger = getLogger(req); // if childLoggerProvider is configured
});
},
});
In case you need a special processing of request, or to modify the response for selected endpoints, use the method
addExpressMiddleware() of EndpointsFactory (or its alias use()). The method has two optional features: a provider
of a context and an error transformer for adjusting the response status code.
import { defaultEndpointsFactory } from "express-zod-api";
import createHttpError from "http-errors";
import { auth } from "express-oauth2-jwt-bearer";
const factory = defaultEndpointsFactory.use(auth(), {
provider: (req) => ({ auth: req.auth }), // optional, can be async
transformer: (err) => createHttpError(401, err.message), // optional
statusCode: 401, // for Documentation: Middleware may interrupt handling this way
});
You can implement additional validations within schemas using refinements.
Validation errors are reported in a response with a status code 400.
import { z } from "zod";
import { Middleware } from "express-zod-api";
const nicknameConstraintMiddleware = new Middleware({
input: z.object({
nickname: z
.string()
.min(1)
.refine(
(nick) => !/^\d.*$/.test(nick),
"Nickname cannot start with a digit",
),
}),
// ...,
});
By the way, you can also refine the whole I/O object, for example, in case you need a complex validation of its props.
const endpoint = endpointsFactory.build({
input: z
.object({
email: z.email().optional(),
id: z.string().optional(),
otherThing: z.string().optional(),
})
.refine(
(inputs) => Object.keys(inputs).length >= 1,
"Please provide at least one property",
),
// ...,
});
In Express 5 the default query string parser was changed from "extended" (which is the qs module) to "simple" (which
is the node:querystring module). The "extended" parser supports nested objects and arrays with optional indexes in
square brackets. You can choose between those parsers as well as configure a custom implementation:
queryParser value |
Query string example for arrays |
| simple |
?values=1&values=2&values=3 |
| extended |
as simple or ?values[]=1&values[]=2&values[]=3 |
(str) => qs.parse(str, {comma:true}) |
as extended or ?values=1,2,3 |
Query and path parameters should have acceptable input schema. Those parameters are in general expected to be strings
or coerced primitives that could accept strings. Query parameters can also be parsed as arrays of strings or objects
having string properties (depends on queryParser config option). Therefore, there is often a need to transform their
values into numbers. Using the .transform() schema method is recommended for this purpose. Alternatively, you can
also use z.coerce or z.preprocess() schemas.
import { Routing } from "express-zod-api";
const routing: Routing = {
"/v1/users/:groupId": factory.build({
input: z.object({
// path parameter is originally a string:
groupId: z.string().transform((id) => parseInt(id, 10)),
// query parser accepts arrays of strings:
roleIds: z
.array(z.string())
.transform((ids) => ids.map((id) => parseInt(id, 10))),
}),
}),
};
Top level transformations and mapping
For some APIs it may be important that public interfaces such as query parameters use snake case, while the
implementation itself requires camel case for internal naming. In order to facilitate interoperability between the
different naming standards you can .transform() the entire input schema into another object using a well-typed
mapping library, such as camelize-ts. However, that approach would not be
enough for the output schema if you're also aiming to generate valid documentation,
because the transformations themselves do not contain schemas. Addressing this case, the framework offers the .remap()
method of the object schema, a part of the Zod plugin, which under the hood, in addition to the
transformation, also .pipe() the transformed object into a new object schema.
Here is a recommended solution: it is important to use shallow transformations only.
import camelize from "camelize-ts";
import snakify from "snakify-ts";
import { z } from "zod";
const endpoint = endpointsFactory.build({
input: z
.object({ user_id: z.string() })
.transform((inputs) => camelize(inputs, /* shallow: */ true)),
output: z
.object({ userName: z.string() })
.remap((outputs) => snakify(outputs, /* shallow: */ true)),
handler: async ({ input: { userId }, logger }) => {
logger.debug("user_id became userId", userId);
return { userName: "Agneta" }; // becomes "user_name" in response
},
});
The .remap() method can also accept an object with an explicitly defined naming of your choice. The original keys
missing in that object remain unchanged (partial mapping).
z.object({ user_name: z.string(), id: z.number() }).remap({
user_name: "weHAVEreallyWEIRDnamingSTANDARDS", // "id" remains intact
});
Dates in JavaScript are one of the most troublesome entities. In addition, Date cannot be passed directly in JSON
format. Therefore, attempting to return Date from the endpoint handler results in it being converted to an ISO string
in actual response by calling
toJSON(),
which in turn calls
toISOString().
It is also impossible to transmit the Date in its original form to your endpoints within JSON. Therefore, there is
confusion with the original method z.date() that is not recommended to use without transformations.
In order to solve this problem, the framework provides two custom methods for dealing with dates: ez.dateIn() and
ez.dateOut() for using within input and output schemas accordingly.
ez.dateIn() is a transforming schema that accepts an ISO string representation of a Date, validates it, and
provides your endpoint handler or middleware with a Date. It supports the following formats:
2021-12-31T23:59:59+02:00
2021-12-31T23:59:59.000Z
2021-12-31T23:59:59Z
2021-12-31T23:59:59
2021-12-31
ez.dateOut(), on the contrary, accepts a Date and provides ResultHandler with a string representation in ISO
format for the response transmission. Both schemas accept metadata as an argument. Consider the following example:
import { z } from "zod";
import { ez, defaultEndpointsFactory } from "express-zod-api";
const updateUserEndpoint = defaultEndpointsFactory.build({
method: "post",
input: z.object({
userId: z.string(),
birthday: ez.dateIn({ examples: ["1963-04-21"] }), // string -> Date in handler
}),
output: z.object({
createdAt: ez.dateOut({ examples: ["2021-12-31"] }), // Date -> string in response
}),
handler: async ({ input }) => ({
createdAt: new Date("2022-01-22"), // 2022-01-22T00:00:00.000Z
}),
});
Consider using ez.paginated() to get reusable input and output schemas for offset or cursor-based pagination.
Attach schemas to your endpoint and compose with other params (e.g. .and(z.object({ ... }))).
To check if more pages are available, use Client::hasMore().
import { z } from "zod";
import { ez, defaultEndpointsFactory } from "express-zod-api";
const pagination = ez.paginated({
style: "offset", // or "cursor"
itemSchema: z.object({ id: z.number(), name: z.string() }),
itemsName: "users", // defines the output name of the items array
maxLimit: 100,
defaultLimit: 20,
});
const listUsers = defaultEndpointsFactory.build({
input: pagination.input,
output: pagination.output,
handler: async ({ input: { limit, offset } }) => {
const { users, total } = await db.getUsers(limit, offset);
return { users, total, limit, offset }; // or { users, nextCursor, limit } for cursor pagination
},
});
Cross-Origin Resource Sharing
You can enable your API for other domains using the corresponding configuration option cors. The value is required to
ensure you explicitly choose the correct setting: false | true — disables/enables CORS for any origin, setting
Access-Control-Allow-Origin: * and Access-Control-Allow-Headers: content-type. You can also pass standard Express
middleware for full control, consider using the well-known cors package.
import cors from "cors";
import { createConfig } from "express-zod-api";
const configWithCorsPackage = createConfig({
cors: cors({ origin: "https://example.com" }),
});
const configWithCustomRequestHandler = createConfig({
cors: (req, res, next) => {
res.set({
"Access-Control-Allow-Origin": "https://example.com",
"Access-Control-Allow-Headers": "content-type",
"Access-Control-Max-Age": "5000",
});
next();
},
});
Please note: If you only want to send specific headers on requests to a specific endpoint, consider the
Middlewares or response customization approach.
The modern API standard often assumes the use of a secure data transfer protocol, confirmed by a TLS certificate, also
often called an SSL certificate in habit. This way you can additionally (or solely) configure and run the HTTPS server:
import { createConfig, createServer } from "express-zod-api";
const config = createConfig({
https: {
options: {
cert: fs.readFileSync("fullchain.pem", "utf-8"),
key: fs.readFileSync("privkey.pem", "utf-8"),
},
listen: 443, // port, UNIX socket or options
}, // ... cors, logger, etc
});
const { app, servers, logger } = createServer(config, routing);
Ensure having @types/node package installed. At least you need to specify the port (usually it is 443) or UNIX socket,
certificate and the key, issued by the certifying authority. For example, you can acquire a free TLS certificate for
your API at Let's Encrypt.
According to Express.js best practices guide,
it might be a good idea to enable GZIP and Brotli compression for your API responses.
Install compression and @types/compression, and enable or configure compression:
import { createConfig } from "express-zod-api";
const config = createConfig({
/** @link https://www.npmjs.com/package/compression#options */
compression: { threshold: "1kb" }, // or true
});
To receive a compressed response, the client should include the following header in the request:
Accept-Encoding: br, gzip, deflate. Only responses with compressible content types are subject to compression.
The built-in logger is used by default with the following options that you can configure:
import { createConfig } from "express-zod-api";
const config = createConfig({
logger: {
level: "debug", // or "warn" in production mode
color: undefined, // detects automatically, boolean
depth: 2, // controls how deeply entities should be inspected
},
});
However, it's for development purposes only: it prints messages to the console synchronously, which may degrade the
performance of your API in production mode. Consider installing a professional logging solution
instead, such as Pino or Winston. You can use
any logger having at least these methods: info(), debug(), error() and warn(). Here is how to use pino:
import pino, { Logger } from "pino";
import { createConfig } from "express-zod-api";
const logger = pino({
transport: {
target: "pino-pretty",
options: { colorize: true },
},
});
const config = createConfig({ logger });
// Setting the type of logger used
declare module "express-zod-api" {
interface LoggerOverrides extends Logger {}
}
In case you need a dedicated logger for each request (for example, equipped with a request ID), you can specify the
childLoggerProvider option in your configuration. The function accepts the initially defined logger and the request,
it can also be asynchronous. The child logger returned by that function will replace the logger in all handlers.
You can use the .child() method of the built-in logger or install a custom logger instead.
import { createConfig, BuiltinLogger } from "express-zod-api";
import { randomUUID } from "node:crypto";
// This enables the .child() method on "logger":
declare module "express-zod-api" {
interface LoggerOverrides extends BuiltinLogger {}
}
const config = createConfig({
childLoggerProvider: ({ parent, request }) =>
parent.child({ requestId: randomUUID() }), // accessible at logger.ctx.requestId later
});
Customizing input sources
You can customize the list of request properties that are combined into input that is being validated and available
to your endpoints and middlewares. The order here matters: each next item in the array has a higher priority than its
previous sibling. The following arrangement is default:
import { createConfig } from "express-zod-api";
createConfig({
inputSources: {
get: ["query", "params"],
post: ["body", "params", "files"],
put: ["body", "params"],
patch: ["body", "params"],
delete: ["query", "params"],
query: ["query", "body", "params"],
}, // ...
});
Headers as an input source
Similarly, you can enable request headers as the input source. This is an opt-in feature. Please note:
- consider giving
headers the lowest priority among other inputSources to avoid overwriting;
- consider handling headers in
Middleware and declaring them within security property to improve Documentation;
- the request headers acquired that way are always lowercase when describing their validation schemas.
import { createConfig, Middleware } from "express-zod-api";
import { z } from "zod";
createConfig({
inputSources: {
get: ["headers", "query"], // headers have lowest priority
}, // ...
});
new Middleware({
security: { type: "header", name: "token" }, // recommended
input: z.object({ token: z.string() }),
});
factory.build({
input: z.object({
"x-request-id": z.string(), // this one is from request.headers
id: z.string(), // this one is from request.query
}), // ...
});
Install cookie-parser as well as @types/cookie-parser and enable cookies in your config. To validate cookies add
"cookies" and/or "signedCookies" to your inputSources (the order matters!):
import { createConfig } from "express-zod-api";
const config = createConfig({
cookies: { secret: "my-secret" }, // or true; the secret enables signedCookies
inputSources: {
get: ["query", "params", "cookies", "signedCookies"], // for methods of your choice
},
});
Consider createCookieMiddleware() that makes a Middleware providing setCookie() and clearCookie() helpers,
as well as getCookie() — alternative to the cookies as an input source:
import { Middleware } from "express-zod-api";
const cookieDrivenFactory = factory
.useCookies({ httpOnly: true, sameSite: "lax", path: "/" }) // shorthand, recommended base options
.addMiddleware(
new Middleware({
security: { type: "cookie", name: "session" }, // improves Documentation
input: z.object({ session: z.string() }), // alternatively, use getCookie
handler: async ({ input: { session }, ctx: { getCookie } }) => {
assert.equal(session, getCookie("session")); // getCookie reads from signedCookies first
},
}),
);
const sessionSettingEndpoint = cookieDrivenFactory.buildVoid({
handler: async ({ ctx: { setCookie } }) => {
setCookie("session", "abc123", { httpOnly: false }); // overridden cookie options
},
});
Consider the createCacheMiddleware() that provides helpers for HTTP caching following the MDN HTTP Caching guide.
It covers all standard Cache-Control directives, conditional request handling, and the "Not Modified" (304) flow:
const avatarEndpoint = factory
.useCache({ maxAge: 3600, scope: "public" }) // shorthand, the policy applies to every response
.build({
output: z.object({ avatar: z.string() }),
handler: async ({
ctx: { ifNoneMatch, setETag, notModified, addCachePolicy },
}) => {
const etag = `"avatar-v2"`;
if (ifNoneMatch?.includes(etag)) return notModified() as never; // skips validation, sends 304
setETag(etag);
addCachePolicy({ staleWhileRevalidate: 86400 }); // extends the default policy
return { avatar: "https://example.com/avatar.png" };
},
});
Install express-rate-limit. Consider the createRateLimitMiddleware() to enable and configure rate limit on a certain
EndpointsFactory. When the limit is exceeded, the Middleware throws a 429 HTTP error, handled by your ResultHandler.
const endpoint = factory
.useRateLimit({
windowMs: 60000,
max: 100,
statusCode: 429, // when set explicitly, it will be reflected in the Documentation
}) // shorthand, or .addMiddleware(createRateLimitMiddleware())
.buildVoid({
handler: async ({ ctx: { rateLimit, logger } }) => {
logger.debug("Features", rateLimit); // { limit, used, remaining, resetTime, getKey, resetKey }
},
});
ResultHandler is responsible for transmitting consistent responses containing the endpoint output or an error.
The defaultResultHandler sets the HTTP status code and ensures the following type of the response:
type DefaultResponse<OUT> =
| { status: "success"; data: OUT } // Positive response
| { status: "error"; error: { message: string } }; // or Negative response
You can create your own result handler by using this example as a template:
import { z } from "zod";
import {
ResultHandler,
ensureHttpError,
getMessageFromError,
} from "express-zod-api";
const yourResultHandler = new ResultHandler({
positive: (data) => ({
schema: z.object({ data }),
mimeType: "application/json", // optional or array
}),
negative: z.object({ error: z.string() }),
handler: ({ error, input, output, request, response, logger }) => {
if (error) {
const { statusCode } = ensureHttpError(error);
const message = getMessageFromError(error);
return void response.status(statusCode).json({ error: message });
}
response.status(200).json({ data: output });
},
});
See also Different responses for different status codes.
After creating your custom ResultHandler you can use it as an argument for EndpointsFactory instance creation:
import { EndpointsFactory } from "express-zod-api";
const endpointsFactory = new EndpointsFactory(yourResultHandler);
For some REST APIs, empty responses are typical: with status code 204 (No Content) and redirects (302). To describe
it, set the mimeType to null and schema to z.never():
const resultHandler = new ResultHandler({
positive: { statusCode: 204, mimeType: null, s
