Skip to content

How to Implement Request Validation in Express.js at the Edge (Before Business Logic)

Purpose

This post demonstrates how to implement request validation at the edge in Express.js, ensuring all incoming data is validated before reaching your route handlers and business logic.

Environment

  • Node.js 20.x
  • Express.js 4.18.x
  • Joi 17.x (for JavaScript)
  • Zod 3.x (for TypeScript)
  • npm 10.x

The Problem

When I built my first Express API, I validated input directly inside route handlers. This led to bloated controller code and, worse, inconsistent validation across endpoints. I kept forgetting to check certain fields, which caused runtime errors when undefined values propagated to my database layer.

Here’s the problematic pattern I started with:

src/routes/users.js
app.post('/api/users', async (req, res) => {
// Manual validation scattered throughout handler
if (!req.body.email) {
return res.status(400).json({ error: 'Email is required' });
}
if (!req.body.email.includes('@')) {
return res.status(400).json({ error: 'Invalid email format' });
}
if (!req.body.password || req.body.password.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters' });
}
if (!req.body.name) {
return res.status(400).json({ error: 'Name is required' });
}
// Finally, the actual business logic
const user = await createUser(req.body);
res.json(user);
});

This approach has several problems:

  1. Code duplication: The same email validation logic appeared in multiple routes.
  2. Incomplete validation: I forgot to check for edge cases like empty strings.
  3. No sanitization: Malicious input like { "email": "[email protected]", "role": "admin" } could sneak in extra fields.
  4. Hard to maintain: Adding a new field meant updating multiple route handlers.

When I deployed to production, I got a 2 AM call because a user somehow passed an empty string for email, crashing my database query. That’s when I realized I needed validation at the edge.

The Solution

I learned about validation middleware - functions that validate requests before they reach route handlers. This pattern is called “validation at the edge” because it happens at the boundary of your application, not deep inside business logic.

Method 1: Joi Validation Middleware

I started with Joi, a popular schema validation library for JavaScript:

src/middleware/validate.js
const Joi = require('joi');
const validateRequest = (schema) => {
return (req, res, next) => {
const { error, value } = schema.validate(req.body, {
abortEarly: false, // Return all errors, not just the first
stripUnknown: true // Remove unknown keys for security
});
if (error) {
const errors = error.details.map(detail => ({
field: detail.path.join('.'),
message: detail.message
}));
return res.status(400).json({
success: false,
error: 'Validation failed',
details: errors
});
}
// Replace req.body with validated and sanitized value
req.body = value;
next();
};
};
module.exports = { validateRequest };

Then I defined my schemas in a separate file:

src/validators/schemas.js
const Joi = require('joi');
const userSchema = Joi.object({
email: Joi.string().email().required(),
password: Joi.string().min(8).required(),
name: Joi.string().min(2).max(100).required()
});
const productSchema = Joi.object({
name: Joi.string().required(),
price: Joi.number().positive().required(),
category: Joi.string().required()
});
module.exports = { userSchema, productSchema };

Now my route handlers became clean and focused:

src/routes/users.js
const { validateRequest } = require('../middleware/validate');
const { userSchema } = require('../validators/schemas');
app.post('/api/users',
validateRequest(userSchema),
async (req, res) => {
// req.body is now validated and sanitized
const user = await createUser(req.body);
res.json(user);
}
);

When I tested with invalid input:

Terminal window
curl -X POST http://localhost:3000/api/users \
-H "Content-Type: application/json" \
-d '{"email": "invalid", "password": "short"}'

I got a helpful error response:

{
"success": false,
"error": "Validation failed",
"details": [
{ "field": "email", "message": "\"email\" must be a valid email" },
{ "field": "password", "message": "\"password\" length must be at least 8 characters long" },
{ "field": "name", "message": "\"name\" is required" }
]
}

Method 2: Zod for TypeScript Projects

When I switched to TypeScript, I found Zod provides better type inference:

src/middleware/validate.ts
import { z } from 'zod';
import { Request, Response, NextFunction } from 'express';
const validateBody = <T>(schema: z.ZodSchema<T>) => {
return (req: Request, res: Response, next: NextFunction) => {
try {
const validated = schema.parse(req.body);
req.body = validated;
next();
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
success: false,
error: 'Validation failed',
details: error.errors.map(err => ({
field: err.path.join('.'),
message: err.message
}))
});
}
next(error);
}
};
};
export { validateBody };

The best part about Zod is automatic type inference:

src/validators/schemas.ts
import { z } from 'zod';
const createUserSchema = z.object({
email: z.string().email('Invalid email format'),
password: z.string().min(8, 'Password must be at least 8 characters'),
name: z.string().min(2).max(100),
age: z.number().int().min(0).max(150).optional()
});
// TypeScript automatically infers this type from the schema
type CreateUserInput = z.infer<typeof createUserSchema>;
export { createUserSchema, CreateUserInput };

Now my route handler has full type safety:

src/routes/users.ts
import { validateBody } from '../middleware/validate';
import { createUserSchema, CreateUserInput } from '../validators/schemas';
app.post('/api/users',
validateBody(createUserSchema),
async (req: Request, res: Response) => {
// req.body is now typed as CreateUserInput
const userData: CreateUserInput = req.body;
const user = await createUser(userData);
res.json(user);
}
);

