Modular Routers

express.Router() returns an isolated mini-application: it has get, post, use and param, it holds its own ordered layer stack, and it is itself a middleware function. That last fact is the whole trick — mounting a router is the same operation as adding any other middleware, so routers nest without a special API.

One router per resource, one file per router, is the layout that scales. A router knows nothing about where it will be mounted, so the prefix lives in one place and moving /api/books to /api/v2/books is a one-line change.

routes/books.js — a resource router with a nested child routerJavaScript
import { Router } from 'express';
import reviews from './reviews.js';
const books = Router();
books.use((req, res, next) => { req.startedAt = Date.now(); next(); });
books.param('bookId', (req, res, next, value) => { req.bookId = Number(value); next(); });
books.get('/', (req, res) => res.json({ books: ['Eloquent JavaScript'] }));
books.get('/:bookId', (req, res) => res.json({ id: req.bookId }));
books.use('/:bookId/reviews', reviews);
export default books;
routes/reviews.js — a child router that needs its parent's parameterJavaScript
import { Router } from 'express';
const reviews = Router({ mergeParams: true });
reviews.get('/', (req, res) => res.json({ of: req.params.bookId, list: [] }));
reviews.post('/', (req, res) => res.status(201).json({ of: req.params.bookId }));
export default reviews;
Output
200 GET /api/books            {"books":["Eloquent JavaScript"]}
200 GET /api/books/7          {"id":7}
200 GET /api/books/7/reviews  {"of":"7","list":[]}
201 POST /api/books/7/reviews {"of":"7"}

mergeParams: true is what makes the child work. Without it a router sees only the parameters from its own patterns, and req.params.bookId — captured by the parent's mount path — would be undefined. Turn it on for any router mounted under a path containing a parameter; on a name collision the child's parameter wins.

Middleware added with router.use applies to every route in that router and every router nested below it, never to routes outside it. That containment is reason enough to reach for a router even without a URL prefix: a Router() carrying an authentication guard, mounted at /, scopes the guard to one file's routes.