Routers & Modular Apps
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Express 5.3.0 (published 2026-10-09; the latest Express 4 release is 4.22.3) on Node.js 18 or later (Express 5 requires >= 18), and was checked on 2026-10-29. Express 5.3 delegates routing to the router package (version 2.2), which in turn uses path-to-regexp 8 for path matching. All examples are ESM (type set to module in package.json), use node: prefixes for built-in modules and follow the Bookshelf scenario used throughout this section: a small service that manages books, authors and reviews.
An Express application is, at heart, a stack of middleware and routes. That works for a hello-world, but a real service quickly needs to be split into pieces that can be written, tested and mounted independently. Express offers two tools for that. A router (express.Router()) is a mini middleware stack with its own routes that you mount on a path of another router or application. A sub-application (a second express() instance mounted with app.use()) is a complete application with its own settings that you mount the same way. A third tool, the vhost middleware, dispatches a request to a router or application according to the Host header instead of the path. This page covers all three, the options and request properties that change when you nest them, and how to build versioned APIs with them.
For the routes themselves (verbs, handlers, next('route')), see Routing; for the path syntax in Express 5, see Route Paths and Parameters; for how middleware runs in order, see Middleware Fundamentals.
Routers and sub-applications at a glance
| Router | Sub-application | |
|---|---|---|
Created with |
|
|
Settings ( |
None. It uses the settings of the application that finally handles the request. |
Its own |
|
Not available. |
Available, independent of the parent. |
Mounted with |
|
|
Emits events |
No |
Emits |
Typical use |
Group related routes ( |
Isolate a self-contained area with its own configuration, error handlers or views (an admin panel). |
Prefer a router unless you need separate settings: it is lighter, easier to test and has no hidden inheritance rules.
express.Router([options])
express.Router() returns a router: a function with the same signature as a middleware (req, res, next) that also exposes use, all, param, route and one method per HTTP verb (get, post, put, delete, patch, …). Because a router is a middleware, you can pass it to app.use() or to another router’s use(), and you can mix it with other middleware in the same call.
The optional options object has three properties. They are fixed at creation and apply to every route and router.use() path registered on that router.
| Option | Default | Meaning |
|---|---|---|
|
|
When |
|
|
When |
|
|
When |
A router created without options does not inherit case sensitive routing or strict routing from the application: each router decides for itself. This surprises people who set app.enable('strict routing') and then find that routers still ignore trailing slashes.
A complete router module
The Bookshelf books router below keeps all the knowledge about /books in one file. It has router-level middleware, a router.param() callback that loads the book once for every route that contains :bookId, and router.route() chaining. The router never calls listen(); the application that imports it decides where it is mounted.
import express, { Router } from 'express';
const books = new Map([
[1, { id: 1, title: 'Dune', authorId: 1 }],
[2, { id: 2, title: 'Emma', authorId: 2 }],
]);
let nextId = 3;
export const booksRouter = Router({ caseSensitive: false, strict: false });
// Router-level middleware: runs for every request that enters this router.
booksRouter.use((req, res, next) => {
res.set('X-Bookshelf-Router', 'books');
next();
});
// Runs once per request for any route with a :bookId parameter.
booksRouter.param('bookId', (req, res, next, value) => {
const book = books.get(Number(value));
if (!book) {
res.status(404).json({ error: `Book ${value} not found` });
return;
}
req.book = book;
next();
});
booksRouter
.route('/')
.get((req, res) => {
res.json([...books.values()]);
})
.post(express.json(), (req, res) => {
const { title, authorId } = req.body ?? {};
if (typeof title !== 'string' || title.trim() === '') {
res.status(400).json({ error: 'title is required' });
return;
}
const book = { id: nextId++, title: title.trim(), authorId: authorId ?? null };
books.set(book.id, book);
// req.baseUrl is the path where this router is mounted, so the router
// does not have to know whether it lives at /books or /api/v1/books.
res.status(201).location(`${req.baseUrl}/${book.id}`).json(book);
});
booksRouter
.route('/:bookId')
.get((req, res) => {
res.json(req.book);
})
.put(express.json(), (req, res) => {
const { title } = req.body ?? {};
if (typeof title !== 'string' || title.trim() === '') {
res.status(400).json({ error: 'title is required' });
return;
}
req.book.title = title.trim();
res.json(req.book);
})
.delete((req, res) => {
books.delete(req.book.id);
res.status(204).end();
});
Source: Express routing guide ("express.Router" and "app.route()"), Router API.
import express from 'express';
import { booksRouter } from './routes/books.js';
const app = express();
app.use('/books', booksRouter);
// Reached only when no router handled the request.
app.use((req, res) => {
res.status(404).json({ error: 'Not found' });
});
export default app;
import { createServer } from 'node:http';
import app from './app.js';
const port = Number(process.env.PORT ?? 3000);
const server = createServer(app);
server.on('error', (error) => {
console.error('Server error', error);
process.exitCode = 1;
});
server.listen(port, () => {
console.log(`Bookshelf listening on http://localhost:${port}`);
});
Splitting app.js (builds and exports the application) from server.js (listens) lets tests import the application without opening a port; see Project Structure and Configuration and Testing.
Source: Hello world and Routing guide.
Mounting routers with app.use()
app.use([path,] callback [, callback...]) mounts middleware at a path. A router (or a sub-application, or any function) mounted at path receives every request whose path starts with path on a segment boundary. With no path, the default is / and the middleware sees every request.
-
The path is a prefix, not an exact match.
app.use('/books', router)matches/books,/books/,/books/1and/books/1/reviews, but not/bookshelf. -
Inside the router,
req.urlandreq.pathhave the mount path stripped (a request for/books/1is seen as/1). The stripped part is available asreq.baseUrl, andreq.originalUrlalways holds the full original URL (req.baseUrlvsreq.originalUrl). -
The order of
app.use()calls is the order of evaluation. A router mounted earlier gets the first chance at the request; if none of its routes sends a response or fails, the request falls through to the next layer. Mount specific paths before general ones and register the 404 handler and the error-handling middleware last. -
An array of paths mounts the same router in several places:
app.use(['/books', '/library'], booksRouter). -
Mounting the same router instance at two different places shares its state, including any in-memory data and
router.param()callbacks. That is usually what you want for aliases; create two routers if you do not. -
Since Express 5, mount paths follow
path-to-regexp8 syntax. A mount path such as'/*'(valid in Express 4) now fails with a "Missing parameter name" error at startup; use'/*splat'(a named wildcard) or omit the path altogether. A mount path with named parameters is allowed ('/authors/:authorId/books'), see Nested routers andmergeParams. -
app.use()throws aTypeErrorwhen you call it with no middleware or with something that is not a function or an Express application. A typical cause is mounting an import that isundefinedbecause the module has no such export.
Source: Application API, app.use and Using middleware.
Router-level middleware
Router-level middleware is middleware bound to a router with router.use() or router.METHOD() instead of an application. It behaves exactly like application-level middleware, but it only runs for requests that enter that router, which makes it the natural place for concerns that belong to one area of the API: a body parser for JSON routes, an authorization check for /admin, a per-area rate limiter.
import express, { Router } from 'express';
export const reviewsRouter = Router();
// Only requests under the reviews router are parsed and logged.
reviewsRouter.use(express.json());
reviewsRouter.use((req, res, next) => {
console.log(`${req.method} ${req.originalUrl}`);
next();
});
Because the order in which you register layers matters, put router-level middleware before the routes it must protect. A middleware placed after a route never runs for that route.
Leaving a router with next('router')
Calling next() with no argument moves to the next matching layer. Two special string arguments change the flow:
-
next('route')skips the remaining handlers of the current route and continues with the next matching route. It works only in middleware loaded withapp.METHOD()orrouter.METHOD(). -
next('router')exits the current router completely: every remaining layer in that router is skipped, and processing continues in the parent after the point where the router was mounted. It works in any layer of a router. -
next(error)(any other truthy value except those two strings) skips straight to the error-handling middleware.
next('router') is the tool for conditional routers: a router that applies only when a precondition holds, and otherwise steps aside so that a different router (or the 404 handler) can respond. The example below mounts an administrator router that is active only when a valid token header is present, followed by a public router for the same path.
import { Router } from 'express';
export const adminReportsRouter = Router();
export const publicReportsRouter = Router();
adminReportsRouter.use((req, res, next) => {
const expected = process.env.ADMIN_TOKEN; // placeholder: set it in your environment
// An unset token must never grant access, so refuse both cases explicitly.
if (!expected || req.get('x-admin-token') !== expected) {
next('router'); // not an administrator: leave this router
return;
}
next();
});
adminReportsRouter.get('/summary', (req, res) => {
res.json({ audience: 'admin', books: 2, reviews: 14, pendingModeration: 3 });
});
publicReportsRouter.get('/summary', (req, res) => {
res.json({ audience: 'public', books: 2 });
});
import { adminReportsRouter, publicReportsRouter } from './routes/reports.js';
// Same path, two routers: the first one steps aside with next('router').
app.use('/reports', adminReportsRouter);
app.use('/reports', publicReportsRouter);
A request without the header goes through the admin router (the guard calls next('router')), and is answered by the public router. With the header, the admin router answers and the public one is never reached. Return after calling next(): forgetting the return (or the else) lets the function continue and often sends a second response, which fails with ERR_HTTP_HEADERS_SENT.
Source: Using middleware, "Router-level middleware" (the next('router') example), Routing guide.
Chaining routes with router.route()
router.route(path) returns a single Route instance for one path. You attach handlers for several verbs to it by chaining, so the path is written once. That removes duplicated paths (and the typos that come with them), and router.route(path).all() lets you run middleware before every verb of that path.
import { Router } from 'express';
export const authorsRouter = Router();
authorsRouter
.route('/:authorId')
.all((req, res, next) => {
// Runs first, for every HTTP method on this path.
res.set('Cache-Control', 'no-store');
next();
})
.get((req, res) => {
res.json({ id: Number(req.params.authorId), name: 'Frank Herbert' });
})
.put((req, res) => {
res.status(501).json({ error: 'Not implemented' });
})
.delete((req, res) => {
res.status(204).end();
});
The same method exists on the application as app.route(). Handlers on a route run in the order they were added; all() handlers are added in the same list, so their position relative to get() or put() matters. A verb with no handler on the route returns the router’s usual 404 (no handler matched), and a request with an unsupported method is also answered with 404 unless you add an all() handler or a custom 405 response.
Source: Router API, router.route and Routing guide, "app.route()".
router.param()
router.param(name, callback) registers a callback that runs when a route path declared on this router contains the parameter :name. The callback receives (req, res, next, value, name). It is called once per request-response cycle for a given value, even when several routes in the router match the same parameter, and only if the earlier callbacks call next().
-
Param callbacks are local to the router where they are defined. They do not run for parameters declared in routers mounted inside it, and they do not run for parameters in the mount path of the router itself (those belong to the parent).
-
Express 5 removed the
router.param(fn)signature that registered a function to customize howparam(name, fn)works; pass a name and a function. A leading colon in the name (app.param(':id', fn)) was also removed. Passing an array of names is rejected in Express 5. -
A callback must call
next()or send a response, or the request hangs. It is a good place to validate the format (Number.isInteger(...)), load a record and attach it toreq, as in the books router above.
Source: Router API, router.param.
Nested routers and mergeParams
Routers can be mounted on routers, to any depth. A parent router often owns the first segment of a resource (/authors/:authorId) and delegates the nested collection (/authors/:authorId/books) to a child. By default, the child cannot see :authorId, because req.params is rebuilt for each router from that router’s own paths. Create the child with mergeParams: true to keep the parent’s parameters.
import { Router } from 'express';
const authors = new Map([
[1, { id: 1, name: 'Frank Herbert' }],
[2, { id: 2, name: 'Jane Austen' }],
]);
const booksByAuthor = new Map([
[1, [{ id: 1, title: 'Dune' }]],
[2, [{ id: 2, title: 'Emma' }]],
]);
// Child router: needs the :authorId declared in the parent's mount path.
const authorBooksRouter = Router({ mergeParams: true });
authorBooksRouter.get('/', (req, res) => {
res.json(booksByAuthor.get(Number(req.params.authorId)) ?? []);
});
export const authorsRouter = Router();
authorsRouter.get('/', (req, res) => {
res.json([...authors.values()]);
});
authorsRouter.param('authorId', (req, res, next, value) => {
if (!authors.has(Number(value))) {
res.status(404).json({ error: `Author ${value} not found` });
return;
}
next();
});
authorsRouter.get('/:authorId', (req, res) => {
res.json(authors.get(Number(req.params.authorId)));
});
// The mount path of the child contains the parameter.
authorsRouter.use('/:authorId/books', authorBooksRouter);
With the router mounted in the application as app.use('/authors', authorsRouter), a request for GET /authors/1/books reaches authorBooksRouter with req.params.authorId === '1'. Without mergeParams: true, req.params would be an empty object there and the handler would return [] for every author, which is the classic symptom of a forgotten option.
Two details are worth knowing:
-
req.paramsvalues are always strings. Convert them (Number(...)) and validate them. In Express 5,req.paramshas a null prototype, so it has no inheritedhasOwnPropertyortoString, and parameters that did not match are omitted from the object instead of appearing asundefined. -
A
router.param('authorId', ...)callback registered on the parent also runs forGET /authors/1/books, because the parameter is declared in the parent’suse()path. In the example above it returns 404 forGET /authors/5/booksbefore the child router is entered. The reverse is not true: a callback registered on the child never runs for a parameter that only the parent declares.
Source: Router API, "mergeParams".
A router tree
Everything together forms a tree of mount paths. The diagram shows the Bookshelf application: the application mounts routers for books, authors and reports and a sub-application for administration; the authors router mounts a child router for the books of an author. The label on each edge is the mount path and the value of req.baseUrl seen inside the child.
Reading the tree from the application downward gives the path of any route: GET /authors/1/books enters authorsRouter (baseUrl /authors), then authorBooksRouter (baseUrl /authors/1/books), where the route / matches. A request that nothing handles ends up in the application’s 404 handler. The dotted edge is the next('router') hand-off from Leaving a router with next('router').
Sub-applications
A sub-application is an ordinary express() instance mounted on a path of another application. It has its own settings, its own app.locals, its own error-handling middleware and its own view configuration, so it can be developed as a standalone app and then plugged into a bigger one. Mount it with the same app.use(path, subApp) call.
import express from 'express';
export const adminApp = express();
adminApp.disable('x-powered-by');
adminApp.set('json spaces', 2);
adminApp.locals.title = 'Bookshelf admin';
// Fires when the parent mounts this app.
adminApp.on('mount', (parent) => {
console.log(`Admin app mounted at ${adminApp.mountpath}`);
console.log(`Absolute path: ${adminApp.path()}`);
console.log(`Parent trust proxy setting: ${parent.get('trust proxy')}`);
});
adminApp.get('/dashboard', (req, res) => {
res.json({
title: req.app.locals.title,
mountpath: req.app.mountpath,
baseUrl: req.baseUrl,
originalUrl: req.originalUrl,
});
});
adminApp.use((error, req, res, next) => {
// Errors thrown inside the sub-app are handled here first.
console.error(error);
res.status(500).json({ error: 'Admin error' });
});
import { adminApp } from './admin/app.js';
app.use('/admin', adminApp);
GET /admin/dashboard returns a JSON object with the title Bookshelf admin, the mountpath /admin, the baseUrl /admin and the originalUrl /admin/dashboard.
app.mountpath, app.path() and the mount event
-
app.mountpathis the path pattern (or array of patterns) on which a sub-application was mounted. The main application hasmountpathequal to/. It is exactly what was given toapp.use(); when the sub-application is mounted on a pattern such as'/admin{/:section}',mountpathshows the pattern, not the matched string. For the matched string usereq.baseUrl. -
app.path()returns the canonical absolute path of the application, which includes the paths of all its parents: a sub-application mounted at/admininside one mounted at/v1returns/v1/admin. -
The
mountevent is emitted on the sub-application, with the parent application as argument, whenapp.use()registers it. A sub-application can use it to read parent settings or to register itself. It is emitted when mounting a sub-application, not when mounting a router, because routers are not event emitters. -
The mount path of a sub-application is stripped from
req.urlexactly as for a router, andreq.apprefers to the sub-application inside it and to the parent again afterwards. -
An error that is not handled by the sub-application’s own error handlers bubbles to the parent’s error-handling middleware.
Which settings are inherited
On mount, Express links the sub-application’s settings to the parent’s through the prototype chain. Settings that have a default value (such as etag, x-powered-by, env, query parser, subdomain offset, views) are not inherited, because the sub-application already holds its own default: set them again in the sub-application if you need them. Settings with no default value (such as view engine, json spaces, json replacer) are inherited. There are two exceptions: trust proxy is inherited although it has a default, and view cache is not inherited in production. The practical consequence is that app.disable('x-powered-by') on the parent leaves the sub-application’s header on unless it is disabled too, and that a views folder set on the parent is not used by res.render() inside the sub-application, while app.set('view engine', 'pug') on the parent is. Read the settings table in Application and Settings for the list and the defaults.
req.baseUrl vs req.originalUrl
Three request properties describe the URL, and they differ when routers are involved. For a request GET /authors/1/books?sort=title in the tree above, inside authorBooksRouter:
| Property | Value | Meaning |
|---|---|---|
|
|
The full URL as received, including the query string. Express never modifies it. Use it for logging and for redirects that must preserve the whole request. |
|
|
The mount path matched by the router the handler is currently in, as a concrete string. If the mount path had parameters or wildcards, |
|
|
What is left of the URL after the matched mount path, including the query string. Express rewrites it as the request goes into mounted routers and restores it when it comes back out. Treat it as internal. |
|
|
The pathname part of |
The identity req.originalUrl === req.baseUrl + req.url holds for URLs processed by routers (it is the reason req.url is rewritten). Use req.baseUrl to build links that remain valid wherever the router is mounted, as the Location header of the books router above does, and use req.originalUrl to log the request exactly as it arrived. A route handler in the application itself (no mount) has an empty req.baseUrl.
Source: Request API, req.baseUrl, req.originalUrl, req.path.
Virtual hosts with vhost
vhost is an official middleware module (documentation, npm vhost 3.0.2) that dispatches a request to a handler when the Host header matches a pattern. Together with routers and sub-applications it lets one process serve several sites or the same API on several host names.
npm install vhost
vhost(hostname, handle) takes a host name (a string, which may contain * wildcards, or a RegExp) and a handler function, which is usually an Express application or router. The handler runs only when the request’s host name (without the port) matches; otherwise vhost calls next() and the following layers are evaluated.
-
A string pattern is matched case-insensitively against the whole host name (the port is ignored), and
req.vhost[0]keeps the case the client sent. Each*matches the characters of a single label (it never matches a dot), so*.example.commatchesapi.example.combut neitherexample.comnora.b.example.com. Use aRegExpfor deeper subdomains. -
Wildcard matches are exposed as numeric indices on
req.vhost: for*.example.comand the hostapi.example.com,req.vhost[0]isapi.req.vhost.hostnameis the matched host name andreq.vhost.hostincludes the port. -
The host header comes from the client.
vhostis a routing convenience and not a security boundary: do not use it to decide who may access sensitive data, and configuretrust proxycorrectly when a proxy forwards the original host (see Behind Proxies). -
In production the usual choice is to let a reverse proxy or load balancer route by host and run one Express process per site; use
vhostwhen one process is simpler, for local multi-site development, or for tenant-by-subdomain applications.
import { createServer } from 'node:http';
import express from 'express';
import vhost from 'vhost';
const baseDomain = process.env.BASE_DOMAIN ?? 'bookshelf.localhost';
// Public site on the bare domain.
const site = express();
site.get('/', (req, res) => {
res.send('Welcome to the Bookshelf');
});
// API on api.<domain>.
const api = express();
api.get('/books', (req, res) => {
res.json([{ id: 1, title: 'Dune' }]);
});
// One handler for any other subdomain: tenant libraries such as alice.<domain>.
const tenants = express();
tenants.get('/', (req, res) => {
res.send(`Library of ${req.vhost[0]}`);
});
const app = express();
// Order matters: specific hosts before the wildcard.
app.use(vhost(baseDomain, site));
app.use(vhost(`api.${baseDomain}`, api));
app.use(vhost(`*.${baseDomain}`, tenants));
app.use((req, res) => {
res.status(404).send('Unknown host');
});
createServer(app).listen(Number(process.env.PORT ?? 3000), () => {
console.log(`Try: curl -H "Host: api.${baseDomain}" http://localhost:3000/books`);
});
With this code, curl -H "Host: api.bookshelf.localhost" http://localhost:3000/books is answered by api, curl -H "Host: alice.bookshelf.localhost" http://localhost:3000/ by tenants (req.vhost[0] is alice), and an unlisted host reaches the 404 handler. Most browsers resolve *.localhost to the loopback address, so you can test with http://api.bookshelf.localhost:3000/books directly.
Source: vhost middleware documentation and expressjs/vhost on GitHub.
The official vhost example
The Express repository ships a vhost example (examples/vhost). It builds a main application that serves / and /:sub and a small redirect application, and mounts them in a third application: vhost('*.example.com', redirect) sends every subdomain to the redirect application, which redirects to example.com using req.vhost[0] (the part matched by the wildcard) as the path, and vhost('example.com', main) serves the bare domain. Run it from a clone of the repository with node examples/vhost after adding foo.example.com, bar.example.com and example.com to your hosts file (the example’s comment lists the entries), then browse to http://foo.example.com:3000/ to see the redirect. The example uses one application per site because each wants its own routes and middleware; the earlier code adds a 404 fallback and a tenant application to show the same idea with more cases.
Source: Express examples index and examples/vhost.
API versioning with routers
Routers make versioning an API cheap: each version is a router mounted under its own prefix, and versions share whatever has not changed.
-
URL prefix versioning (
/api/v1/books,/api/v2/books) is the approach the officialmulti-routerexample demonstrates and the most common one; it is easy to cache, test and read in logs. -
Header or media-type versioning (
Accept: application/vnd.bookshelf.v2+json) keeps URLs stable but needs a small middleware to pick the router; see Versioning by header. -
A new version should copy only what changes. Put shared logic in services or in handler functions imported by both routers rather than copying route files.
import { Router } from 'express';
import { booksRouter } from '../routes/books.js';
export const v1 = Router();
v1.use((req, res, next) => {
// Sunset signalling for clients still on v1 (RFC 8594). The date is a placeholder.
res.set('Deprecation', 'true');
res.set('Sunset', 'Wed, 01 Jul 2027 00:00:00 GMT');
res.set('Link', '</api/v2/books>; rel="successor-version"');
next();
});
v1.get('/', (req, res) => {
res.json({ version: 1, links: { books: `${req.baseUrl}/books` } });
});
// v1 keeps the original books router unchanged.
v1.use('/books', booksRouter);
import { Router } from 'express';
const books = [
{ id: 1, title: 'Dune', author: { id: 1, name: 'Frank Herbert' } },
{ id: 2, title: 'Emma', author: { id: 2, name: 'Jane Austen' } },
];
export const v2 = Router();
v2.get('/', (req, res) => {
res.json({ version: 2, links: { books: `${req.baseUrl}/books` } });
});
// v2 embeds the author and paginates; v1 clients are unaffected.
v2.get('/books', (req, res) => {
const page = Math.max(1, Number.parseInt(req.query.page, 10) || 1);
const size = Math.min(50, Math.max(1, Number.parseInt(req.query.size, 10) || 10));
const start = (page - 1) * size;
res.json({
page,
size,
total: books.length,
items: books.slice(start, start + size),
});
});
import { v1 } from './api/v1.js';
import { v2 } from './api/v2.js';
app.use('/api/v1', v1);
app.use('/api/v2', v2);
// Unknown versions get a clear error instead of the generic 404.
// Known versions fall through, so an unknown route under /api/v1 still gets the generic 404.
const knownVersions = new Set(['v1', 'v2']);
app.use('/api/:version', (req, res, next) => {
if (knownVersions.has(req.params.version)) {
next();
return;
}
res.status(404).json({ error: `Unknown API version ${req.params.version}` });
});
The same router can be mounted in both versions when an area did not change: app.use(['/api/v1', '/api/v2'], reviewsRouter). A single app.use() registers it at both prefixes, and req.baseUrl inside shows which one the client used.
The official multi-router example
The Express repository’s examples/multi-router is the smallest version of this structure: the entry file mounts one router per API version with app.use('/api/v1', ...) and app.use('/api/v2', ...), each router lives in a file of a controllers folder, exports an express.Router() and defines its own / and /users routes, and the application adds a root route of its own. The v1 and v2 routers above follow the same pattern with ESM syntax and shared code. Run the example from a clone with node examples/multi-router and request /api/v1/users and /api/v2/users to compare the answers.
Source: examples/multi-router and Express examples index.
Versioning by header
When the version must travel in a header, a small router that dispatches to the others keeps the version routers unchanged.
import { Router } from 'express';
import { v1 } from './api/v1.js';
import { v2 } from './api/v2.js';
const byVersion = { 1: v1, 2: v2 };
export const api = Router();
api.use((req, res, next) => {
// e.g. "Accept-Version: 2"; default to the latest stable version.
const requested = Number(req.get('accept-version') ?? 2);
const router = byVersion[requested];
if (!router) {
res.status(406).json({ error: `Unsupported version ${requested}` });
return;
}
res.vary('Accept-Version');
router(req, res, next); // a router is a function: call it like middleware
});
Calling router(req, res, next) hands the request to the chosen router; when it does not answer, next continues in the parent. Add the Vary header, as above, so that caches do not mix versions.
Source: Response API, res.vary and Router API.
Mistakes and pitfalls
-
Forgotten
mergeParams. The child router sees emptyreq.params. Create it withRouter({ mergeParams: true }). -
Routers do not inherit routing options.
strict routingandcase sensitive routingset on the application do not change a router created with defaults. -
Router order. A greedy mount such as
app.use('/', router)placed before specific mounts steals their requests. Mount the most specific prefixes first. -
Trailing slash differences. With
strict: false(the default)/booksand/books/both match; withstrict: truethey do not, and a link without the slash returns 404. -
Assuming settings are shared. A sub-application does not inherit settings that have a default value (
etag,x-powered-by,views, …), excepttrust proxy; routers have no settings at all. -
Mutating
req.urlorreq.baseUrlby hand. Express manages them while entering and leaving mounted routers; changing them yourself produces wrong matches. To rewrite a request, setreq.urlin a middleware placed before the router that should see the new path, and nowhere else. -
Hardcoded absolute links in router files. Use
req.baseUrlso the router works at any mount point. -
Express 4 patterns that throw in Express 5.
app.use('/*', ...),router.get('/files/*', ...), optional parameters written:id?and regular-expression sub-expressions in a string path such as'/:id(\\d)'` throw at startup in Express 5. Use named wildcards (`/*splat+,/{*splat}), braces for optional segments (/books{/:id}) and validate withrouter.param(). -
Two responses. Calling
res.send()and thennext()(or forgettingreturnafternext('router')) leads toERR_HTTP_HEADERS_SENT. -
Async handlers. In Express 5 a rejected promise or a thrown error in an
asynchandler is forwarded to the error handlers, so atry/catchin every handler is no longer needed. Express 4 hangs the request in that case.
What changed recently
-
Express 5.0 (2024-09). Routing moved to
router2.x andpath-to-regexp8: no bare*, no?on parameters, no regular-expression sub-expressions in string paths.router.param(fn)and the colon prefix inapp.param(':name')were removed. Rejected promises from handlers reach the error handlers.app.routerreturned as a property (it was removed in Express 4). -
Express 5.1 (2025-03-31). Became the
latesttag on npm. Express 5 requires Node.js 18 or later. -
Express 5.2 and 5.3 (2025-12 to 2026-10). Maintenance releases that include security fixes in dependencies, plus the
QUERYmethod support (app.query()androuter.query()) in 5.3; see Migrating and What Changed. -
Express 4. Continues to receive security releases (4.22.3 is the latest). Router options and mounting behave the same as in Express 5; only path syntax and promise handling differ.
Where the books are outdated
The books consulted for this section teach routers with Express 3 and 4 idioms. The concepts they cover (router-level middleware, router.route() chaining, mounting) are still correct. These details are outdated:
| Older pattern | Today |
|---|---|
|
|
|
Both were removed in Express 4. Routes run in the order registered; in Express 5 |
|
Use |
Wildcard and optional paths such as |
Invalid with |
|
Removed in Express 5. Pass a name and a function. |
|
Removed in Express 5. Chain the status: |
|
Removed in Express 5. Read |
One |
Rejected promises are forwarded automatically. Mount an error-handling middleware last (also on a sub-application when it needs its own). |
References
-
Express routing guide (
express.Router,app.route()) -
Express API: Router (options,
router.route,router.param,router.use) -
Express API: express() (
express.Router) -
Express API: Application (
app.use,app.mountpath,app.path, themountevent) -
Express API: Request (
req.baseUrl,req.originalUrl,req.path,req.params) -
Using middleware (router-level middleware,
next('router')) -
Express examples (
multi-router,vhost) -
Routing, Route Paths and Parameters and Middleware Fundamentals