The Router Pattern
A single app.js listing every route quickly becomes a wall of handlers that nobody wants to touch. The router pattern breaks an Express application into small, self-contained express.Router() modules — each one owning a single feature or resource — that are then mounted under a URL prefix in the main app. Because a router is essentially a mini-application with its own routes and middleware, this gives you isolation, reuse, and a thin entry point that reads like a table of contents. It is the foundation every other Express structural pattern builds on.
A router is a mini-application
express.Router() returns an object that behaves like a stripped-down app: it supports .get(), .post(), .use(), route parameters, and middleware. The difference is that a router is not bound to a port — it is mounted onto a real app (or another router) at a path prefix. Every route defined inside it becomes relative to that prefix, so the router stays ignorant of where it lives.
// routes/users.js
const express = require("express");
const router = express.Router();
router.get("/", async (req, res) => {
res.json([{ id: 1, name: "Ada" }]);
});
router.get("/:id", async (req, res) => {
res.json({ id: Number(req.params.id), name: "Ada" });
});
module.exports = router;
Mounting it under /users means router.get("/") answers GET /users and router.get("/:id") answers GET /users/42. The router file never hard-codes the /users prefix, which is what makes it relocatable.
Mounting routers in the entry point
The main file’s only job is to assemble the application: configure global middleware, mount each feature router under its prefix, and start listening. Notice how thin it stays even as features multiply.
// app.js
const express = require("express");
const usersRouter = require("./routes/users");
const ordersRouter = require("./routes/orders");
const authRouter = require("./routes/auth");
const app = express();
app.use(express.json());
app.use("/users", usersRouter);
app.use("/orders", ordersRouter);
app.use("/auth", authRouter);
app.use((req, res) => res.status(404).json({ error: "Not Found" }));
app.listen(3000, () => console.log("Listening on http://localhost:3000"));
Output:
$ curl -s http://localhost:3000/users/42
{"id":42,"name":"Ada"}
The entry point now reads as a routing map: each line answers “which feature handles this prefix?” without leaking implementation detail.
Router-scoped middleware
A router can carry its own middleware that applies only to the routes it owns. Calling router.use() (or passing middleware to a specific route) keeps cross-cutting concerns local instead of polluting the global app. Here, every /orders route requires authentication, but nothing outside the router is affected.
// routes/orders.js
const express = require("express");
const router = express.Router();
const requireAuth = require("../middleware/requireAuth");
router.use(requireAuth); // applies to every route below
router.get("/", async (req, res) => {
res.json({ orders: [], owner: req.user.id });
});
router.post("/", async (req, res) => {
const order = { id: Date.now(), ...req.body };
res.status(201).json(order);
});
module.exports = router;
Tip: Middleware order matters. Mount
router.use(requireAuth)before the routes it should protect — routers run their stack top to bottom, just like the main app.
Nesting routers for hierarchy
Because routers can be mounted onto other routers, you can model nested resources cleanly. To access a parent’s URL parameters from a child router, enable mergeParams.
// routes/comments.js — nested under /posts/:postId/comments
const express = require("express");
const router = express.Router({ mergeParams: true });
router.get("/", async (req, res) => {
res.json({ postId: req.params.postId, comments: [] });
});
module.exports = router;
// routes/posts.js
const express = require("express");
const router = express.Router();
const commentsRouter = require("./comments");
router.use("/:postId/comments", commentsRouter);
router.get("/:postId", async (req, res) => res.json({ id: req.params.postId }));
module.exports = router;
A request to GET /posts/7/comments reaches the comments router, which can still read req.params.postId thanks to mergeParams.
Router options reference
The express.Router() factory accepts an options object that controls how paths and parameters are matched.
| Option | Default | Effect |
|---|---|---|
mergeParams | false | Inherit req.params from the parent router/app |
caseSensitive | false | Treat /Users and /users as distinct routes |
strict | false | Treat /users and /users/ as distinct routes |
Note: In Express 5.x the path-matching engine changed — optional parameters use
{:id}rather than:id?, and unnamed wildcards like*must be named (/*splat). The router API and mounting model are otherwise unchanged from 4.x.
Best practices
- Give each resource or feature its own router file and mount it under a clear, plural prefix (
/users,/orders). - Keep the prefix out of the router itself — define routes relative to
/so the module stays relocatable and testable. - Attach authentication, validation, and rate limiting as router-scoped middleware so concerns stay local to the feature.
- Use
mergeParams: truefor nested routers that need a parent’s route parameters. - Mirror your folder structure to your URL structure so a new contributor can guess where a route lives.
- Keep
app.jsto assembly only — global middleware, router mounts, andlisten()— and push all logic into the routers and the layers below them.