How to validate request data in Express.js?
Request validation in Express.js checks incoming data against predefined rules before it reaches your route handler, blocking bad input at the door.
Theory
TL;DR
- Think of it like a bouncer at a club: the schema is the guest list,
req.bodyis the person showing ID. No match, no entry. - The main approach: define a schema (Zod, Joi, or express-validator), wrap it in middleware, attach it to your route.
- TypeScript project? Use Zod - it infers types automatically. Plain JS? Joi. Quick param or query checks only? express-validator is the lightest path.
- Always put the validation middleware before any async work. Checking data after a DB call wastes resources on a request that was never valid.
- All three libraries return structured, field-level errors - not just a generic 400.
Quick example
const { z } = require('zod');
const express = require('express');
const app = express();
app.use(express.json());
const userSchema = z.object({
name: z.string().min(2),
email: z.string().email()
});
const validate = (schema) => (req, res, next) => {
try {
schema.parse(req.body);
next();
} catch (err) {
res.status(400).json({ errors: err.errors });
}
};
app.post('/users', validate(userSchema), (req, res) => {
res.json({ message: 'User created', data: req.body });
});
// { "name": "Bob", "email": "bob@example.com" } -> 200 OK
// { "name": "A", "email": "invalid" } -> 400 with field-level errorsvalidate() is a higher-order function that returns middleware. The schema runs before the handler. If anything fails, the request stops there - the route handler never executes.
Schema-based vs manual validation
Manual if checks work fine for one or two fields. They fall apart fast after that: the same checks get copied across routes, edge cases around type coercion get missed, and gaps like prototype pollution can slip through. Schema libraries centralize the rules in one object, coerce types automatically, and produce structured error messages you can send directly to the client.
Zod also generates TypeScript types from your schema, so req.body becomes fully typed downstream without separate interfaces. That alone cuts a lot of boilerplate in production TypeScript projects.
When to use each library
- Zod: TypeScript project, new codebase, want inferred types with no duplication.
- Joi: Plain JavaScript, legacy app, or a team already familiar with Hapi's ecosystem. Slightly faster than Zod at very high throughput.
- express-validator: Minimal setup, just a few URL params or query strings to check, no full schema library needed.
- class-validator: NestJS or class-based architecture, decorator syntax on DTOs.
Comparison table
| Library | TS support | Error format | Performance | Best for |
|---|---|---|---|---|
| Zod | Native (inferred types) | Rich, path-based | Fast | New TS projects |
| Joi | Manual types | Detailed | Fastest | Plain JS / legacy |
| express-validator | None | Basic | Lightweight | Quick param checks |
| class-validator | Decorators | Customizable | Slower (reflection) | NestJS / class-based |
How the middleware stack handles this
express.json() parses the raw HTTP body into req.body using Node's http.IncomingMessage. Your validation middleware runs next in the stack. Zod's schema.parse() walks the object recursively, checking types and constraints in sequence. On failure it throws a ZodError. Your middleware catches it and sends 400 before the route handler ever runs.
One thing that trips people up consistently: req.query values are always strings. A query like ?page=2 gives you the string "2", not the number 2. Zod's .transform(Number) handles this, but you have to add it explicitly - it does not happen automatically.
Common mistakes
Mistake 1: validating inside the handler, after async work.
// Wrong: DB call runs even on bad input
app.post('/users', async (req, res) => {
await db.connect();
schema.parse(req.body); // too late
});
// Fix: middleware runs first
app.post('/users', validate(schema), async (req, res) => {
await db.connect();
});Mistake 2: forgetting express.json().
Without it, req.body is undefined, and schema.parse(undefined) produces a confusing error instead of a clean 400. Add it before your routes with a size limit:
app.use(express.json({ limit: '10kb' }));Mistake 3: using .optional() on fields that should be required.
// Wrong: passes empty strings and null
name: z.string().optional()
// Fix: require a non-empty string
name: z.string().min(1)This is a common source of DB constraint errors that appear much later in the request lifecycle, far from where the actual bug is.
Mistake 4: not transforming query string numbers.
// Wrong: "abc" passes z.string() but breaks downstream logic
query: z.object({ page: z.string() })
// Fix: transform and validate the number
query: z.object({
page: z.string().transform(Number).refine(n => !isNaN(n) && n > 0, 'Must be a positive number')
})Real-world usage
- Next.js API routes: Zod on
/api/userswith inferred handler types, standard in Vercel templates. - NestJS: class-validator decorators on DTOs, used in most enterprise setups.
- Strapi CMS: Joi schemas for custom plugin endpoints.
- Plain Express APIs: express-validator for quick param checks, Zod for anything with a complex body structure.
Follow-up questions
Q: Why use middleware instead of validating inside the handler?
A: Middleware runs before any business logic, saves CPU on invalid requests, and lets you reuse the same schema across multiple routes. It also composes cleanly with auth middleware in the same chain.
Q: What is the difference between parse() and safeParse() in Zod?
A: parse() throws a ZodError on failure - use it in try/catch inside middleware. safeParse() returns { success: boolean, data | error } and never throws, which is better for WebSocket handlers or non-HTTP contexts where throwing is awkward.
Q: How do you handle file uploads with validation?
A: Use Multer to parse the multipart body first, then validate req.body and req.file with Zod. Check the MIME type with z.enum(['image/jpeg', 'image/png']) on req.file.mimetype after Multer runs.
Q: Does Zod protect against prototype pollution?
A: Yes, since Zod 3.22 keys like __proto__ are stripped automatically. Joi removes unknown fields by default too. If you want to reject unexpected fields entirely rather than strip them, use .strict().
Q (senior): You are building an API gateway for a microservices system with 100 schemas loaded dynamically per route. How do you design the validation layer?
A: Keep a central schema registry (a Map in-process, Redis for distributed setups) keyed by route ID. Load schemas lazily and cache the compiled Zod objects. Use an OpenAPI-to-Zod generator to keep schemas consistent with your API contracts. For non-TS services, fall back to Joi. Version schemas with a key like users:v1 and users:v2 so old and new routes coexist without conflict.
Examples
Basic: single body validation with safeParse
const { z } = require('zod');
const express = require('express');
const app = express();
app.use(express.json());
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(8)
});
const validate = (schema) => (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error.errors });
}
req.body = result.data; // use the coerced, validated data
next();
};
app.post('/login', validate(loginSchema), (req, res) => {
res.json({ message: 'Credentials valid' });
});
// { email: "a@b.com", password: "secret123" } -> 200
// { email: "notanemail", password: "short" } -> 400 with two field errorssafeParse() is cleaner than parse() in middleware because it never throws - the error is just a value you check. Assigning result.data back to req.body means the handler gets the coerced output, not the raw input.
Intermediate: body and query params on a signup route
const createUserSchema = z.object({
body: z.object({
name: z.string().min(2).max(50),
email: z.string().email(),
password: z.string().min(8).regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
'Password must include upper, lower, and digit'
)
}),
query: z.object({
referral: z.string().optional()
})
});
const validate = (schema) => (req, res, next) => {
const result = schema.safeParse({ body: req.body, query: req.query });
if (!result.success) {
return res.status(400).json({
error: 'Validation failed',
details: result.error.errors.map(e => ({
field: e.path.join('.'),
message: e.message
}))
});
}
next();
};
app.post('/users', validate(createUserSchema), async (req, res) => {
res.json({ userId: 123 });
});
// POST /users?referral=abc { name:"Alice", email:"a@b.com", password:"Pass123" } -> 200
// POST /users { name:"A", email:"bad", password:"weak" } -> 400, 3 field errorsWrapping both body and query in one schema means a single middleware call covers the whole request. Each error object in the response includes the full field path like body.password, so the client knows exactly what failed.
Advanced: nested arrays with transform
This pattern trips up a lot of developers because Zod's .transform() changes the output shape, which affects what req.body looks like inside the handler.
const orderSchema = z.object({
items: z.array(z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive().max(99),
metadata: z.record(z.any()).optional() // allows extra fields without breaking
}))
.min(1)
.max(10)
}).transform(data => ({
...data,
totalItems: data.items.reduce((sum, item) => sum + item.quantity, 0)
}));
const validate = (schema) => (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error.errors });
}
req.body = result.data; // includes totalItems computed by Zod
next();
};
app.post('/orders', validate(orderSchema), (req, res) => {
console.log(req.body.totalItems); // already computed, no extra logic needed
res.json({ order: req.body });
});
// { items: [{ productId: "550e8400-e29b-41d4-a716-446655440001", quantity: 5 }] }
// -> 200, totalItems: 5
// { items: [] }
// -> 400 "Array must contain at least 1 item(s)"
// { items: [{ productId: "not-uuid", quantity: 0 }] }
// -> 400 two separate field errorsThe .transform() step runs only after all validation passes. You get a clean, enriched object in req.body instead of computing totalItems again in the handler. Zod 3.22+ also strips __proto__ and similar keys automatically, so prototype pollution is handled without any extra code.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.