Node.js and Express course menu
Validating Request Data in Express (with Zod)
Never trust client input: parse JSON and form bodies in Express, validate bodies, params and query strings by hand and with a Zod schema, return clear 400 errors listing each problem, coerce types, strip unknown fields, and write reusable validation middleware.
On this page
Every value in a request comes from the client, and clients can send anything: missing fields, wrong types, huge strings, or deliberately malicious data. Validate all input before using it. Validation in the browser is a convenience for users; the server must check everything again.
Parsing bodies#
app.use(express.json({ limit: "100kb" })); // JSON bodies (with a size limit)
app.use(express.urlencoded({ extended: true })); // HTML form bodiesMalformed JSON makes express.json() respond with a 400 error before your route runs; the size limit rejects oversized bodies with 413.
Validating by hand#
For a small API, a few checks are enough:
import express from "express";
const app = express();
app.use(express.json());
app.post("/api/signup", (req, res) => {
const { name, email, age } = req.body;
const errors = [];
if (typeof name !== "string" || name.trim().length < 2) errors.push("name must be at least 2 characters");
if (typeof email !== "string" || !/^\S+@\S+\.\S+$/.test(email)) errors.push("email must be a valid email address");
if (age !== undefined && (!Number.isInteger(age) || age < 13)) errors.push("age must be a whole number of at least 13");
if (errors.length) return res.status(400).json({ errors });
res.status(201).json({ message: `Welcome, ${name.trim()}!` });
});
app.listen(3000);curl -s -X POST localhost:3000/api/signup -H "Content-Type: application/json" -d '{"name":"Meera","email":"meera@example.com","age":24}'
curl -s -X POST localhost:3000/api/signup -H "Content-Type: application/json" -d '{"name":"M","email":"meera@","age":9}'
curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:3000/api/signup -H "Content-Type: application/json" -d '{"name":"Meera","email":'{"message":"Welcome, Meera!"}
{"errors":["name must be at least 2 characters","email must be a valid email address","age must be a whole number of at least 13"]}
400The last request is invalid JSON, so express.json() rejected it with status 400 before the route ran (-o /dev/null -w "%{http_code}" shows only the status). Its body is Express's default HTML error page, which in development includes the full stack trace, with file paths from your server. With NODE_ENV=production it shows only the status text. The error handling lesson replaces it with a JSON error.
Report all problems at once, so users can fix everything in one go.
Validation with a schema: Zod#
Hand-written checks get repetitive and easy to get wrong. A schema library describes the expected shape once and checks data against it. Zod is popular in JavaScript and TypeScript projects:
npm install zodimport { z } from "zod";
const Product = z.object({
name: z.string().trim().min(2).max(60),
price: z.number().positive(),
category: z.enum(["stationery", "bags", "electronics"]),
tags: z.array(z.string()).max(5).default([]),
});
const good = Product.safeParse({ name: " Backpack ", price: 1299, category: "bags", extra: "ignored" });
console.log(good.success, good.data);
const bad = Product.safeParse({ name: "B", price: -5, category: "food" });
console.log(bad.success);
for (const issue of bad.error.issues) {
console.log(`${issue.path.join(".")}: ${issue.message}`);
}node zod-demo.jstrue { name: 'Backpack', price: 1299, category: 'bags', tags: [] }
false
name: Too small: expected string to have >=2 characters
price: Too small: expected number to be >0
category: Invalid option: expected one of "stationery"|"bags"|"electronics"Notice what the schema did with the valid data: it trimmed the name, filled in the default tags, and dropped the unknown extra field, so only expected data reaches your code. safeParse returns { success, data } or { success, error } instead of throwing. (These messages come from Zod 4.6.7, the version used here.)
Reusable validation middleware#
Wrap schema checks in middleware so every route validates the same way:
import express from "express";
import { z } from "zod";
const app = express();
app.use(express.json());
function validate(schema, source = "body") {
return (req, res, next) => {
const result = schema.safeParse(req[source]);
if (!result.success) {
return res.status(400).json({
error: "Validation failed",
details: result.error.issues.map((i) => ({ field: i.path.join("."), message: i.message })),
});
}
req.valid = result.data; // validated, cleaned data
next();
};
}
const NewOrder = z.object({
productId: z.number().int().positive(),
quantity: z.number().int().min(1).max(10),
pincode: z.string().regex(/^\d{6}$/, "pincode must be 6 digits"),
});
const ListQuery = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(50).default(10),
});
app.post("/api/orders", validate(NewOrder), (req, res) => {
res.status(201).json({ message: "Order placed", order: req.valid });
});
app.get("/api/orders", validate(ListQuery, "query"), (req, res) => {
res.json({ page: req.valid.page, limit: req.valid.limit });
});
app.listen(3000);curl -s -X POST localhost:3000/api/orders -H "Content-Type: application/json" -d '{"productId":3,"quantity":2,"pincode":"560001"}'
curl -s -X POST localhost:3000/api/orders -H "Content-Type: application/json" -d '{"productId":3,"quantity":20,"pincode":"5600"}'
curl -s "localhost:3000/api/orders"
curl -s "localhost:3000/api/orders?page=3&limit=20"
curl -s "localhost:3000/api/orders?limit=500"{"message":"Order placed","order":{"productId":3,"quantity":2,"pincode":"560001"}}
{"error":"Validation failed","details":[{"field":"quantity","message":"Too big: expected number to be <=10"},{"field":"pincode","message":"pincode must be 6 digits"}]}
{"page":1,"limit":10}
{"page":3,"limit":20}
{"error":"Validation failed","details":[{"field":"limit","message":"Too big: expected number to be <=50"}]}z.coerce.number() converts the query string's text into numbers before checking, since everything in a URL is a string. Validated data is stored on req.valid rather than overwriting req.query, which Express 5 makes read-only.
What to validate#
- Types and required fields: strings, numbers, booleans, arrays.
- Ranges and lengths: quantities, prices, string lengths (also limits the size of what you store).
- Formats: email, phone, PIN code, dates, ids.
- Allowed values: enums for status, category, sort order.
- Ownership and permissions (is this order yours?), which is a business rule checked after validation.
Common mistakes#
Practice#
Practice exercise
Write a Zod schema for a review: rating an integer from 1 to 5, comment a string of 10 to 500 characters (trimmed), and optional recommend boolean defaulting to true. Test it with a valid and an invalid object and print the results.
Show hint
z.number().int().min(1).max(5), z.string().trim().min(10).max(500), z.boolean().default(true).
Show solution
import { z } from "zod";
const Review = z.object({
rating: z.number().int().min(1).max(5),
comment: z.string().trim().min(10).max(500),
recommend: z.boolean().default(true),
});
console.log(Review.safeParse({ rating: 5, comment: " Fast delivery, great quality. " }).data);
const result = Review.safeParse({ rating: 6, comment: "Bad" });
console.log(result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`));node review.js{
rating: 5,
comment: 'Fast delivery, great quality.',
recommend: true
}
[
'rating: Too big: expected number to be <=5',
'comment: Too small: expected string to have >=10 characters'
]Key takeaway#
Next: Responses and Status Codes.
Finished this lesson?