Home / Articles / Guarding the Express Boundary: One Zod Middleware for Body, Params and Query

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.

1982 words

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 explicit null; .default() fills in absent values.
  • z.coerce is 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 only result.data reach handlers.
  • Centralize validation in one middleware, and make each schema match the shape it parses.
  • Use z.coerce for 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.