Writing Custom Middleware
Third-party middleware handles common needs; custom middleware handles yours. Writing middleware is straightforward once you understand the (req, res, next) pattern.
A request ID middleware
Section titled “A request ID middleware”Attach a unique ID to every request for log correlation:
import { Request, Response, NextFunction } from 'express'import { randomUUID } from 'crypto'
export function requestId(req: Request, res: Response, next: NextFunction) { const id = randomUUID() res.locals.requestId = id res.set('X-Request-Id', id) next()}// In a route handler, access it via res.localsapp.get('/posts', (req, res) => { console.log('Request ID:', res.locals.requestId) res.json(posts)})A request body validator
Section titled “A request body validator”Validate that required fields are present before reaching the route handler:
import { Request, Response, NextFunction } from 'express'
export function requireFields(...fields: string[]) { return (req: Request, res: Response, next: NextFunction) => { const missing = fields.filter(f => !req.body[f]) if (missing.length > 0) { return res.status(400).json({ error: `Missing required fields: ${missing.join(', ')}` }) } next() }}Use it on specific routes:
import { requireFields } from './middleware/validate.js'
app.post('/posts', requireFields('title', 'body'), (req, res) => { // req.body.title and req.body.body are guaranteed to exist const { title, body } = req.body // ... create post})
app.post('/auth/register', requireFields('username', 'password'), (req, res) => { // ...})This is a middleware factory — a function that returns a middleware function. The inner function closes over fields.
A rate limiter (manual example)
Section titled “A rate limiter (manual example)”A simple in-memory rate limiter for understanding the pattern:
import { Request, Response, NextFunction } from 'express'
const requests = new Map<string, { count: number; resetAt: number }>()
export function rateLimit(maxRequests: number, windowMs: number) { return (req: Request, res: Response, next: NextFunction) => { const ip = req.ip || 'unknown' const now = Date.now() const entry = requests.get(ip)
if (!entry || entry.resetAt < now) { requests.set(ip, { count: 1, resetAt: now + windowMs }) return next() }
if (entry.count >= maxRequests) { return res.status(429).json({ error: 'Too many requests' }) }
entry.count++ next() }}In production, use express-rate-limit (covered in Module 06) — it handles edge cases this simple version misses.
Adding middleware to res.locals
Section titled “Adding middleware to res.locals”res.locals is the conventional place to attach request-scoped data:
// Auth middleware (preview of Module 06)export function authenticate(req: Request, res: Response, next: NextFunction) { const token = req.headers.authorization?.split(' ')[1] if (!token) return res.status(401).json({ error: 'Unauthorized' })
try { const user = verifyJwt(token) res.locals.user = user // Available in downstream handlers next() } catch { res.status(401).json({ error: 'Invalid token' }) }}Exercise
Section titled “Exercise”- Create
src/middleware/validate.tswith therequireFieldsfactory. - Apply it to your
POST /postsroute. - Test with a request missing
title— confirm you get a 400 error. - Test with both fields present — confirm the route handler runs.
- Custom middleware follows the same
(req, res, next)signature as built-in middleware. - Middleware factories (functions that return middleware) let you configure behavior per-route.
res.localsis where you attach request-scoped data for downstream handlers.- Validation middleware keeps route handlers clean — validate early, handle errors in one place.