Reusable, Configurable Middleware
Ship middleware as small factories so the same logic works across projects and routes with different options.
Once you have written the same auth check or logger three times, it is time to promote it into a proper middleware factory: a function that takes options and returns the actual (req, res, next) handler. This is exactly how express.static, helmet and cors are shaped, and following the same pattern makes your code feel native to the ecosystem.
The factory shape
The outer function runs once at wire-up time and closes over your configuration. The inner function runs per request. Keeping expensive setup (compiling a schema, reading a key) in the outer scope is a small habit that pays off under load.
const requireHeader = (name) => (req, res, next) =>
req.get(name) ? next() : res.status(400).json({ error: `Missing ${name}` });
app.use(requireHeader('x-request-id'));Why this matters
- One implementation, many configurations — no copy-paste drift.
- Trivial to unit test: call the factory, then call the returned function with fake
req/res. - Composable — you can stack several factories on a single route.
Example
// middleware/cache.js
// A configurable in-flight response cache. Note how the Map and TTL live
// in the factory scope, created once, while the closure runs per request.
export function cache({ ttlMs = 30_000, keyFn = (req) => req.originalUrl } = {}) {
const store = new Map(); // { key -> { body, expires } }
return function cacheMiddleware(req, res, next) {
if (req.method !== 'GET') return next();
const key = keyFn(req);
const hit = store.get(key);
if (hit && hit.expires > Date.now()) {
res.set('X-Cache', 'HIT');
return res.json(hit.body);
}
// Wrap res.json so we transparently capture the payload on the way out.
const originalJson = res.json.bind(res);
res.json = (body) => {
store.set(key, { body, expires: Date.now() + ttlMs });
res.set('X-Cache', 'MISS');
return originalJson(body);
};
next();
};
}
// usage
// app.get('/reports', cache({ ttlMs: 60_000 }), reportsController.index);When to use it
- A developer packages an access-logging middleware as an npm module so multiple Express services in a monorepo share the same implementation.
- A team writes a reusable validate(schema) factory that accepts a Joi schema and returns a middleware function, enabling one-liner validation per route.
- A configurable timeout middleware factory accepts a ms parameter so different route groups can enforce different maximum response times.
More examples
Middleware factory pattern
Creates a middleware factory that closes over a role string and returns a role-checking middleware for any Express route.
function requireRole(role) {
return (req, res, next) => {
if (req.user?.role !== role) {
return res.status(403).json({ error: `Role '${role}' required` });
}
next();
};
}
app.get('/admin', requireRole('admin'), (req, res) => res.send('Admin only'));
app.get('/editor', requireRole('editor'), (req, res) => res.send('Editor only'));Reusable schema validator
A validate() factory takes a Joi schema and returns a middleware — reuse across routes by passing different schemas.
const Joi = require('joi');
function validate(schema) {
return (req, res, next) => {
const { error } = schema.validate(req.body, { abortEarly: false });
if (error) return res.status(400).json({ errors: error.details.map(d => d.message) });
next();
};
}
const userSchema = Joi.object({ name: Joi.string().required(), email: Joi.string().email().required() });
app.post('/users', validate(userSchema), (req, res) => res.status(201).json(req.body));Configurable timeout middleware
Returns a middleware that cancels a response timeout when the response finishes, with the ms value configured per route group.
function timeout(ms) {
return (req, res, next) => {
const timer = setTimeout(() => {
res.status(503).json({ error: 'Request timed out' });
}, ms);
res.on('finish', () => clearTimeout(timer));
next();
};
}
app.use('/api/fast', timeout(2000));
app.use('/api/slow', timeout(30000));
Discussion