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

express.Router(options) (also callable without new)

express()

Settings (view engine, etag, …​)

None. It uses the settings of the application that finally handles the request.

Its own app.set() / app.get() settings; settings with a default value are not inherited, settings without one are (see Which settings are inherited).

app.locals, template engines, app.render()

Not available.

Available, independent of the parent.

Mounted with

app.use(path, router) or router.use(path, router)

app.use(path, subApp)

Emits events

No

Emits mount on the sub-application with the parent as argument.

Typical use

Group related routes (/books, /authors), share router-level middleware, version an API.

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

caseSensitive

false

When false, /Books and /books are the same route. Set it to true to treat them as different. It is the per-router counterpart of the case sensitive routing application setting.

mergeParams

false

When false, req.params inside the router contains only the parameters of the router’s own paths. When true, parameters captured by the mount path in the parent are preserved and merged with the router’s own. If the same name appears in both, the router’s own value wins.

strict

false

When false, a trailing slash is ignored (/books and /books/ match the same route). When true, /books does not match /books/. It is the per-router counterpart of the strict routing setting.

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.

src/routes/books.js
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.

src/app.js
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;
src/server.js
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/1 and /books/1/reviews, but not /bookshelf.

  • Inside the router, req.url and req.path have the mount path stripped (a request for /books/1 is seen as /1). The stripped part is available as req.baseUrl, and req.originalUrl always holds the full original URL (req.baseUrl vs req.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-regexp 8 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 and mergeParams.

  • app.use() throws a TypeError when 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 is undefined because the module has no such export.

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 with app.METHOD() or router.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.

src/routes/reports.js
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 });
});
src/app.js (fragment)
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.

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 how param(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 to req, as in the books router above.

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.

src/routes/authors.js
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.params values are always strings. Convert them (Number(...)) and validate them. In Express 5, req.params has a null prototype, so it has no inherited hasOwnProperty or toString, and parameters that did not match are omitted from the object instead of appearing as undefined.

  • A router.param('authorId', ...) callback registered on the parent also runs for GET /authors/1/books, because the parameter is declared in the parent’s use() path. In the example above it returns 404 for GET /authors/5/books before 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.

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.

flowchart TD A["app (express)"] -->|"/books"| B["booksRouter"] A -->|"/authors"| C["authorsRouter"] A -->|"/reports"| R1["adminReportsRouter"] A -->|"/reports"| R2["publicReportsRouter"] A -->|"/admin"| S["adminApp (sub-app)"] A -->|"/api/v1, /api/v2"| V["versioned API routers"] C -->|"/:authorId/books"| D["authorBooksRouter (mergeParams)"] B --> B1["route /"] B --> B2["route /:bookId"] S --> S1["route /dashboard"] R1 -.->|"next('router')"| R2

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.

src/admin/app.js
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' });
});
src/app.js (fragment)
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.mountpath is the path pattern (or array of patterns) on which a sub-application was mounted. The main application has mountpath equal to /. It is exactly what was given to app.use(); when the sub-application is mounted on a pattern such as '/admin{/:section}', mountpath shows the pattern, not the matched string. For the matched string use req.baseUrl.

  • app.path() returns the canonical absolute path of the application, which includes the paths of all its parents: a sub-application mounted at /admin inside one mounted at /v1 returns /v1/admin.

  • The mount event is emitted on the sub-application, with the parent application as argument, when app.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.url exactly as for a router, and req.app refers 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

req.originalUrl

/authors/1/books?sort=title

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.

req.baseUrl

/authors/1/books

The mount path matched by the router the handler is currently in, as a concrete string. If the mount path had parameters or wildcards, baseUrl is the matched text (/authors/1/books), not the pattern. Different from app.mountpath, which stays as the pattern.

req.url

/?sort=title

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.

req.path

/

The pathname part of req.url, without the query string. Routes are matched against it.

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.

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.com matches api.example.com but neither example.com nor a.b.example.com. Use a RegExp for deeper subdomains.

  • Wildcard matches are exposed as numeric indices on req.vhost: for *.example.com and the host api.example.com, req.vhost[0] is api. req.vhost.hostname is the matched host name and req.vhost.host includes the port.

  • The host header comes from the client. vhost is a routing convenience and not a security boundary: do not use it to decide who may access sensitive data, and configure trust proxy correctly 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 vhost when one process is simpler, for local multi-site development, or for tenant-by-subdomain applications.

src/vhost-example.js
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.

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.

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 official multi-router example 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.

src/api/v1.js
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);
src/api/v2.js
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),
  });
});
src/app.js (fragment)
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.

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.

Mistakes and pitfalls

  • Forgotten mergeParams. The child router sees empty req.params. Create it with Router({ mergeParams: true }).

  • Routers do not inherit routing options. strict routing and case sensitive routing set 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) /books and /books/ both match; with strict: true they 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, …​), except trust proxy; routers have no settings at all.

  • Mutating req.url or req.baseUrl by hand. Express manages them while entering and leaving mounted routers; changing them yourself produces wrong matches. To rewrite a request, set req.url in a middleware placed before the router that should see the new path, and nowhere else.

  • Hardcoded absolute links in router files. Use req.baseUrl so 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 with router.param().

  • Two responses. Calling res.send() and then next() (or forgetting return after next('router')) leads to ERR_HTTP_HEADERS_SENT.

  • Async handlers. In Express 5 a rejected promise or a thrown error in an async handler is forwarded to the error handlers, so a try/catch in 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 router 2.x and path-to-regexp 8: no bare *, no ? on parameters, no regular-expression sub-expressions in string paths. router.param(fn) and the colon prefix in app.param(':name') were removed. Rejected promises from handlers reach the error handlers. app.router returned as a property (it was removed in Express 4).

  • Express 5.1 (2025-03-31). Became the latest tag 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 QUERY method support (app.query() and router.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

var router = express.Router(); with module.exports = router (CommonJS)

import { Router } from 'express' and export const x = Router() in ESM. CommonJS still works but is no longer the default style.

app.use(app.router) or app.configure() blocks (Express 3)

Both were removed in Express 4. Routes run in the order registered; in Express 5 app.router is only a property referencing the application’s router and is never mounted by hand.

app.use(bodyParser()) before the routers, or the standalone body-parser package

Use express.json() and express.urlencoded(), either at application level or as router-level middleware. Without a parser req.body is undefined in Express 5.

Wildcard and optional paths such as /api/*, /:id? and /:id(\\d)+ for mount paths and routes

Invalid with path-to-regexp 8 in Express 5: use /api/*splat and /books{/:id}, and validate numbers in handlers or router.param().

router.param(fn) to customize how param callbacks are registered

Removed in Express 5. Pass a name and a function.

res.send(404, 'Not found') and res.json(obj, 201) in router handlers

Removed in Express 5. Chain the status: res.status(404).send('Not found').

req.param('id') to read from params, body or query

Removed in Express 5. Read req.params.id, req.body.id or req.query.id explicitly.

One try/catch per async handler, with an error handler that was only added at the end

Rejected promises are forwarded automatically. Mount an error-handling middleware last (also on a sub-application when it needs its own).

References