Skip to content

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.

Attach a unique ID to every request for log correlation:

src/middleware/requestId.ts
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.locals
app.get('/posts', (req, res) => {
console.log('Request ID:', res.locals.requestId)
res.json(posts)
})

Validate that required fields are present before reaching the route handler:

src/middleware/validate.ts
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 simple in-memory rate limiter for understanding the pattern:

src/middleware/rateLimit.ts
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.

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' })
}
}
  1. Create src/middleware/validate.ts with the requireFields factory.
  2. Apply it to your POST /posts route.
  3. Test with a request missing title — confirm you get a 400 error.
  4. 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.locals is where you attach request-scoped data for downstream handlers.
  • Validation middleware keeps route handlers clean — validate early, handle errors in one place.