Core Patterns
Express middleware executes in the order it is registered. Each middleware either calls next() to pass control forward, sends a response to end the chain, or calls next(err) to jump to error-handling middleware. Correct ordering is not optional — it determines security, correctness, and performance.
Middleware Execution Order and Composition
Register middleware from outermost concern (logging, body parsing) to innermost (route handlers). Auth and rate-limiting must run before business logic.
import express, { Request, Response, NextFunction } from 'express';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
const app = express();
// Layer 1 — Security headers (must be first, before any response)
app.use(helmet());
// Layer 2 — Body parsing (before any route reads req.body)
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true }));
// Layer 3 — Request logging (after body parse so body is available to log)
app.use(requestLogger);
// Layer 4 — Rate limiting (before auth to block floods early)
app.use('/api/', rateLimit({ windowMs: 60_000, max: 100 }));
// Layer 5 — Authentication (before any protected route)
app.use('/api/', authenticate);
// Layer 6 — Routes (after all cross-cutting middleware)
app.use('/api/v1/users', userRouter);
app.use('/api/v1/orders', orderRouter);
// Layer 7 — 404 handler (after all routes, before error handler)
app.use((_req, res) => res.status(404).json({ error: 'Not found' }));
// Layer 8 — Error handler (must be last, 4-argument signature)
app.use(errorHandler);
Error-Handling Middleware
Express identifies error middleware by the four-argument signature (err, req, res, next). It must be the last app.use() call. All other middleware propagates errors by calling next(err).
import { ErrorRequestHandler, Request, Response, NextFunction } from 'express';
// Custom error class carries HTTP status
class AppError extends Error {
constructor(
public readonly statusCode: number,
message: string
) {
super(message);
this.name = 'AppError';
}
}
// Centralized error handler — single place for all error responses
const errorHandler: ErrorRequestHandler = (
err: Error,
req: Request,
res: Response,
next: NextFunction
) => {
// Operational errors: known, expected
if (err instanceof AppError) {
return res.status(err.statusCode).json({ error: err.message });
}
// Zod validation errors
if (err.name === 'ZodError') {
return res.status(400).json({ error: 'Validation failed', details: err });
}
// Programming errors: unknown, log and hide detail
console.error('[Unhandled]', err);
res.status(500).json({ error: 'Internal server error' });
};
// Usage: next(err) from any middleware or route jumps here
app.use(errorHandler);
Never call next() after sending a response. Check res.headersSent if there is any ambiguity:
const safeNext = (res: Response, next: NextFunction, err?: Error) => {
if (!res.headersSent) next(err);
};
Custom Middleware Factory Pattern
A middleware factory is a function that accepts configuration and returns a middleware function. This keeps middleware configurable without global state.
import { Request, Response, NextFunction, RequestHandler } from 'express';
// Factory: returns middleware configured with options
function requireRole(allowedRoles: string[]): RequestHandler {
return (req: Request, res: Response, next: NextFunction) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ error: 'Unauthenticated' });
if (!allowedRoles.includes(user.role)) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
}
// Factory: configurable rate limiter per route group
function createRateLimiter(max: number, windowMs: number): RequestHandler {
const counts = new Map<string, { count: number; reset: number }>();
return (req: Request, res: Response, next: NextFunction) => {
const key = req.ip ?? 'unknown';
const now = Date.now();
const entry = counts.get(key);
if (!entry || now > entry.reset) {
counts.set(key, { count: 1, reset: now + windowMs });
return next();
}
if (entry.count >= max) {
return res.status(429).json({ error: 'Too many requests' });
}
entry.count++;
next();
};
}
// Usage: compose factories per route group
app.use('/api/admin', requireRole(['admin']));
app.use('/api/public', createRateLimiter(30, 60_000));
Authentication Chain Middleware
Auth is not a single middleware — it is a chain: verify token, load user, attach to request. Each step has a single responsibility.
import jwt from 'jsonwebtoken';
import { RequestHandler } from 'express';
// Extend Express Request with typed user
declare global {
namespace Express {
interface Request {
user?: { id: string; role: string; email: string };
}
}
}
// Step 1: Extract and verify JWT from Authorization header
const verifyToken: RequestHandler = (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing token' });
}
const token = authHeader.slice(7);
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!) as {
sub: string;
role: string;
email: string;
};
// Attach minimal decoded payload — do NOT trust claims from client
req.user = { id: payload.sub, role: payload.role, email: payload.email };
next();
} catch {
res.status(401).json({ error: 'Invalid or expired token' });
}
};
// Step 2: Load full user from DB (only when route needs complete profile)
const loadUser: RequestHandler = async (req, res, next) => {
try {
const user = await userRepository.findById(req.user!.id);
if (!user) return res.status(401).json({ error: 'User not found' });
req.user = { id: user.id, role: user.role, email: user.email };
next();
} catch (err) {
next(err);
}
};
// Usage: chain only what each route needs
router.get('/profile', verifyToken, loadUser, getProfile);
router.get('/feed', verifyToken, getFeed); // no DB lookup needed
Request Validation Middleware with Zod
Validation middleware parses and replaces req.body with the typed, safe result. Downstream handlers receive validated data — no need to validate again.
import { z, ZodSchema } from 'zod';
import { RequestHandler } from 'express';
// Generic factory: validate any part of the request
function validate<T>(
schema: ZodSchema<T>,
source: 'body' | 'params' | 'query' = 'body'
): RequestHandler {
return (req, res, next) => {
const result = schema.safeParse(req[source]);
if (!result.success) {
return res.status(400).json({
error: 'Validation failed',
details: result.error.flatten(),
});
}
// Replace source with parsed, type-safe data
(req as any)[source] = result.data;
next();
};
}
// Schemas
const CreateUserSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
role: z.enum(['user', 'admin']).default('user'),
});
const UserIdParamSchema = z.object({
id: z.string().uuid(),
});
// Applied per route
router.post(
'/users',
verifyToken,
requireRole(['admin']),
validate(CreateUserSchema, 'body'),
createUserHandler
);
router.get(
'/users/:id',
verifyToken,
validate(UserIdParamSchema, 'params'),
getUserHandler
);
After validation middleware runs, handlers can cast safely:
const createUserHandler: RequestHandler = async (req, res, next) => {
// req.body is already validated — safe to use directly
const data = req.body as z.infer<typeof CreateUserSchema>;
try {
const user = await userService.create(data);
res.status(201).json(user);
} catch (err) {
next(err);
}
};