Modularize Express Routes with Router Instances
Split growing Express route files into modules, mount routers with prefixes, and scope middleware to specific route groups.
22 Dec 2025, 09:09 UTC

Your Express app starts with one file. A few route handlers in app.js feel manageable until authentication, logging, and validation middleware start crowding the same stack. As the route list grows, it becomes harder to tell which middleware applies to which endpoint, and testing a single route requires booting the whole application.
The useful takeaway: Express Router instances let you split route definitions into separate modules, mount them under a URL prefix, and attach middleware that runs only for routes inside that module. This keeps app.js small and makes each route group easier to test in isolation.
What a Router instance actually does
An Express Router is a mini-application that handles routing and middleware but does not listen on a port by itself. You create one with express.Router(), define routes on it, then mount it in your main app at a path prefix such as /api. Middleware attached to the router runs before the router's route handlers, but only for requests that reach that mounted path.
Terms used here:
- Middleware: a function that receives the request, response, and a
nextcallback. It can inspect or modify the request, end the response, or pass control to the next function. - Mounting: attaching a Router to the main app at a specific path using
app.use('/prefix', router). - next(err): calling
nextwith an error argument skips remaining middleware and routes and sends the request to Express error-handling middleware.
Worked example: a users router with scoped middleware
Assume Express 4.x. Create a router module for user endpoints.
// src/routes/users.js
const express = require('express');
const router = express.Router();
// Router-level middleware: runs only for routes mounted on this router.
router.use((req, res, next) => {
console.log('[users router]', req.method, req.originalUrl);
next();
});
router.get('/', (req, res) => {
res.json([{ id: 1, name: 'Example User' }]);
});
router.get('/:userId', (req, res) => {
res.json({ id: req.params.userId, name: 'Example User' });
});
// Forward async errors to the central error handler.
router.get('/:userId/posts', async (req, res, next) => {
try {
const posts = await getPostsForUser(req.params.userId);
res.json(posts);
} catch (err) {
next(err);
}
});
module.exports = router;
Mount it in the main application. Order matters: body-parsing middleware must come before any router that reads the request body.
// app.js
const express = require('express');
const usersRouter = require('./src/routes/users');
const app = express();
// Global middleware runs for every request.
app.use(express.json());
app.use((req, res, next) => {
console.log('[global]', req.method, req.url);
next();
});
// Mount the users router under /api/users.
app.use('/api/users', usersRouter);
// Central error handler: four arguments express it as an error handler.
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ error: 'Internal server error' });
});
app.listen(3000, () => {
console.log('Listening on http://localhost:3000');
});
Run and verify
Run these commands in your project root. No special permissions are required.
# Install Express if you have not already
npm install express@4
# Start the app
node app.js
# In another terminal, call a route handled by the router
curl -i http://localhost:3000/api/users
# Call a nested route that exercises async error forwarding
curl -i http://localhost:3000/api/users/1/posts
Expected checks:
GET /api/usersreturns HTTP 200 and a JSON array.- The terminal running
node app.jsprints both the global log line and the router-specific[users router]line. GET /api/users/1/poststriggers thecatchblock ifgetPostsForUserrejects, and the central error handler returns HTTP 500 instead of crashing the process.
Risk: if you mount the router before express.json(), route handlers that read req.body will receive an undefined body. Keep body parsers above mounted routers that consume payloads.
Limitations to watch for
- Route shadowing: Express processes middleware and routes in declaration order. A route defined before a mounted router can match the same path and prevent the router from ever running.
- Unhandled rejections: an async route handler that throws without calling
next(err)can produce an unhandled promise rejection, which may crash the Node process depending on version and configuration. - Middleware scope: router-level middleware does not leak to other routers or to the global stack, which is usually what you want, but it also means shared logic such as request ID generation must live in global middleware if every route needs it.
Practical checklist before you merge
- Each route file exports a Router instance with
module.exports = router. - Body parsers and global logging mount before any router that needs them.
- Async route handlers wrap logic in
try/catchand callnext(err)on failure. - You can start the app and receive expected status codes from
curlfor each mounted prefix.
Refactoring routes into routers changes source files only; there is no database or runtime state to roll back. If the new structure adds complexity without reducing duplication, moving the routes back into app.js is straightforward.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.