5 Express.js Middleware Patterns You'll Use in Every App
Middleware is Express's superpower. These 5 patterns cover 90% of what you need.
What Is Middleware?
Request → [Middleware 1] → [Middleware 2] → [Middleware 3] → Response
↓ ↓ ↓
Log auth Parse body Handle error
Each middleware can:
- Modify request (req) or response (res)
- End the response (res.send())
- Pass control to next middleware (next())
- Throw an error (next(err))
Pattern 1: Request Validation Middleware
// ❌ Validation scattered in every route:
app.post('/api/users', async (req, res) => {
if (!req.body.email) return res.status(400).json({ error: 'email required' });
if (!isValidEmail(req.body.email)) return res.status(400).json({ error: 'invalid email' });
if (!req.body.name || req.body.name.length > 100) return res.status(400).json({ error: 'invalid name' });
// ... actual logic buried under validation noise
});
// ✅ Reusable validation middleware:
function validate(schema) {
return (req, res, next) => {
const { error, value } = schema.validate(req.body, {
abortEarly: false, // Return ALL errors, not just first
stripUnknown: true, // Remove extra fields
});
if (error) {
const details = error.details.map(e => ({
field: e.path.join('.'),
issue: e.message,
}));
return res.status(422).json({
error: { code: 'VALIDATION_ERROR', message: 'Invalid input', details }
});
}
req.body = value; // Use sanitized values
next();
};
}
// Usage:
const Joi = require('joi');
const createUserSchema = Joi.object({
email: Joi.string().email().required(),
name: Joi.string().min(1).max(100).required(),
role: Joi.string().valid('user', 'admin').default('user'),
});
app.post('/api/users', validate(createUserSchema), async (req, res) => {
// req.body is already validated and sanitized!
const user = await User.create(req.body);
res.status(201).json({ data: user });
});
Pattern 2: Authentication Middleware
// JWT authentication middleware
function auth(options = {}) {
const { required = true, roles = [] } = options;
return async (req, res, next) => {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
if (required) return res.status(401).json({ error: 'Missing token' });
return next(); // Optional auth — proceed as anonymous
}
try {
const token = header.split(' ')[1];
const payload = jwt.verify(token, process.env.JWT_SECRET);
req.user = payload; // Attach user to request
// Role check
if (roles.length && !roles.includes(payload.role)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
} catch (err) {
if (required) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
next(); // Invalid token but auth not required
}
};
}
// Usage variations:
// Require authentication
app.get('/api/profile', auth(), (req, res) => {
res.json({ data: req.user });
});
// Optional authentication (works for logged-in and guest users)
app.get('/api/products', auth({ required: false }), (req, res) => {
const prices = req.user ? getDiscountPrices() : getRegularPrices();
res.json({ data: prices });
});
// Role-based access
app.delete('/api/users/:id', auth({ roles: ['admin'] }), async (req, res) => {
await User.delete(req.params.id);
res.json({ success: true });
});
Pattern 3: Rate Limiting with Context
// Simple in-memory rate limiter (for single-server apps)
function rateLimit(options = {}) {
const {
windowMs = 60_000, // 1 minute window
max = 100, // Max requests per window
keyGenerator = (req) => req.ip,
message = { error: 'Too many requests' },
} = options;
const requests = new Map();
return (req, res, next) => {
const key = keyGenerator(req);
const now = Date.now();
// Clean old entries
if (requests.has(key)) {
const entry = requests.get(key);
if (now - entry.resetTime > windowMs) {
requests.delete(key); // Window expired
}
}
if (!requests.has(key)) {
requests.set(key, { count: 1, resetTime: now + windowMs });
} else {
const entry = requests.get(key);
entry.count++;
if (entry.count > max) {
const retryAfter = Math.ceil((entry.resetTime - now) / 1000);
res.set('Retry-After', String(retryAfter));
res.status(429).json(message);
return;
}
}
// Add rate limit headers
const entry = requests.get(key);
res.set('X-RateLimit-Limit', String(max));
res.set('X-RateLimit-Remaining', String(Math.max(0, max - entry.count)));
res.set('X-RateLimit-Reset', new Date(entry.resetTime).toISOString());
next();
};
}
// Usage:
// Global rate limiter
app.use(rateLimit({ max: 1000 }));
// Stricter for auth endpoints
app.post('/api/login',
rateLimit({ max: 5, windowMs: 15 * 60_000 }), // 5 per 15 min
handleLogin
);
// Per-user rate limiter
app.post('/api/invite',
rateLimit({ max: 3, windowMs: 3600_000, keyGenerator: (req) => req.user.id }),
sendInvite
);
Pattern 4: Async Error Handler
// The #1 Express bug source: unhandled promise rejections
// This wrapper fixes it forever:
function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
// Now you can use async/await in routes WITHOUT try/catch everywhere:
app.get('/api/users/:id', asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) throw new NotFoundError('User', req.params.id); // Goes to error handler!
res.json({ data: user });
}));
// Combined with the global error handler from earlier:
app.use((err, req, res, _next) => {
console.error(`[ERR] ${req.method} ${req.path}:`, err.message);
const statusCode = err.statusCode || err.status || 500;
res.status(statusCode).json({
error: {
code: err.code || 'INTERNAL_ERROR',
message: statusCode === 500 && process.env.NODE_ENV === 'production'
? 'Internal server error'
: err.message,
},
});
});
Pattern 5: Request Logging & Correlation
// Add unique ID to every request for tracing
function requestId(req, _res, next) {
req.id = crypto.randomUUID();
next();
}
// Structured logging middleware
function requestLogger(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'],
};
if (res.statusCode >= 400) {
console.error(JSON.stringify(logData)); // Errors to stderr
} else {
console.log(JSON.stringify(logData)); // Normal to stdout
}
});
next();
}
// Apply to all routes
app.use(requestId);
app.use(requestLogger);
// Now every log line has a request ID for correlation:
// {"id":"abc123","method":"GET","url":"/api/users","status":200,"duration":"12ms","ip":"1.2.3.4"}
// When you see an error in logs, search for the ID to find the full request flow
Putting It All Together
const express = require('express');
const app = express();
// Order matters! Middleware runs in sequence.
// 1. Core
app.use(express.json({ limit: '10kb' }));
app.use(requestId());
app.use(requestLogger());
// 2. Security
app.use(rateLimit({ max: 1000 }));
app.use(helmet());
// 3. Routes
app.post('/api/users',
rateLimit({ max: 30, windowMs: 60_000 }),
validate(createUserSchema),
asyncHandler(async (req, res) => {
const user = await User.create(req.body);
res.status(201).json({ data: user });
})
);
app.get('/api/profile',
auth(),
asyncHandler(async (req, res) => {
res.json({ data: req.user });
})
);
app.delete('/api/users/:id',
auth({ roles: ['admin'] }),
asyncHandler(async (req, res) => {
await User.delete(req.params.id);
res.json({ success: true });
})
);
// 4. Error handler (MUST be last)
app.use((err, req, res, _next) => {
const statusCode = err.statusCode || 500;
res.status(statusCode).json({
error: {
code: err.code || 'INTERNAL_ERROR',
message: statusCode === 500 && process.env.NODE_ENV === 'production'
? 'Internal server error'
: err.message,
requestId: req.id,
},
});
});
app.listen(3000);
Quick Reference Card
| Pattern | Purpose | Package Needed |
|---|---|---|
validate() |
Input sanitization | joi / zod |
auth() |
Authentication | jsonwebtoken |
rateLimit() |
Throttling | None (custom) or express-rate-limit |
asyncHandler() |
Error wrapping | None |
requestId() |
Request tracing | crypto (built-in) |
What's your favorite Express middleware pattern? Share it below!
Follow @armorbreak for more Node.js content.
Top comments (0)