Good middleware is reused across services and teams; bad middleware leaks state, swallows errors and breaks ordering.
Reusable middleware follows the factory pattern: a function that takes options and returns (req, res, next). express.json(options), cors(options) and helmet(options) all work this way.
Design rules that separate solid middleware from fragile middleware:
- Validate options at creation time and throw early, so a misconfiguration fails at startup rather than on the first request.
- No shared mutable state between requests, unless it's deliberate and bounded (a cache with a size limit). Per-request data goes on
res.localsor a single, namespacedreqproperty. - Call `next` exactly once, or respond — never both. Forward errors with
next(err)instead of responding with your own error format; let the application's error handler decide. - Be async-safe across versions. Express 5 catches rejections, but if you also want to support Express 4 users, wrap your async body and call
next(err)yourself. - Hook the response lifecycle with
res.on("finish")/res.on("close")rather than monkey-patchingres.endwhere possible;closewithoutfinishmeans the client went away. - Avoid depending on Express when you don't need it. Middleware that only uses Node's
req/resplusnextworks with Express, Connect, and Node'shttpwith a tiny adapter; list Express as a peer dependency only if you use its helpers.
For TypeScript users, augment Express's Request interface through module augmentation so req.tenant is typed, and export your option types.
Compose related middleware into one unit with a Router or an array — app.use([a, b, c]) is valid — so consumers get the correct internal order for free.
Sub-applications (app.use("/admin", adminApp)) are the heavier form of reuse: a whole app with its own settings, emitting a "mount" event and exposing adminApp.mountpath. Test middleware in isolation with a tiny app and supertest, including error and early-exit paths.
Example
// tenant.js — publishable middleware factory
export function tenantResolver({ header = "x-tenant-id", lookup, required = true } = {}) {
if (typeof lookup !== "function") throw new TypeError("tenantResolver: lookup(id) is required");
return async function tenantResolverMiddleware(req, res, next) { // named for stack traces
try {
const id = req.get(header);
if (!id) {
if (!required) return next();
return next(Object.assign(new Error(`missing ${header} header`), { status: 400 }));
}
const tenant = await lookup(id);
if (!tenant) return next(Object.assign(new Error("unknown tenant"), { status: 404 }));
res.locals.tenant = tenant; // per-request, no shared state
next();
} catch (err) {
next(err); // also correct under Express 4
}
};
}
// usage
import express from "express";
const app = express();
app.use(tenantResolver({ lookup: async (id) => (id === "acme" ? { id, plan: "pro" } : null) }));
app.get("/plan", (req, res) => res.json({ plan: res.locals.tenant.plan }));
app.use((err, req, res, next) => res.status(err.status ?? 500).json({ error: err.message }));
app.listen(3000);Remember
- Middleware factories validate options up front and return
(req, res, next) - Keep per-request data in
res.localsor one namespacedreqfield - Forward errors with
next(err); let the app format them - Prefer Node-level APIs and lifecycle events over monkey-patching
Watch out for
- Storing request data in a variable captured by the factory, which leaks between concurrent requests.