This article is published in English.
Guarding the Express Boundary: One Zod Middleware for Body, Params and Query
Learn how to validate Express request bodies, route params and query strings with one reusable Zod middleware, and how it complements Sequelize model validation.
Nothing stops a client from posting a number where your API expects a name, or null where it expects a password. Code that trusts req.body blindly eventually writes broken rows or throws errors far from their real cause. This guide shows how to describe valid input once with Zod, enforce it in a single Express middleware covering body, route params and query string, and keep controllers focused on business logic.
The problem: requests arrive untyped
Here is a perfectly legal HTTP payload that no registration endpoint should accept:
{
"fullName": 123,
"email": "hello",
"password": null
}
Every field has the wrong shape. Zod is a schema library for JavaScript and TypeScript that lets you state precisely what you expect and get back either clean data or a structured list of problems.
Describing input as a schema
Suppose registration needs a fullName string, a well-formed email, a password of at least eight characters, and an optional whole-number age. In Zod that reads almost like the requirements themselves:
const { z } = require('zod');
const registerSchema = z.object({
fullName: z.string().min(2),
email: z.string().email(),
password: z.string().min(8),
age: z.number().int().min(18).optional()
});
The rules live in one object instead of scattered if statements. The age rule also enforces a minimum of 18: a missing age passes, an age of 16 fails.
Installing and importing
Zod is a regular npm dependency:
npm install zod
In CommonJS, pull in the z namespace with require:
const { z } = require('zod');
With ES modules, use a named import:
import { z } from 'zod';
Adding readable error messages
Each validator accepts an optional message, which is what clients will see:
const registerSchema = z.object({
fullName: z.string().min(2, 'Full name is required'),
email: z.string().email('Invalid email'),
password: z
.string()
.min(8, 'Password must be at least 8 characters'),
age: z
.number()
.int()
.min(18)
.optional()
});
A payload that satisfies every rule passes unchanged:
{
"fullName": "John Smith",
"email": "john@example.com",
"password": "password123",
"age": 25
}
This one has a name that is too short, an address without a domain, and a three-character password:
{
"fullName": "J",
"email": "invalid-email",
"password": "123"
}
Zod reports all three issues at once, so a form can highlight every invalid field in a single round trip.
Recent Zod releases (v4 and later) also offer top-level validators such as z.email() and deprecate the chained z.string().email() style. The chained form still works, but check the current docs for your version.
Choosing between parse() and safeParse()
parse() throws
parse() returns the validated data or throws a ZodError:
const data = registerSchema.parse(req.body);
In an Express handler you must then catch the error yourself or forward it with next(err).
safeParse() returns a result
safeParse() never throws. It returns an object with a success flag, which fits request handling better because invalid input is an expected outcome, not an exceptional one:
const result = registerSchema.safeParse(req.body);
On failure, error.issues lists each problem with its path and message, ready for a 400 response:
if (!result.success) {
return res.status(400).json({
success: false,
errors: result.error.issues
});
}
On success, result.data holds the parsed value:
const data = result.data;
Use result.data from here on, not req.body: unknown keys are stripped by default, and coercions and defaults are already applied.
From inline checks to a reusable middleware
The simplest integration calls safeParse() inside the handler:
app.post('/register', (req, res) => {
const result = registerSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
success: false,
message: 'Validation failed',
errors: result.error.issues
});
}
const data = result.data;
console.log(data);
// Continue with registration logic...
return res.status(201).json({
success: true,
data
});
});
It works, but with 20 or 50 endpoints the same lines get pasted into every controller and slowly drift apart. Also notice that this example echoes the validated object, password included, back to the client; a real endpoint should return only non-sensitive fields.
A validate() factory
The factory below takes a schema and returns an Express handler. It validates body, params and query together, answers 400 on failure, and otherwise stores the parsed result on req.validated before calling next():
const validate = (schema) => {
return (req, res, next) => {
const result = schema.safeParse({
body: req.body,
params: req.params,
query: req.query
});
if (!result.success) {
return res.status(400).json({
success: false,
message: 'Validation failed',
errors: result.error.issues
});
}
req.validated = result.data;
next();
};
};
module.exports = validate;
Two details matter. Writing to a separate req.validated property avoids trouble in Express 5, where req.query is a getter and cannot simply be reassigned. And because the middleware wraps input as { body, params, query }, schemas must follow that shape. A flat registerSchema would look for fullName at the top level and reject every request, so wrap it as z.object({ body: registerSchema }), or make the middleware validate only req.body.
Wiring it into a route
The middleware sits between the path and the controller:
router.post(
'/register',
validate(registerSchema),
register
);
The request pipeline becomes:
Request
↓
Express Router
↓
Zod Validation Middleware
↓
Controller
↓
Service
↓
Database
Invalid input stops at the middleware and the controller never runs; valid input continues with data guaranteed to match the schema.
Keeping controllers about business logic
Without a validation layer, a controller accumulates every concern at once:
const register = async (req, res) => {
// validation
// check email
// validate password
// validate name
// business logic
// database operation
};
With the middleware in place, it just reads verified values:
const register = async (req, res) => {
const {
fullName,
email,
password
} = req.validated.body;
// Business logic
};
As a bonus, schemas can be unit-tested with plain objects, and controller tests no longer need a case for every malformed payload.
Validating route parameters with coercion
The same approach covers URL segments. Take a request for one user:
GET /users/123
A schema for the id parameter:
const userParamsSchema = z.object({
id: z.coerce.number().int().positive()
});
Attached like before (under a params key when used with the middleware above):
router.get(
'/users/:id',
validate(userParamsSchema),
getUser
);
The key piece is the coercion:
z.coerce.number()
Everything in a URL is text. The value of
req.params.id
arrives as the string
"123"
not as the number
123
A plain z.number() would reject every request. z.coerce.number() runs the input through Number() first and then applies .int() and .positive(). One edge case: Number('') is 0, so an empty value becomes zero. Here .positive() catches it, but a schema without a lower bound would let it through.
Validating query strings with defaults
Pagination is the classic query-string case:
GET /users?page=1&limit=10
Coercion plus defaults yields safe numbers even when the client omits them:
const userQuerySchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(10)
});
The .max(100) cap also stops a client from requesting a million rows in one call.
Common Zod building blocks
Most schemas combine a small set of pieces:
z.string(),z.number(),z.boolean()check primitive types.z.object()describes an object's shape;z.array()validates an array and its elements.z.enum()limits a value to a fixed list of options..min()and.max()bound a number's value or a string's or array's length..email()checks the email format;.int()requires a whole number;.positive()a value above zero..optional()allows a missing field;.nullable()allows an explicitnull;.default()fills in absent values.z.coerceis a namespace rather than a function:z.coerce.number()and friends convert input before validating..refine()adds custom rules;.transform()reshapes a value after it passes..parse()throws on failure;.safeParse()returns a success or error result.
Example: a user record with roles
A user in a nursery management app could look like this:
const userSchema = z.object({
fullName: z.string().min(2),
email: z.string().email(),
role: z.enum([
'admin',
'teacher',
'parent'
]),
isActive: z.boolean().default(true)
});
z.enum() rejects any other role, and isActive defaults to true when omitted. The schema doubles as documentation.
Zod and Sequelize validate different layers
Teams on Sequelize and MySQL often ask why they need Zod when models already have validators. The two protect different boundaries.
Zod guards the API boundary
It checks what arrives over HTTP before application code acts on it:
HTTP Request
↓
Zod
↓
Controller
Sequelize guards the data layer
Its validators run when a model is saved, deep in the service layer:
Controller
↓
Service
↓
Sequelize
↓
MySQL
Using both
Together they form two independent layers:
Client
↓
Express
↓
Zod
↓
Controller
↓
Service
↓
Sequelize
↓
MySQL
Zod gives fast, client-friendly 400 responses; Sequelize catches mistakes that originate inside the application, such as a background job building a bad record. Database constraints like NOT NULL and unique indexes remain the final safety net.
Organizing schemas in a larger codebase
In a module-based project, each module gets a validation file next to its routes, controller and service, with the shared middleware in its own folder:
src/
├── modules/
│ └── users/
│ ├── user.controller.js
│ ├── user.service.js
│ ├── user.routes.js
│ └── user.validation.js
│
├── middleware/
│ └── validate.js
│
└── app.js
user.validation.js exports the module's schemas:
const { z } = require('zod');
const createUserSchema = z.object({
fullName: z.string().min(2),
email: z.string().email(),
password: z.string().min(8)
});
module.exports = {
createUserSchema
};
and the routes file stays short:
router.post(
'/users',
validate(createUserSchema),
createUser
);
When a field changes, the controller and its rules are edited together. To reuse the same schemas in the browser, see sharing one Zod schema across React and Node.
Why a single source of truth pays off
Without a schema, validation leaks into controllers as ad hoc checks:
if (!email) {
// ...
}
if (!password) {
// ...
}
if (password.length < 8) {
// ...
}
if (!['admin', 'teacher'].includes(role)) {
// ...
}
Each endpoint repeats a slightly different version, and nobody sees the full contract at a glance. The equivalent schema states it in a few lines:
const userSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
role: z.enum(['admin', 'teacher'])
});
That is the agreement between API and clients, enforced in one place. For a comparison with another popular approach, see Zod versus express-validator.
Key takeaways
The real value is the order of responsibilities Zod enforces:
Request
↓
Validation
↓
Controller
↓
Business Logic
↓
Database
- Validate at the edge with
safeParse()and let onlyresult.datareach handlers. - Centralize validation in one middleware, and make each schema match the shape it parses.
- Use
z.coercefor params and query strings, and cap values such as page size. - Keep ORM validators and database constraints as a second layer, not a replacement.
- Colocate schemas with their modules so the contract changes with the code.