In our previous posts, we built a beautiful, robust architecture. We have separated our routes and controllers, introduced middleware, and implemented a global error handler that catches every mistake.

Our kitchen is clean and our staff is trained. But we still have a major vulnerability: We trust our customers too much.

If you look at our createUser controller, it probably looks something like this:

export const createUser = asyncHandler(async (req, res) => {
    const { name, email, password } = req.body;
    
    if (!name || typeof name !== 'string') {
        throw new ApiError(400, "Name is required and must be text");
    }
    if (!email || !email.includes('@')) {
        throw new ApiError(400, "Valid email is required");
    }
    if (!password || password.length < 8) {
        throw new ApiError(400, "Password must be at least 8 characters");
    }
    
    // Finally... actually create the user
});

Half of the controller is just checking if the data is correct! As your application grows and your data gets more complex, writing these manual if statements becomes a nightmare. You will inevitably forget one, and bad data will crash your app or corrupt your database.

Today, we learn rule number one of backend development: Never trust client data.

We are going to move validation entirely out of our controllers using an elegant library called Zod.

The Bouncer with a Clipboard

If our standard middleware (like checking an API key) is the bouncer checking IDs, Validation Middleware is the bouncer making sure you actually meet the dress code before you bother the chef.

Instead of the chef (Controller) yelling that the order is missing ingredients, the bouncer stops the bad order at the door.

Enter Zod

Zod is a TypeScript-first schema declaration and validation library. Even if we are writing pure JavaScript, Zod's API is incredibly intuitive and powerful.

Let's install it:

npm install zod

Step 1: Define the "Dress Code" (The Schema)

A schema is just a blueprint of what perfect data should look like. Let's create a dedicated folder for our schemas.

Create: src/schemas/user.schema.js
import { z } from 'zod';
 
export const userRegistrationSchema = z.object({
    body: z.object({
        name: z.string({
            required_error: "Name is required",
        }).min(2, "Name must be at least 2 characters"),
        
        email: z.string({
            required_error: "Email is required",
        }).email("Not a valid email address"),
        
        password: z.string({
            required_error: "Password is required",
        }).min(8, "Password must be at least 8 characters")
          .regex(/[a-zA-Z]/, "Password must contain at least one letter")
          .regex(/[0-9]/, "Password must contain at least one number")
    })
});

Look how readable that is! We've defined exactly what we expect inside req.body and even provided custom error messages. Zod has built-in checkers for emails, URLs, minimum lengths, and complex regex patterns.

Step 2: The Universal Validation Middleware

Now we need a way to apply this schema to our route. We will build a single, reusable middleware function that takes any Zod schema and validates the incoming request against it.

Create: src/middlewares/validate.middleware.js
import { ZodError } from 'zod';
import { ApiError } from '../utils/ApiError.js';
 
const validate = (schema) => (req, res, next) => {
    try {
        schema.parse({
            body: req.body,
            params: req.params,
            query: req.query
        });
        next();
    } catch (err) {
        if (err instanceof ZodError) {
            const zodIssues = Array.isArray(err.issues)
                ? err.issues
                : Array.isArray(err.errors)
                    ? err.errors
                    : [];
 
            const errors = zodIssues.map((e) => ({
                field: Array.isArray(e.path)
                    ? e.path.filter((p) => p !== 'body').join('.')
                    : '',
                message: e.message,
            }));
 
            return next(new ApiError(400, errors[0]?.message || 'Validation error', errors));
        }
        next(err);
    }
};
 
export { validate };

What's Happening Here?

  1. validate is a function that returns a middleware function (a concept known as currying or Higher-Order Functions). This allows us to pass a specific schema to it in our route definitions.
  2. We pass the body, query, and params into schema.parse(). If they match the blueprint, Zod stays silent, and we call next().
  3. If they don't match, Zod throws an error. We catch it, map over the detailed Zod errors to make them look clean, and throw our trusty ApiError which sends a 400 Bad Request to the user.

Step 3: Wire it into the Route

Now, we plug our new Bouncer directly into the route.

Update: src/routes/user.routes.js
import express from 'express';
import { createUser } from '../controllers/user.controller.js';
import { validate } from '../middlewares/validate.middleware.js';
import { userRegistrationSchema } from '../schemas/user.schema.js';
 
const router = express.Router();
 
// Add the validation middleware BEFORE the controller
router.post(
    '/', 
    validate(userRegistrationSchema), 
    createUser
);
 
export default router;

Step 4: The Clean Controller

Now, return to your user controller. It no longer needs to worry about bad emails or short passwords. If the code reaches the controller, the data is guaranteed to be perfect.

export const createUser = asyncHandler(async (req, res) => {
    // Look ma, no manual validation!
    const { name, email, password } = req.body;
    
    const newUser = await User.create({ name, email, password });
    
    return res.status(201).json(
        new ApiResponse(201, newUser, "User created successfully")
    );
});

The Result

If a user tries to send a request with a short password and no email:

{
  "name": "A"
}

Our global error handler immediately returns:

{
  "statusCode": 400,
  "message": "Validation Failed",
  "errors": [
    { "field": "body.email", "message": "Email is required" },
    { "field": "body.password", "message": "Password is required" },
    { "field": "body.name", "message": "Name must be at least 2 characters" }
  ],
  "success": false
}

Beautiful, standard, and automated.

Summary

By moving validation into schemas with Zod and executing it via middleware, we achieved Separation of Concerns.

  • Schemas define what the data should look like.
  • Middleware enforces the rules.
  • Controllers execute the business logic.

Now that we can confidently validate incoming data, what happens when a user wants to read a lot of data? If we have 10,000 users, .find() will crash our server.

In the next post, we’ll dive into URL Queries and learn how to implement Pagination, Filtering, and Sorting so we can serve massive amounts of data without breaking a sweat.

Happy coding!