Modular Routers at Scale
Split a large API into feature routers, each owning its own middleware, and assemble them with a single mount point.
A 2,000-line app.js is a code smell. The cure is feature-oriented routers: each feature (users, orders, billing) exports its own express.Router() that owns its routes and its route-local middleware. The app then becomes a short, readable manifest of mounts.
Routers are mini-apps
A router can carry its own parsers, validators and even its own error handling. Because app.use('/orders', ordersRouter) prefixes every path inside, the router never repeats /orders.
// routes/index.js — one place that lists the whole surface area
import { Router } from 'express';
import users from './users.js';
import orders from './orders.js';
const api = Router();
api.use('/users', users);
api.use('/orders', orders);
export default api; // app.use('/api/v1', api)Express 5 routing changes to know
- Path matching moved to
path-to-regexpv8: the bare*wildcard now needs a name, e.g./files/*splat. - Optional segments use braces:
/users{/:id}instead of the old/users/:id?. - Regex must be explicit; unnamed groups are gone.
Example
// routes/orders.js — a self-contained feature router.
import express, { Router } from 'express';
import * as orders from '../controllers/orders.js';
import { validate } from '../middleware/validate.js';
import { createOrderSchema } from '../schemas/orders.js';
const router = Router({ mergeParams: true });
// Router-local middleware: applies to every route in THIS file only.
router.use(express.json({ limit: '50kb' }));
// Express 5 path syntax: optional id via braces, named wildcard for files.
router
.route('/{/:id}')
.get(orders.list) // GET /orders and GET /orders/:id
.post(validate(createOrderSchema), orders.create);
router.get('/:id/attachments/*splat', orders.attachment);
export default router;
// app.js
// import api from './routes/index.js';
// app.use('/api/v1', api); // version lives at the mount, not the routeWhen to use it
- A large Express monolith is refactored into feature-based route modules so the authentication, products, and orders domains each live in separate files.
- A team uses nested routers so the /api/v1 router mounts feature routers, and each feature router mounts its own sub-resource routers.
- A developer writes integration tests for the orders router in isolation by importing the Router directly without mounting the full app.
More examples
Feature-based router modules
Each resource gets its own Router file; app.js only mounts them, keeping the entry point short.
// routes/products.js
const router = require('express').Router();
const ctrl = require('../controllers/products');
router.get('/', ctrl.list);
router.post('/', ctrl.create);
router.get('/:id', ctrl.getById);
module.exports = router;
// app.js
app.use('/api/products', require('./routes/products'));
app.use('/api/orders', require('./routes/orders'));Nested sub-resource router
Mounts an order items router under /:id/items with mergeParams: true so it inherits the parent :id parameter.
const orderItemsRouter = require('express').Router({ mergeParams: true });
orderItemsRouter.get('/', ctrl.listItems);
orderItemsRouter.post('/', ctrl.addItem);
orderItemsRouter.delete('/:itemId', ctrl.removeItem);
const ordersRouter = require('express').Router();
ordersRouter.get('/', ctrl.listOrders);
ordersRouter.get('/:id', ctrl.getOrder);
ordersRouter.use('/:id/items', orderItemsRouter); // nested
app.use('/api/orders', ordersRouter);API version barrel router
A v1 index router mounts all v1 feature routers; swapping to v2 only requires changing the mount point in app.js.
// routes/v1/index.js
const router = require('express').Router();
router.use('/users', require('./users'));
router.use('/products', require('./products'));
router.use('/orders', require('./orders'));
module.exports = router;
// app.js
app.use('/api/v1', require('./routes/v1'));
app.use('/api/v2', require('./routes/v2'));
Discussion