Splitting a Growing Express App with Router: A Practical Walkthrough
When app.js hits hundreds of lines of route handlers, Express Router lets you split routes into focused, mountable modules. Here's a worked example with param validation, scoped middleware, and the ordering pitfalls to avoid.
29 Nov 2025, 09:32 UTC

Your app.js started at 80 lines. Six months later it's 900 lines of route handlers, and adding one endpoint means scrolling past auth logic, three unrelated resources, and a stray console.log nobody remembers. The fix isn't a framework migration — it's Express's built-in Router, which lets you break routes into focused modules without changing how requests flow.
The takeaway: a router is a mini-application that handles routes and middleware, then gets mounted under a path prefix. Splitting by resource keeps related handlers together, makes middleware scope explicit, and makes individual modules testable in isolation.
What a Router Actually Is
express.Router() returns an object that behaves like a stripped-down app: it supports get, post, use, and even param. It does not listen on a port or own global middleware. You define routes relative to wherever the router will be mounted, export it, and attach it in your main file.
This works in Express 4 and Express 5 with essentially the same API. The examples below assume Express 4.x and CommonJS; ES modules work identically with import/export.
A Worked Example: Users Router
Create routes/users.js:
const express = require('express');
const router = express.Router();
// Router-level middleware: runs only for /api/users/* requests
router.use((req, res, next) => {
console.log(`${req.method} ${req.originalUrl}`);
next();
});
// Validate the :id param once, for every route that uses it
router.param('id', (req, res, next, id) => {
if (!/^\d+$/.test(id)) {
return res.status(400).json({ error: 'id must be numeric' });
}
next();
});
router.get('/:id', async (req, res, next) => {
try {
const user = await db.findUser(req.params.id); // your data layer
if (!user) return res.status(404).json({ error: 'not found' });
res.json(user);
} catch (err) {
next(err); // hand off to the app's error handler
}
});
module.exports = router;Then in app.js:
const express = require('express');
const usersRouter = require('./routes/users');
const app = express();
app.use(express.json());
app.use('/api/users', usersRouter);
// Error handler goes AFTER all routers
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ error: 'internal error' });
});
app.listen(3000);Note that inside the router, the route is '/:id', not '/api/users/:id' — the mount path supplies the prefix. A request to GET /api/users/42 hits the router's logging middleware, then the param validation, then the handler.
Verifying It Works
Run the app locally (node app.js, no special permissions needed) and check three things:
curl http://localhost:3000/api/users/42— expect the request log line in the console and a JSON response (or a 404/500 from your data layer stub).curl http://localhost:3000/api/users/abc— expect400with the numeric-id error, provingrouter.paramfired before the handler.curl http://localhost:3000/api/other— expect no router log line, proving the middleware is scoped to the mount path.
If the logging line never appears, the most common cause is mounting order: a catch-all route or generic middleware registered before the router is swallowing the request.
Trade-offs Worth Knowing
Routers add a layer of indirection. When a request behaves oddly, you now have to trace the path through app.js into one or more router files, and middleware order bugs become subtler — mounting a router after a 404 handler means it never runs, and mounting it after your error handler means next(err) has nowhere to go. Keep a strict convention: body parsing and global middleware first, routers next, 404 and error handlers last.
Routers also are not automatically namespaced beyond their mount path. Two routers both defining GET /:id mounted at /api/users and /api/orders are fine; mounting both at /api creates a collision where the first one wins silently.
Start Small
Pick your largest resource — the one with the most handlers — and move it into routes/<resource>.js today. Mount it, run the curl checks above, and confirm your existing tests pass before touching anything else. Once one router is in place, the pattern sells itself: new endpoints go in a file you can read in one screen, and onboarding a teammate no longer starts with "scroll to line 600."
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.