Request Validation as Middleware
Turn a schema into a reusable validate() middleware so handlers only ever see clean, typed input.
Validation belongs at the edge, before your controller runs, so that by the time your business logic executes it can trust its inputs completely. The cleanest way to express this in Express is a validate(schema) factory that parses req.body, req.query and req.params, replaces them with the parsed (coerced, defaulted) values, and short-circuits with a 400 on failure.
Why replace, not just check
A good schema library does more than yes/no — it coerces ("42" to 42), applies defaults, and strips unknown keys. Assigning the parsed result back onto req means your handler reads req.body.page as a real number, not a string.
const parsed = schema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ errors: parsed.error.issues });
req.body = parsed.data; // hand the clean data downstreamExpress 5 note
In Express 5 req.query is a getter and is no longer writable in place, so validate the query and stash the clean copy on req.validatedQuery (or read from the returned data) rather than reassigning req.query.
Example
// middleware/validate.js
import { z } from 'zod';
// Accepts a map of the request parts you care about.
export function validate({ body, query, params } = {}) {
return (req, res, next) => {
const errors = [];
const run = (schema, value, where) => {
if (!schema) return value;
const r = schema.safeParse(value);
if (!r.success) {
errors.push(...r.error.issues.map((i) => ({ where, path: i.path, message: i.message })));
return value;
}
return r.data;
};
const cleanBody = run(body, req.body, 'body');
const cleanQuery = run(query, req.query, 'query'); // don't reassign req.query in v5
const cleanParams = run(params, req.params, 'params');
if (errors.length) return res.status(400).json({ error: 'ValidationError', errors });
req.body = cleanBody;
req.validatedQuery = cleanQuery; // Express 5: req.query is read-only
req.params = cleanParams;
next();
};
}
// schemas/orders.js
export const listOrders = {
query: z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
}),
};
// routes
// router.get('/', validate(listOrders), orders.list);When to use it
- A team uses a reusable validate middleware factory with Zod schemas to validate request bodies, query strings, and path parameters consistently across all routes.
- A developer chains express-validator rules defined in a separate validations/ file and imports them alongside controllers to keep route files tidy.
- A multi-step form's API validates each step's payload against a different Joi schema before saving partial data to the session.
More examples
Zod request validator middleware
A validateBody factory parses req.body with a Zod schema, returning issues on failure and replacing req.body with the coerced data on success.
const { z } = require('zod');
const createUser = z.object({
name: z.string().min(2),
email: z.string().email(),
age: z.number().int().min(0).optional(),
});
function validateBody(schema) {
return (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) return res.status(400).json({ errors: result.error.issues });
req.body = result.data; // coerced and typed
next();
};
}
app.post('/users', validateBody(createUser), ctrl.create);Validating query params with Joi
Validates and applies Joi defaults to req.query so the controller always receives a validated page and limit.
const Joi = require('joi');
const paginationSchema = Joi.object({
page: Joi.number().integer().min(1).default(1),
limit: Joi.number().integer().min(1).max(100).default(20),
});
function validateQuery(schema) {
return (req, res, next) => {
const { error, value } = schema.validate(req.query);
if (error) return res.status(400).json({ error: error.message });
req.query = value; // apply defaults
next();
};
}
app.get('/products', validateQuery(paginationSchema), ctrl.list);express-validator rule files
Keeps express-validator rule arrays in a dedicated validations/ file so route files only import named rule sets.
// validations/post.js
const { body } = require('express-validator');
exports.createPost = [
body('title').trim().isLength({ min: 5, max: 200 }),
body('content').trim().isLength({ min: 20 }),
body('tags').isArray().optional(),
];
// routes/posts.js
const { createPost } = require('../validations/post');
const { validationResult } = require('express-validator');
router.post('/', createPost, (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
res.status(201).json(req.body);
});
Discussion