Nested & Mergeable Routers
Real APIs rarely stay flat. A user has posts, a post has comments, an order has line items — relationships that read naturally as nested URLs like /users/:id/posts. Express lets you model this by mounting one Router inside another, keeping each resource’s logic in its own file. The catch is that a child router does not see its parent’s route parameters by default, and the mergeParams option is how you fix that.
Mounting a router inside a router
A Router is itself middleware, so anywhere you can call app.use() you can also mount another router. Mounting a child router on a parameterized path is what creates a hierarchy: the parent owns :userId, and everything below it inherits that context.
import express from "express";
const app = express();
// Child router: knows about posts, not users
const postsRouter = express.Router();
postsRouter.get("/", (req, res) => {
res.json({ message: "list posts", params: req.params });
});
// Parent router: owns the :userId segment
const usersRouter = express.Router();
usersRouter.use("/:userId/posts", postsRouter);
app.use("/users", usersRouter);
app.listen(3000);
Request GET /users/42/posts reaches postsRouter, but the captured userId is missing:
Output:
{ "message": "list posts", "params": {} }
The parent matched :userId, yet the child’s req.params is empty. This is the default isolation behavior — each router gets a fresh params object scoped to the path it matched.
Sharing params with mergeParams
Pass { mergeParams: true } when creating the child router and it merges the parent’s matched params into its own req.params.
const postsRouter = express.Router({ mergeParams: true });
postsRouter.get("/", (req, res) => {
res.json({ message: "list posts", userId: req.params.userId });
});
postsRouter.get("/:postId", (req, res) => {
const { userId, postId } = req.params;
res.json({ userId, postId });
});
Now both parent and child params are visible:
Output:
GET /users/42/posts -> { "message": "list posts", "userId": "42" }
GET /users/42/posts/7 -> { "userId": "42", "postId": "7" }
Tip:
mergeParamsonly matters on the child router — the one mounted on a path with a parameter it needs to read. The parent router does not need it.
Modeling a nested resource end-to-end
A clean approach is one file per resource. The posts router validates that the parent user exists, then serves data scoped to that user. Because async handlers can throw, Express 5 forwards rejected promises to your error handler automatically; on Express 4 you must call next(err) yourself.
// routes/posts.js
import express from "express";
const router = express.Router({ mergeParams: true });
const db = {
posts: [
{ id: "7", userId: "42", title: "Nested routers" },
{ id: "9", userId: "42", title: "mergeParams" },
],
};
// Runs for every request into this router; userId is available here too
router.use(async (req, res, next) => {
const exists = db.posts.some((p) => p.userId === req.params.userId);
if (!exists) return res.status(404).json({ error: "user not found" });
next();
});
router.get("/", async (req, res) => {
const posts = db.posts.filter((p) => p.userId === req.params.userId);
res.json(posts);
});
router.post("/", async (req, res) => {
const post = { id: String(Date.now()), userId: req.params.userId, ...req.body };
db.posts.push(post);
res.status(201).json(post);
});
export default router;
// routes/users.js
import express from "express";
import postsRouter from "./posts.js";
const router = express.Router();
router.get("/:userId", (req, res) => {
res.json({ id: req.params.userId });
});
router.use("/:userId/posts", postsRouter);
export default router;
// app.js
import express from "express";
import usersRouter from "./routes/users.js";
const app = express();
app.use(express.json());
app.use("/users", usersRouter);
app.listen(3000, () => console.log("listening on :3000"));
A request to GET /users/42/posts returns only that user’s posts:
Output:
[
{ "id": "7", "userId": "42", "title": "Nested routers" },
{ "id": "9", "userId": "42", "title": "mergeParams" }
]
Param naming and precedence
When a child and parent both define a param of the same name, the merge keeps the child’s value — the innermost match wins. To avoid surprises, give each level a distinct name (userId, postId) rather than reusing id everywhere.
| Behavior | mergeParams: false (default) | mergeParams: true |
|---|---|---|
| Child sees own params | Yes | Yes |
| Child sees parent params | No | Yes |
| Name collision resolves to | n/a | Child’s value |
| Typical use | Standalone routers | Nested resources |
Gotcha: Merged params are inherited at request time, so a sub-router that relies on
req.params.userIdonly works when mounted under a path that actually captures:userId. Mount it on a literal path and that param will simply beundefined.
Best practices
- Give each nesting level a unique param name so merges never collide silently.
- Set
mergeParams: trueonly on child routers that genuinely read parent params. - Keep one router per resource in its own file and mount it from the parent.
- Validate the parent resource (e.g. the user exists) in router-level middleware before handlers run.
- Avoid nesting more than two levels deep in URLs — beyond that, prefer flatter routes like
/posts/:postId. - On Express 4, wrap async handlers or call
next(err); on Express 5, rejected promises are forwarded automatically.