Router-Level Middleware
Router-level middleware behaves exactly like application-level middleware, but it is bound to an express.Router() instance instead of the app itself. That single difference is powerful: anything you register with router.use() only runs for requests that actually reach that router, letting you scope logging, authentication, validation, or rate limiting to one resource without polluting the rest of the app. This page shows how to attach middleware to a router, how it composes with app-level middleware, and the ordering rules that govern the combined pipeline.
Creating a router with its own middleware
A router is a self-contained, mountable mini-application. You call express.Router(), attach middleware and routes to it, and export it. Middleware registered with router.use() runs before every route defined on that same router — and only those routes.
// routes/users.js
const express = require("express");
const router = express.Router();
// Runs for every request that reaches this router
router.use((req, res, next) => {
console.log(`[users] ${req.method} ${req.originalUrl}`);
next();
});
router.get("/", async (req, res) => {
res.json({ users: ["Ada", "Linus"] });
});
router.get("/:id", async (req, res) => {
res.json({ id: req.params.id });
});
module.exports = router;
Mounting the router on a path activates its middleware for that prefix only:
// app.js
const express = require("express");
const usersRouter = require("./routes/users");
const app = express();
app.use("/users", usersRouter); // router middleware fires for /users/*
app.listen(3000, () => console.log("Listening on http://localhost:3000"));
Output:
$ curl http://localhost:3000/users/42
[users] GET /users/42
{"id":"42"}
Requests to any other path — /, /orders, /health — never trigger the [users] logger, because they never enter the router.
Scoping authentication to one router
The most common use of router-level middleware is guarding a group of routes. Instead of sprinkling auth checks across individual handlers, attach one gatekeeper to the router and every route inherits it.
// routes/admin.js
const express = require("express");
const router = express.Router();
async function requireAuth(req, res, next) {
const token = req.headers.authorization?.replace("Bearer ", "");
if (token !== "secret-token") {
return res.status(401).json({ error: "Unauthorized" });
}
req.user = { id: 1, role: "admin" };
next();
}
router.use(requireAuth); // protects every route below
router.get("/dashboard", (req, res) => {
res.json({ message: `Welcome, ${req.user.role}` });
});
module.exports = router;
Output:
$ curl http://localhost:3000/admin/dashboard
{"error":"Unauthorized"}
$ curl -H "Authorization: Bearer secret-token" \
http://localhost:3000/admin/dashboard
{"message":"Welcome, admin"}
Because requireAuth short-circuits with a response when the token is missing, the route handler never runs. When it calls next(), control falls through to the matched route with req.user attached.
How it composes with app-level middleware
App-level and router-level middleware form a single, ordered pipeline. Express evaluates middleware in registration order, descending into a router only when the request path matches the router’s mount point. The flow looks like this:
const express = require("express");
const app = express();
const adminRouter = require("./routes/admin");
// 1. App-level: runs for EVERY request first
app.use((req, res, next) => {
req.requestId = Math.random().toString(36).slice(2);
console.log(`[app] ${req.requestId} ${req.method} ${req.url}`);
next();
});
app.use(express.json()); // 2. App-level body parser
// 3. Router-level: only for /admin/*, runs after the two above
app.use("/admin", adminRouter);
app.listen(3000);
For a request to /admin/dashboard, the execution order is:
| Step | Middleware | Scope | Runs? |
|---|---|---|---|
| 1 | Request-ID logger | App-level | Yes |
| 2 | express.json() | App-level | Yes |
| 3 | requireAuth | Router-level | Yes |
| 4 | /dashboard handler | Router route | Yes (if authed) |
App-level middleware always executes first because it is registered before the router mount. The router’s own middleware sees everything the app-level middleware set up — req.requestId, a parsed req.body, and so on.
Tip: Mount path stripping happens at the router boundary. Inside a router mounted at
/admin,req.urlis relative (/dashboard), butreq.originalUrlandreq.baseUrlstill report the full incoming path (/admin/dashboard) and the mount prefix (/admin). Usereq.originalUrlin logs.
Path-scoped middleware within a router
You can also give middleware a sub-path inside the router, narrowing it to a subset of that router’s own routes:
const router = express.Router();
// Only for /users/:id/* requests within this router
router.use("/:id", (req, res, next) => {
console.log(`Accessing user ${req.params.id}`);
next();
});
router.get("/:id/profile", (req, res) => res.json({ id: req.params.id }));
Note: In Express 5, an
asyncmiddleware or handler that throws or rejects is forwarded to the error handler automatically. In Express 4 you must wrap awaited calls intry/catchand callnext(err)yourself, even inside router-level middleware.
Best practices
- Reach for router-level middleware to scope a concern (auth, logging, validation) to one resource instead of guarding paths globally at the app level.
- Register cross-cutting app-level middleware (body parsers, request IDs, CORS) before mounting routers so routers inherit that setup.
- Keep each router in its own module and export it — this is the idiomatic way to build modular, testable Express apps.
- Use
req.originalUrl(notreq.url) inside router middleware for logging, sincereq.urlis stripped of the mount path. - Always call
next()or send a response in router middleware; forgetting either leaves the request hanging. - Place a router-specific error handler with the four-argument signature at the bottom of a router to isolate its failure handling.