Writing Custom Middleware
When no npm package fits — or the job is small enough not to warrant one — you write your own middleware. It's just a function with the (req, res, next) signature. This page covers the patterns that make custom middleware robust: doing one thing, attaching data to req, the configurable middleware factory pattern, async middleware, and the rules that keep the pipeline flowing.
The simplest custom middleware
// Attach a high-resolution start time, then continue:
function requestTimer(req, res, next) {
req.startTime = process.hrtime.bigint()
next() // ALWAYS hand off (or respond)
}
app.use(requestTimer)Attaching data to req for later handlers
// Resolve the current user once; every later handler can read req.user:
async function attachUser(req, res, next) {
const token = req.get('Authorization')?.replace('Bearer ', '')
if (token) {
req.user = await lookupUserByToken(token) // may be undefined
}
next()
}
app.use(attachUser)
app.get('/me', (req, res) => {
if (!req.user) return res.status(401).json({ error: 'Not logged in' })
res.json(req.user)
})The factory pattern — configurable middleware
The most important pattern: a function that returns a middleware, so callers can configure it. This is exactly how express.json({ limit }) and rateLimit({ max }) work — and how you should write reusable middleware:
// Outer function takes options; inner function is the middleware:
function requireRole(role) {
return (req, res, next) => {
if (!req.user) return res.status(401).json({ error: 'Login required' })
if (req.user.role !== role) {
return res.status(403).json({ error: 'Forbidden' })
}
next()
}
}
// Each call produces a tailored middleware:
app.get('/admin', requireRole('admin'), adminHandler)
app.get('/editor', requireRole('editor'), editorHandler)Async middleware and errors
// Express 5: a rejected promise is forwarded to the error handler automatically.
function loadResource(req, res, next) {
// Returning the promise (or async/await) lets Express catch rejections:
return (async () => {
const item = await db.items.find(req.params.id)
if (!item) {
const err = new Error('Not found')
err.status = 404
throw err // → routed to error middleware in Express 5
}
req.item = item
next()
})()
}The asyncHandler wrapper (Express 4 safety)
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next)
app.get('/items/:id', asyncHandler(async (req, res) => {
const item = await db.items.find(req.params.id) // rejection → next(err)
res.json(item)
}))Conditionally skipping work
// Short-circuit non-API paths without blocking them:
function apiOnly(req, res, next) {
if (!req.path.startsWith('/api')) return next() // skip, but continue
res.set('X-API', 'true')
next()
}
// 'route' skips the rest of THIS route's stack; 'router' skips the router:
app.get('/legacy', (req, res, next) => {
if (req.query.v === '2') return next('route') // jump to next matching route
res.send('legacy v1')
})A complete, reusable example
middleware/requestId.js
import { randomUUID } from 'node:crypto'
// Tag each request with an id and echo it back in a header.
export function requestId(headerName = 'X-Request-Id') {
return (req, res, next) => {
const id = req.get(headerName) || randomUUID()
req.id = id // available to all later middleware/logs
res.set(headerName, id) // returned to the client for tracing
next()
}
}
// usage: app.use(requestId())Custom middleware checklist
Single responsibility — one concern per function.
Always end the cycle:
next(),next(err), or a response — exactly one.Use the factory pattern when behavior should be configurable.
Namespace anything you attach to
reqto avoid collisions.On Express 4, catch async errors and forward with
next(err).Register it in the right order relative to what it depends on.