Stop Putting Every Express Route in One File: A Practical Case for express.Router()
express.Router() gives each route group its own middleware stack and mount path. Here's a practical split of users and billing routes, plus the ordering pitfalls to avoid.
08 Oct 2025, 09:43 UTC

Every Express project starts the same way: one app.js, a handful of app.get() calls, everything fine. Then the project grows. Six months later you're scrolling through 800 lines of route definitions, middleware that may or may not apply to the route you're editing, and a diff that's impossible to review. The fix isn't a framework migration — it's a feature that's been stable since Express 4: express.Router().
The thesis is simple: a Router is a mini-application with its own middleware stack and route table, and mounting it under a path prefix gives you isolation you can't get from comments and wishful thinking.
What a Router actually is
express.Router() returns a standalone object that supports the same verbs as your app — get, post, put, delete, plus use for middleware. It behaves like a self-contained Express app in miniature: it has its own middleware stack, and its handlers only run when the router is mounted and the request path matches its mount point.
That last point matters more than it sounds. Middleware registered with router.use() runs only for requests that reach that router. An authentication check scoped to /api/admin can't accidentally leak onto your public routes, and a logging middleware on one router won't double-log requests handled by another.
A worked example: splitting users and billing
Say you have user management and billing endpoints. Instead of piling both into app.js, create one module per domain.
// routes/users.js
const express = require('express');
const router = express.Router();
// Runs only for requests that reach this router
router.use((req, res, next) => {
req.requestedAt = Date.now();
next();
});
router.get('/', (req, res) => {
res.json({ users: [] });
});
router.get('/:id', (req, res) => {
res.json({ id: req.params.id });
});
module.exports = router;// routes/billing.js
const express = require('express');
const router = express.Router();
const requireAuth = require('../middleware/requireAuth');
// Auth applies to every billing route, nothing else
router.use(requireAuth);
router.get('/invoices', (req, res) => {
res.json({ invoices: [] });
});
module.exports = router;// app.js
const express = require('express');
const app = express();
app.use(express.json());
app.use('/api/users', require('./routes/users'));
app.use('/api/billing', require('./routes/billing'));
app.listen(3000);Two things fall out of this structure immediately. First, paths inside each router are relative to the mount point — router.get('/:id') in users.js answers GET /api/users/42, so renaming the prefix is a one-line change in app.js instead of a find-and-replace across dozens of route strings. Second, the billing auth middleware is physically incapable of running for user routes, because the billing router is never in that request's middleware chain.
Testing gets easier too
Because a router is just a function with the signature (req, res, next), you can mount it on a throwaway app in a test without booting your real server, its database middleware, or its error handlers:
const express = require('express');
const userRouter = require('../routes/users');
const testApp = express();
testApp.use('/api/users', userRouter);
// Point supertest (or fetch, in Node 18+) at testAppThis keeps unit tests honest: if the user router secretly depends on app-level middleware, the test fails fast instead of passing in production-shaped conditions only.
Verifying the mount actually works
The most common Router bug is a path mismatch — the route exists, but not where you think. Run your app and check from the command line:
curl -i http://localhost:3000/api/users/42You need no special permissions; run it wherever the app is running. Expect a 200 with your JSON body. A 404 with Express's default HTML error page means the request never reached your router — check the mount path in app.js first, then the route path inside the router. Remember that a trailing-slash difference or a router mounted after a catch-all 404 handler will both produce this symptom.
To confirm your environment supports everything here, run npm list express in the project directory. The Router API is stable across Express 4.x and the 5.x line, and works on any Node version those releases support (Node 12 and up is a safe floor; check the engines field if you're pinned to something older).
The trade-off: indirection and ordering
Routers add a layer of indirection. To understand what handles POST /api/billing/refund, a new teammate has to look at app.js for the mount, then the router file, then any router-level middleware. That's usually a good trade, but it has one sharp edge: middleware order still matters globally. App-level middleware declared before your app.use('/api/...', ...) mounts runs for those routes; anything declared after does not. A common mistake is mounting routers and then registering express.json() or an error handler underneath — body parsing silently never happens, and the error handler never sees router errors. Keep the order in app.js deliberate: parsing and logging first, routers next, 404 and error handlers last.
Also note that routers don't share state. If two routers need the same helper (say, a database pool), pass it in explicitly — export a factory like module.exports = (db) => { ... return router; } — rather than reaching for globals.
Where to start
You don't need a big-bang refactor. Pick the route group with the most definitions, move it into routes/<name>.js, mount it, and re-run your curl checks. If your app has fewer than ten routes total, honestly, stay in one file — Router pays off at medium size and beyond, not before. But once you feel that first scroll-induced wince at app.js, that's the signal.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.