Method 3: Centralized Error Handling with Validation

I realized I could combine validation errors with a global error handler for consistency:

src/middleware/errors.js
class ValidationError extends Error {
constructor(errors) {
super('Validation failed');
this.name = 'ValidationError';
this.errors = errors;
this.statusCode = 400;
}
}
const validate = (schema, property = 'body') => {
return (req, res, next) => {
const { error, value } = schema.validate(req[property], {
abortEarly: false,
stripUnknown: true
});
if (error) {
next(new ValidationError(
error.details.map(d => ({
field: d.path.join('.'),
message: d.message,
type: d.type
}))
));
} else {
req[property] = value;
next();
}
};
};
// Global error handler
const errorHandler = (err, req, res, next) => {
if (err instanceof ValidationError) {
return res.status(err.statusCode).json({
success: false,
error: err.message,
details: err.errors
});
}
console.error('Unhandled error:', err);
res.status(500).json({
success: false,
error: 'Internal server error'
});
};
module.exports = { ValidationError, validate, errorHandler };

This allowed me to validate multiple parts of the request:

src/routes/users.js
const { validate, errorHandler } = require('../middleware/errors');
app.post('/api/users/:id',
validate(paramsSchema, 'params'), // Validate route params
validate(querySchema, 'query'), // Validate query string
validate(userSchema, 'body'), // Validate request body
userController.update
);
app.use(errorHandler); // Must be last

Method 4: Validating Query Parameters

I also needed to validate query parameters for pagination and filtering:

src/validators/schemas.js
const paginationSchema = Joi.object({
page: Joi.number().integer().min(1).default(1),
limit: Joi.number().integer().min(1).max(100).default(20),
sort: Joi.string().valid('created_at', 'updated_at', 'name'),
order: Joi.string().valid('asc', 'desc').default('desc')
});
const validateQuery = (schema) => {
return (req, res, next) => {
const { error, value } = schema.validate(req.query, {
allowUnknown: false,
stripUnknown: true
});
if (error) {
return res.status(400).json({
success: false,
error: 'Invalid query parameters',
details: error.details
});
}
req.query = value; // Apply defaults like page=1, limit=20
next();
};
};

Now my list endpoint had safe, defaulted pagination:

src/routes/users.js
app.get('/api/users',
validateQuery(paginationSchema),
async (req, res) => {
// req.query.page and req.query.limit are guaranteed to exist with defaults
const { page, limit, sort, order } = req.query;
const users = await listUsers({ page, limit, sort, order });
res.json(users);
}
);

How It Works

The key concepts behind edge validation:

  1. Middleware chain: Express processes requests through a chain of middleware functions. Validation middleware sits at the front, rejecting invalid requests early.

  2. Schema-driven validation: Instead of writing imperative validation code, you declaratively define what valid input looks like. The schema describes the expected shape, types, and constraints.

  3. Sanitization: The stripUnknown: true option removes any fields not defined in the schema. This prevents mass assignment attacks where malicious users inject unwanted fields.

  4. Early rejection: Invalid requests are rejected at the edge with clear error messages. Business logic never sees invalid data.

Common Mistakes

I made several mistakes while implementing edge validation:

1. Forgetting to validate route parameters

I validated request body but forgot about URL parameters. A user passed /api/users/abc where I expected a number, crashing my database query. I added param validation:

const paramsSchema = Joi.object({
id: Joi.string().uuid().required()
});
app.get('/api/users/:id',
validate(paramsSchema, 'params'),
userController.getById
);

2. Not handling validation errors consistently

Initially, some routes returned { error: "..." } while others returned { message: "..." }. This confused frontend developers. I centralized error handling to ensure consistent response format.

3. Using abortEarly: true

I initially used abortEarly: true, which only returned the first error. Users had to fix one error, resubmit, and get another error. I switched to abortEarly: false to return all validation errors at once.

4. Forgetting to replace req.body with sanitized value

The schema can transform input (like trimming strings or applying defaults). I forgot to do req.body = value after validation, so my route handlers received unsanitized input.

Why This Matters

Edge validation transformed my codebase:

  1. Bug prevention: Invalid data never reaches business logic. The number of runtime errors from undefined or malformed input dropped dramatically.

  2. Cleaner code: Route handlers focus on business logic, not validation. A 50-line handler became 10 lines.

  3. Consistent error responses: All validation errors return the same format, making frontend error handling simpler.

  4. Security: stripUnknown prevents mass assignment attacks. Users can’t inject role: "admin" into a user creation request.

  5. Better DX: Clear, specific error messages help developers debug faster. Instead of “Cannot read property of undefined”, they get “email is required”.

Summary

In this post, I showed how to implement request validation at the edge in Express.js using Joi and Zod. The key insight is that validation middleware catches invalid input before it reaches your business logic, reducing bugs and centralizing error handling.

For JavaScript projects, Joi provides a mature, well-documented solution. For TypeScript projects, Zod offers better type inference and developer experience.

The pattern is simple: define schemas, create validation middleware, and apply it before your route handlers. Your future self debugging a production issue at 3 AM will thank you.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments