Skip to content
Express.js ex routing 4 min read

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: mergeParams only 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.

BehaviormergeParams: false (default)mergeParams: true
Child sees own paramsYesYes
Child sees parent paramsNoYes
Name collision resolves ton/aChild’s value
Typical useStandalone routersNested resources

Gotcha: Merged params are inherited at request time, so a sub-router that relies on req.params.userId only works when mounted under a path that actually captures :userId. Mount it on a literal path and that param will simply be undefined.

Best practices

  • Give each nesting level a unique param name so merges never collide silently.
  • Set mergeParams: true only 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.
Last updated June 14, 2026
Was this helpful?