Skip to content
Express.js ex middleware 4 min read

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:

StepMiddlewareScopeRuns?
1Request-ID loggerApp-levelYes
2express.json()App-levelYes
3requireAuthRouter-levelYes
4/dashboard handlerRouter routeYes (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.url is relative (/dashboard), but req.originalUrl and req.baseUrl still report the full incoming path (/admin/dashboard) and the mount prefix (/admin). Use req.originalUrl in 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 async middleware or handler that throws or rejects is forwarded to the error handler automatically. In Express 4 you must wrap awaited calls in try/catch and call next(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 (not req.url) inside router middleware for logging, since req.url is 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.
Last updated June 14, 2026
Was this helpful?