Use Express Router to Split Routes into Mountable Modules
Split a growing Express app into route modules with express.Router(), mount them under path prefixes, and keep middleware scoped.
05 Jul 2025, 23:35 UTC

The problem: one route file becomes unmaintainable
As an Express application grows, route definitions and middleware tend to collect in a single file. A file with dozens of app.get(), app.post(), and app.use() calls becomes difficult to test, review, and deploy independently. The useful takeaway is to move route groups into separate modules using express.Router(), then mount each module under a path prefix in the main application.
How express.Router() works
express.Router() creates a mini-application: a complete middleware and routing system that can define its own routes, route parameters, and middleware stack. Unlike the main app instance, a router does not listen on a network port. It is mounted into the parent app with app.use('/prefix', router), after which Express forwards matching requests to the router.
Worked example: users and posts route modules
This example assumes Express 4.x. Create three files in the project root:
- routes/users.js — a router for user endpoints.
- routes/posts.js — a router for nested post endpoints that needs the parent user ID.
- app.js — the main Express application that mounts both routers.
// routes/users.js
const express = require('express');
const router = express.Router();
router.use((req, res, next) => {
console.log('users router middleware:', req.originalUrl);
next();
});
router.get('/', (req, res) => {
res.json([{ id: 1, name: 'Example User' }]);
});
router.get('/:userId', (req, res) => {
res.json({ userId: req.params.userId });
});
module.exports = router;
// routes/posts.js
const express = require('express');
const router = express.Router({ mergeParams: true });
router.get('/:postId', (req, res) => {
res.json({
userId: req.params.userId,
postId: req.params.postId
});
});
module.exports = router;
// app.js
const express = require('express');
const usersRouter = require('./routes/users');
const postsRouter = require('./routes/posts');
const app = express();
app.use('/api/users', usersRouter);
app.use('/api/users/:userId/posts', postsRouter);
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).send('Server error');
});
app.listen(3000, () => {
console.log('Listening on port 3000');
});
Run the application from the project root:
node app.js
Required permissions: none beyond normal file execution. Change the port 3000 if another process already occupies it.
Before starting, confirm the installed Express major version:
npm list express
The output should show express@4.x.x.
Expected checks:
curl http://localhost:3000/api/usersreturns the users array and logs the router middleware message.curl http://localhost:3000/api/users/1/posts/2returns both userId and postId because mergeParams: true was set on the posts router.curl http://localhost:3000/api/unknowndoes not trigger the users router middleware, demonstrating middleware isolation.
Risk: mounting the same router instance at multiple prefixes shares that instance's middleware state. If two mount points need different middleware, create separate router instances.
Limits and common mistakes
Middleware order matters
Router-level router.use() middleware runs before route handlers in definition order. Error-handling middleware, which takes four arguments (err, req, res, next), must be placed after all routes so it can catch errors thrown downstream.
Router parameters run once per request
router.param('id', callback) executes only once per request for a given parameter name, even when that parameter appears in multiple routes. Read req.params directly in handlers for repeated access.
Nested resources need mergeParams
Without express.Router({ mergeParams: true }), a child router mounted at /users/:userId/posts cannot see :userId in req.params. This option is essential for nested resource design.
Built-in middleware was removed in Express 4.x
Express 4.x removed body-parser and cookie-parser from the core. Install them separately and mount them before routers that depend on parsed request bodies.
Case-sensitive routing
Route path matching is case-sensitive by default. Set app.set('case sensitive routing', true) if you need consistent behavior across environments.
Router scope versus global scope
| Scope | Defined with | Applies to |
|---|---|---|
| Global middleware | app.use() in the main application | All incoming requests |
| Router middleware | router.use() in a route module | Only requests under that router's mount path |
Practical verification
After mounting routers, confirm the result by curling each mounted path and checking the console output for router-specific middleware logs. Verify that unmounted paths do not trigger router middleware, and that nested routes receive both parent and child parameters when mergeParams is enabled.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.