Splitting a Growing Express App with express.Router: Mounting, Middleware, and mergeParams
express.Router lets you split a monolithic app.js into mountable feature modules with scoped middleware. Here's how mounting, nesting, and the mergeParams option actually work — and where the pipeline gets confusing.
28 Jan 2026, 23:00 UTC

Every Express app starts as one tidy app.js. Then routes pile up — users, orders, webhooks, health checks — and suddenly you're scrolling through 800 lines of route definitions to find the one handler you need. The fix isn't a framework migration; it's a feature that's been in Express for years: express.Router().
The thesis of this post: treat each feature area of your API as a self-contained router module, mount it under a path prefix, and keep module-specific middleware inside the module. The payoff is that URL namespaces become movable, middleware becomes scoped, and route files become small enough to actually read.
What a Router actually is
express.Router() returns an isolated, mountable mini-application. It supports the same verbs (get, post, put, delete) and its own middleware stack, but it doesn't listen on a port — it behaves like middleware that you attach to the real app with app.use().
Two properties make this powerful:
- Mount paths prefix everything. If you mount a router at
/api/users, a route declared asrouter.get('/:id', ...)answersGET /api/users/42. Move the mount to/api/v2/usersand every route inside moves with it — no edits inside the router file. - Middleware is scoped.
router.use()runs only for requests that pass through that router. Authentication for the admin module stops leaking into the public module.
A worked example: users and posts
A common layout is one file per feature, each exporting a configured router:
// routes/users.js
const express = require('express');
const router = express.Router();
// Runs only for requests that reach this router
router.use((req, res, next) => {
console.log('users router:', req.method, req.path);
next();
});
router.get('/', listUsers);
router.get('/:id', getUser);
module.exports = router;// app.js
const express = require('express');
const usersRouter = require('./routes/users');
const app = express();
app.use(express.json());
app.use('/api/users', usersRouter);
app.listen(3000);Run this anywhere Node runs; no special permissions needed. To verify the scoping, hit curl http://localhost:3000/api/users and confirm the log line appears, then hit a route outside the router (e.g., a health check mounted directly on app) and confirm it doesn't. That asymmetry is the whole point.
The mergeParams gotcha
Here's the part that bites people. Suppose you nest resources: posts belonging to a user. You mount a posts router at /users/:userId/posts:
// routes/posts.js
const express = require('express');
const router = express.Router({ mergeParams: true });
router.get('/', (req, res) => {
// Without mergeParams, req.params.userId is undefined here
res.json({ userId: req.params.userId, posts: [] });
});
module.exports = router;
// in users router or app:
app.use('/users/:userId/posts', postsRouter);By default, a router only sees parameters defined in its own route patterns. The :userId from the mount path is invisible inside the child router unless you create it with { mergeParams: true }. The practical check: log req.params inside the child handler with and without the option. Without it, you'll see an empty object (or only the child's own params); with it, userId shows up. If you forget this option, the failure mode is silent — undefined flowing into a database query — so it's worth testing explicitly.
Nesting for versioning
Because routers are just middleware, they nest. A clean pattern for versioned APIs:
const apiV1 = express.Router();
apiV1.use('/users', usersRouter);
apiV1.use('/orders', ordersRouter);
app.use('/api/v1', apiV1);When v2 arrives, you create apiV2, mount the routers that changed, and leave v1 untouched. Versioning stays a routing concern instead of a copy-paste exercise.
The trade-off: pipeline opacity
Routers solve the monolith problem but can create a new one: the request pipeline becomes harder to trace. Middleware ordering bugs are the classic symptom — a request passes through app.use() layers, then router-level router.use() layers, then the handler, and if you register body parsing after a router mount, that router's handlers see an empty req.body. Duplicated middleware is the other trap: if three routers each register their own auth check, behavior can drift between them.
Two habits keep this manageable:
- Register truly global middleware (logging, body parsing, CORS) on
appbefore any router mounts, and keep router middleware limited to concerns that genuinely belong to that module. - When debugging, temporarily add a middleware that logs
req.methodandreq.baseUrl—baseUrlshows which mount path the request came through, which immediately tells you which router is handling it.
Version notes worth checking
The routing behavior above is stable across Express 4 and 5, but details around it are not. Express 5 moved to path-to-regexp v8, which changed wildcard and optional-parameter syntax, and it forwards rejected promises from async handlers to error middleware automatically (Express 4 requires try/catch or a wrapper). Before relying on either, check your installed major version with npm ls express and confirm your route syntax and error handling match that version's docs.
Where to start
Pick the feature area with the most routes in your current app.js, move it into a file that exports a router, and mount it. Verify with curl that the routes resolve at the same URLs as before, then check that any middleware you moved runs only for that module. If you have nested resources, decide deliberately whether each child router needs mergeParams: true — and write one test that would fail if the option disappeared.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.