5 Express.js Middleware Patterns You'll Use in Every App
Middleware is what makes Express powerful. These patterns show up in every production app.
What Is Middleware?
// A function that has access to req, res, and next
function myMiddleware(req, res, next) {
// Do something with the request
console.log(`${req.method} ${req.path}`);
// Either respond (end the chain)
if (req.path === '/health') {
return res.json({ status: 'ok' });
}
// Or pass control to the next middleware
next();
}
app.use(myMiddleware);
Pattern 1: Request Logging & Correlation IDs
import { randomUUID } from 'crypto';
// Add a unique ID to every request for tracing
function requestId(req, _res, next) {
req.id = req.headers['x-request-id'] || randomUUID();
next();
}
// Structured logging with context
function logger(req, _res, next) {
const start = Date.now();
_res.on('finish', () => {
const duration = Date.now() - start;
const logData = {
id: req.id,
method: req.method,
url: req.originalUrl,
status: _res.statusCode,
duration: `${duration}ms`,
ip: req.ip,
userAgent: req.headers['user-agent'],
};
console.log(JSON.stringify(logData));
});
next();
}
app.use(requestId);
app.use(logger);
// Output: {"id":"abc123","method":"GET","url":"/api/users","status":200,"duration":"12ms","ip":"1.2.3.4"}
Pattern 2: Rate Limiting (Custom Implementation)
// Simple in-memory rate limiter
const rateLimits = new Map();
function rateLimit({ windowMs = 60000, max = 100 } = {}) {
return function rateLimitMiddleware(req, res, next) {
const key = req.ip;
const now = Date.now();
const record = rateLimits.get(key) || { count: 0, resetAt: now + windowMs };
if (now > record.resetAt) {
record.count = 0;
record.resetAt = now + windowMs;
}
record.count++;
rateLimits.set(key, record);
// Add headers so clients know their limit
res.setHeader('X-RateLimit-Limit', max);
res.setHeader('X-RateLimit-Remaining', Math.max(0, max - record.count));
res.setHeader('X-RateLimit-Reset', new Date(record.resetAt).toISOString());
if (record.count > max) {
return res.status(429).json({
error: { code: 'RATE_LIMITED', message: 'Too many requests' }
});
}
next();
};
}
// Different limits for different routes
app.use('/api/auth/', rateLimit({ max: 5, windowMs: 60 * 1000 })); // 5/min for auth
app.use('/api/', rateLimit({ max: 100, windowMs: 60 * 1000 })); // 100/min general
// Clean up old entries periodically
setInterval(() => {
const now = Date.now();
for (const [key, record] of rateLimits.entries()) {
if (now > record.resetAt) rateLimits.delete(key);
}
}, 5 * 60 * 1000);
Pattern 3: Authentication & Authorization
// JWT authentication middleware
function auth(req, res, next) {
const header = req.headers.authorization;
if (!header || !header.startsWith('Bearer ')) {
return res.status(401).json({
error: { code: 'AUTH_MISSING', message: 'Authorization required' }
});
}
const token = header.split(' ')[1];
try {
const payload = jwt.verify(token, process.env.JWT_SECRET);
req.user = payload; // Attach user to request
next();
} catch (err) {
return res.status(401).json({
error: { code: 'TOKEN_INVALID', message: 'Invalid or expired token' }
});
}
}
// Role-based authorization (requires auth to run first)
function requireRole(...roles) {
return function roleCheck(req, res, next) {
if (!roles.includes(req.user.role)) {
return res.status(403).json({
error: { code: 'FORBIDDEN', message: `Requires role: ${roles.join(' or ')}` }
});
}
next();
};
}
// Usage:
app.get('/api/profile', auth, (req, res) => {
res.json({ data: req.user });
});
app.delete('/api/users/:id', auth, requireRole('admin'), (req, res) => {
// Only admins can delete users
});
Pattern 4: Error Handling Wrapper
// Wrap async route handlers to catch errors automatically
function asyncHandler(fn) {
return function asyncHandlerWrapper(req, res, next) {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
// Without wrapper — error handling is verbose
app.get('/users/:id', async (req, res, next) => {
try {
const user = await User.findById(req.params.id);
res.json({ data: user });
} catch (err) {
next(err); // Must remember to call next(err)!
}
});
// With wrapper — clean and safe
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
res.json({ data: user }));
}));
// Works with all HTTP methods
app.post('/users', asyncHandler(async (req, res) => {
const user = await User.create(req.body);
res.status(201).json({ data: user });
}));
Pattern 5: Validation Middleware
// Reusable validation using Zod schemas
const z = require('zod');
function validate(schema) {
return function validateMiddleware(req, _res, next) {
try {
// Validate body, query, or params based on schema shape
const result = schema.safeParse({
body: req.body,
query: req.query,
params: req.params,
});
if (!result.success) {
const errors = result.error.errors.map(e => ({
field: e.path.join('.'),
issue: e.message,
}));
return _res.status(422).json({
error: { code: 'VALIDATION_ERROR', message: 'Invalid input', details: errors }
});
}
// Replace request data with validated/transformed data
Object.assign(req, result.data);
next();
} catch (err) {
next(err);
}
};
}
// Define validation schemas
const createUserSchema = z.object({
body: z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
password: z.string().min(8).regex(/^(?=.*[A-Z])(?=.*[0-9])/),
role: z.enum(['user', 'admin']).optional().default('user'),
}),
query: z.object({}).optional(),
params: z.object({}).optional(),
});
const updateUserSchema = z.object({
params: z.object({ id: z.string().uuid() }),
body: z.object({
name: z.string().min(1).max(100).optional(),
email: z.string().email().optional(),
}).partial(), // All fields optional for updates
});
// Apply to routes
app.post('/users', validate(createUserSchema), asyncHandler(async (req, res) => {
// req.body is already validated and typed!
const user = await User.create(req.body.body);
res.status(201).json({ data: user });
}));
app.put('/users/:id', validate(updateUserSchema), asyncHandler(async (req, res) => {
const user = await User.update(req.body.params.id, req.body.body);
res.json({ data: user });
}));
The Complete Middleware Stack
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const app = express();
// Order matters!
// 1. Security headers (always first)
app.use(helmet());
// 2. CORS configuration
app.use(cors({ origin: process.env.ALLOWED_ORIGINS?.split(',') }));
// 3. Request parsing (with limits)
app.use(express.json({ limit: '10kb' }));
app.use(express.urlencoded({ extended: true, limit: '10kb' }));
// 4. App-level middleware
app.use(requestId);
app.use(logger);
// 5. Health check (no auth needed)
app.get('/health', (_req, res) => {
res.json({ status: 'ok', time: new Date().toISOString() });
});
// 6. Public routes
app.use('/api/public', publicRoutes);
// 7. Authenticated routes
app.use('/api', auth, authenticatedRoutes);
// 8. Admin routes
app.use('/admin', auth, requireRole('admin'), adminRoutes);
// 9. Error handler (MUST be last)
app.use((err, req, res, _next) => {
console.error(`[ERR] ${req.id}:`, err);
res.status(err.statusCode || 500).json({
error: {
code: err.code || 'INTERNAL_ERROR',
message: process.env.NODE_ENV === 'production' ? 'Internal error' : err.message,
},
});
});
// 404 handler (no route matched)
app.use((_req, res) => {
res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Route not found' } });
});
Quick Reference
| Pattern | Package Needed | When to Use |
|---|---|---|
| Request logging | None (custom) | Every app |
| Rate limiting |
express-rate-limit or custom |
Public APIs |
| Auth |
jsonwebtoken, passport
|
Protected routes |
| Async handling | None (one-liner) | Any async route |
| Validation |
zod, joi, or yup
|
Input from users |
| Error handling | None (built-in) | Always at the end |
| Security | helmet |
Every public-facing app |
| CORS | cors |
APIs used by browsers |
What's your favorite Express middleware pattern? Share it!
Follow @armorbreak for more Node.js guides.
Top comments (0)