Modularizing Express Routes with express.Router(): A Practical Guide
How to modularize an Express app with express.Router(): a worked mounted-router example, how routing context and middleware order actually work, and the mistakes that cause 404s and hanging requests.
08 Aug 2025, 04:17 UTC

When an Express app grows past a handful of endpoints, putting every app.get() in one file becomes a maintenance problem. The fix is express.Router(): a mini application that holds its own routes and middleware, which you mount onto the main app under a path prefix. This guide shows a working setup, explains how routing context flows through a mounted router, and covers the mistakes that most often cause mysterious 404s and hanging requests.
The short answer
Create a router in its own module, define routes and router-scoped middleware on it, export it, then mount it with app.use('/prefix', router). Every route on the router is automatically prefixed, and middleware runs in the order it was registered — so order matters more than most bugs suggest.
A worked example: an API router module
This example targets Express 4.x (the same API exists in Express 5, with minor differences in path pattern syntax). Create routes/users.js:
// routes/users.js
const express = require('express');
const router = express.Router();
// Router-level middleware: runs for every request that reaches this router.
// It MUST call next() or the request stalls forever.
router.use((req, res, next) => {
req.requestStartedAt = Date.now();
next();
});
router.get('/', (req, res) => {
res.json({ users: [], startedAt: req.requestStartedAt });
});
router.get('/:id', (req, res) => {
res.json({ id: req.params.id });
});
module.exports = router;
Then mount it in app.js:
// app.js
const express = require('express');
const app = express();
const usersRouter = require('./routes/users');
app.use(express.json()); // app-level middleware, runs first
app.use('/api/users', usersRouter);
app.listen(3000);
To verify, start the server (node app.js) and run curl http://localhost:3000/api/users/42 from another terminal. You should get {"id":"42"}. A request to /users/42 (without the prefix) should return a 404, confirming the mount point is doing the prefixing.
How routing context actually works
A mounted router is not a copy — it is the same object, invoked as middleware. That has three practical consequences:
- Shared req/res. The router receives the same
reqandresobjects as the app. Values set by earlier middleware (onreqorres.locals) are visible inside router handlers, and vice versa. - Relative paths. Inside the router,
router.get('/:id')means "whatever comes after the mount point."req.paramsonly contains parameters defined in the router's own path patterns — a parameter from the mount path (e.g., mounting at/api/:version) is not visible unless the router was created withexpress.Router({ mergeParams: true }). - Order is everything. Express walks middleware and routes in registration order and stops at the first handler that ends the response. App-level middleware registered after
app.use('/api/users', usersRouter)never runs for those requests.
Error handling inside a router
Calling next(err) inside router middleware or a handler propagates the error to the nearest error-handling middleware — one with the four-argument signature (err, req, res, next) — whether it is defined on the router or the app:
router.get('/boom', (req, res, next) => {
next(new Error('test failure'));
});
// Router-scoped error handler: only catches errors from this router.
router.use((err, req, res, next) => {
res.status(500).json({ error: err.message });
});
Limits and common mistakes
- Forgetting
next(). Middleware that neither responds nor callsnext()leaves the request hanging until the client times out. This fails silently in many test setups. - Middleware registered after routes.
router.use(...)placed belowrouter.get('/test', ...)will not run for/test— the route already ended the response. - Mounting after
app.listen(). Technically it can work because Express reads the middleware stack per request, but it is fragile and confusing; register everything before listening. - Dynamic mount paths shadowing static ones. Mounting a router at
/:idbefore a router at/healthmeans/healthgets captured as anid. Register specific paths first. - Reusing one router instance at multiple mount points. It works, but any mutable state closed over by the router is shared across all mounts. Prefer a factory function that returns a fresh router.
- Deep nesting. Routers mounting routers mounting routers obscure the request flow and make debugging painful. Two levels is usually enough.
- Missing
mergeParams. If you mount at/api/:versionand needreq.params.versioninside the router, create it withexpress.Router({ mergeParams: true }).
Checking your setup
Beyond the curl check above, a quick way to audit registration order is to log the stack during development: app._router.stack (Express 4) lists layers in order, each with its path regexp. It is an internal API, so use it only for debugging, not in production code. For a behavior-level check, add a temporary middleware that logs req.method and req.originalUrl at the top of the app and confirm each request hits the middleware you expect, in the order you expect.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.