Skip to content
Laviyx Learn

Try

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.

Intermediate10 min readUpdated
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#

JavaScript
app.use(express.json({ limit: "100kb" }));          // JSON bodies (with a size limit)
app.use(express.urlencoded({ extended: true }));    // HTML form bodies

Malformed 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:

manual.js (JavaScript)
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);
Terminal
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":'
Output
{"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"]}
400

The 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:

Terminal
npm install zod
zod-demo.js (JavaScript)
import { 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}`);
}
Terminal
node zod-demo.js
Output
true { 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:

validated.js (JavaScript)
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);
Terminal
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"
Output
{"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
review.js (JavaScript)
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}`));
Terminal
node review.js
Output
{
  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?