Route Paths and Parameters
|
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 (npm express 5.3.0, released 2026-10-09) on Node.js 18 or later, with the
Express 4.22 differences called out wherever they matter. Express 5.3 resolves its paths with router 2.2, which in turn
uses path-to-regexp 8, so the syntax described here is the path-to-regexp 8 syntax; the package has a newer major
version on npm, but Express 5.3 does not use it. All facts were checked on 2026-10-29. The path syntax is the part of
the Express 4 to 5 migration that breaks the most code: patterns that were accepted silently for years now throw when
the route is registered. Routes are introduced in Routing, router objects in
Routers and Modular Apps, and the full list of breaking changes
in Migrating and What Changed.
How a path is matched
A route path is a pattern that Express compiles, once, when the route is registered, into a regular expression plus a list
of named parameters. For each request the router tests the pattern against the request path only: the query string
(?page=2) is never part of the match and is parsed separately into req.query. Two consequences follow.
-
A malformed pattern fails at startup, when
app.get()(orrouter.get(),app.use()and so on) is called, not when the first request arrives. In Express 5 the failure is a thrownTypeErrorfrom path-to-regexp, for exampleMissing parameter name at index 1: /*followed by a link to the library’s error page. The exact wording differs between messages and between releases, so match on the fact that it throws, not on the text. -
Matching is linear. path-to-regexp 8 deliberately removed the features that allowed catastrophic backtracking in user-supplied sub-expressions (regular expressions written inside a string path) and requires a separator between adjacent parameters. That is the security reason several Express 4 patterns were removed rather than kept.
The pattern language has only a few building blocks, summarised here and detailed in the sections that follow.
| Syntax | Meaning | Example |
|---|---|---|
plain text |
Matched literally, within the current setting for case and trailing slash |
|
|
A named parameter: one or more characters up to the next |
|
|
A named parameter whose name is not a plain identifier |
|
|
A named wildcard: one or more characters, including |
|
|
An optional group; everything inside appears or disappears together |
|
backslash |
Escapes the next character so it is matched literally |
|
A tour of the syntax with Bookshelf
The following program registers one route for each building block. It uses the same Bookshelf domain as the rest of the
section, keeps its data in memory so it runs without a database, and answers every route with the parameters Express
extracted, which makes the behaviour easy to see with curl. Save it as paths.js in a project with
the type field set to module in package.json and run node paths.js.
import express from 'express';
const app = express();
// 1. A plain string path: matched literally.
app.get('/api/v1/books', (req, res) => {
res.json({ route: 'list' });
});
// 2. A named parameter: one or more characters up to the next slash.
app.get('/api/v1/books/:isbn', (req, res) => {
res.json({ route: 'detail', params: req.params });
});
// 3. Two parameters separated by a character that cannot be part of a name.
app.get('/api/v1/ranges/:from-:to', (req, res) => {
res.json({ route: 'range', params: req.params });
});
// 4. An optional segment: the whole brace group may be absent.
app.get('/api/v1/authors{/:authorId}', (req, res) => {
res.json({ route: 'authors', params: req.params });
});
// 5. An optional extension on a parameter.
app.get('/api/v1/exports/:name{.:ext}', (req, res) => {
res.json({ route: 'export', params: req.params });
});
// 6. A named wildcard: matches across slashes and yields an array.
app.get('/files/*path', (req, res) => {
res.json({ route: 'file', params: req.params });
});
// 7. A wildcard that also matches the bare prefix, via an optional group.
app.get('/docs{/*page}', (req, res) => {
res.json({ route: 'docs', params: req.params });
});
// 8. A quoted parameter name: not a valid JavaScript identifier.
app.get('/api/v1/tags/:"tag-name"', (req, res) => {
res.json({ route: 'tag', tag: req.params['tag-name'] });
});
// 9. An escaped reserved character: matches the literal text "(draft)".
app.get('/api/v1/books/\\(draft\\)', (req, res) => {
res.json({ route: 'literal parentheses' });
});
// 10. Several paths for one handler.
app.get(['/health', '/healthz'], (req, res) => {
res.json({ status: 'ok' });
});
app.listen(3000, (error) => {
// Express 5 passes startup errors (such as EADDRINUSE) to this callback.
if (error) {
throw error;
}
console.log('Listening on http://localhost:3000');
});
Source: Express: Routing
Requests and the responses they produce:
GET /api/v1/books/9781788830775 HTTP/1.1
HTTP/1.1 200 OK
{"route":"detail","params":{"isbn":"9781788830775"}}
GET /api/v1/ranges/1990-2000 HTTP/1.1
HTTP/1.1 200 OK
{"route":"range","params":{"from":"1990","to":"2000"}}
GET /api/v1/authors HTTP/1.1
HTTP/1.1 200 OK
{"route":"authors","params":{}}
GET /api/v1/authors/42 HTTP/1.1
HTTP/1.1 200 OK
{"route":"authors","params":{"authorId":"42"}}
GET /api/v1/exports/catalog.csv HTTP/1.1
HTTP/1.1 200 OK
{"route":"export","params":{"name":"catalog","ext":"csv"}}
GET /files/covers/2026/front.png HTTP/1.1
HTTP/1.1 200 OK
{"route":"file","params":{"path":["covers","2026","front.png"]}}
GET /docs HTTP/1.1
HTTP/1.1 200 OK
{"route":"docs","params":{}}
Source: Express: Routing
String paths and the request path
A string path with no special characters is a literal. /api/v1/books matches exactly that path, ignoring the query
string. By default the comparison ignores letter case and a single trailing slash, so /API/v1/Books/ also matches; both
behaviours are settings, described in Case sensitivity and strict routing.
app.use(path, ...) is different from app.get(path, ...): a path given to app.use() is a prefix. The pattern
/api matches /api, /api/ and /api/v1/books, and while the middleware runs, req.url has the prefix removed and
req.baseUrl holds it. app.get(), app.post(), app.all() and the other method helpers match the complete path.
Named parameters
A parameter starts with a colon and a name, and matches one or more characters except the segment delimiter /. Its
value is percent-decoded with decodeURIComponent before it reaches req.params, so /books/The%20Pragmatic%20Programmer
gives the string The Pragmatic Programmer. An encoded slash (%2F) is decoded into the value; it does not create a new
segment, which is a frequent surprise for paths that carry file names.
The name after the colon must be a valid JavaScript identifier: a letter, $ or _, followed by letters, digits, $ or
_. Anything else ends the name. That rule is what makes /ranges/:from-:to work, because the hyphen is not part of
an identifier, and it is also why /:id1 is a parameter called id1 rather than id followed by a digit.
Quoted parameter names
When the name is not an identifier, for example because it contains a hyphen or a dot, put it in double quotes after the
colon: :"tag-name". The value is then read with bracket notation, req.params['tag-name']. Keep such names rare; an
identifier name is easier to destructure.
Named wildcards
A wildcard is an asterisk followed by a name, such as *splat. Unlike a named parameter, a wildcard matches one or
more characters including slashes. Express splits the matched text on /, decodes each piece, and stores an array
in req.params:
import express from 'express';
const app = express();
app.get('/files/*path', (req, res) => {
// GET /files/covers/2026/front.png
console.log(req.params.path); // [ 'covers', '2026', 'front.png' ]
res.sendStatus(204);
});
Source: Express: Moving to Express 5
Three details matter in practice.
-
The wildcard must have a name, and any identifier will do. The name
splatis a convention used by the official migration guide, and this page follows it, but*pathor*restbehave identically. -
/*splatrequires at least one character after the slash, so it does not match/itself. To match the bare prefix as well, wrap the wildcard in an optional group:/{*splat}. When the group is absent the key is simply not inreq.params. -
A wildcard can be followed by more pattern text, and several wildcards can appear in one path, each with its own name. Keep such patterns simple; once a path needs real regular-expression behaviour, a RegExp path is clearer.
Because the value is an array, code ported from Express 4 that treated req.params[0] as a string must change. Joining the
array back with req.params.path.join('/') rebuilds the original sub-path; always validate it before using it as a file
name, since segments such as .. are still ordinary strings.
Optional segments with braces
Braces mark an optional group: either all of the text inside appears or none of it does. Braces replace the Express 4 question-mark suffix, which could only follow a single character or parameter.
| Pattern | Matches | req.params |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
each key only when present |
An unmatched optional parameter is omitted from req.params; in Express 4 the key could be present with the value
undefined. Test with 'authorId' in req.params or req.params.authorId === undefined, and give defaults with
destructuring: const { page = '1' } = req.params.
Escaping reserved characters
The characters :, *, {, } and the backslash have a meaning in the pattern language. The characters (,
), [, ], ?, + and ! are reserved for future use, so path-to-regexp 8 throws if they appear
unescaped. To match one of them literally,
precede it with a backslash. In a JavaScript string the backslash itself must be doubled:
import express from 'express';
const app = express();
// Matches the literal path /api/v1/books/(draft)
app.get('/api/v1/books/\\(draft\\)', (req, res) => {
res.json({ route: 'literal parentheses' });
});
// String.raw avoids the doubled backslash.
app.get(String.raw`/api/v1/what\?`, (req, res) => {
res.json({ route: 'literal question mark' });
});
Source: path-to-regexp
A URL that needs these characters in its path is usually a sign that a parameter would be cleaner; the escape form exists mainly for legacy URLs that must keep working.
RegExp paths
A route path may be a JavaScript regular expression. It is tested against the path as it is, so write it with ^ and $
anchors. Capture groups become numbered parameters (0, 1, …) in req.params:
import express from 'express';
const app = express();
// GET /api/v1/books/9781788830775 -> params { '0': '9781788830775' }
app.get(/^\/api\/v1\/books\/(\d{13})$/, (req, res) => {
res.json({ isbn: req.params[0] });
});
Source: Express: Routing
Regular expressions are the replacement for everything path-to-regexp 8 no longer supports inside a string, such as a digits-only constraint. They are also a way to write an expensive pattern by accident, so avoid nested quantifiers and never build one from user input.
Arrays of paths
Any route or app.use() call accepts an array whose entries can mix string patterns and regular expressions, for
example app.get(['/health', '/healthz', /^\/_status$/], handler). The route matches when any entry matches, and
req.params holds the parameters of the entry that matched.
The req.params object
req.params holds the values extracted from the path, keyed by parameter name. Express 5 changed its shape in three ways.
-
Null prototype. In Express 5 the object is created without a prototype, so
req.params.hasOwnProperty('id')throws aTypeError. UseObject.hasOwn(req.params, 'id')or theinoperator. A side effect is that a parameter calledconstructorortoStringis just a normal key and cannot pollute anything. -
Unmatched parameters are omitted instead of being present with the value
undefined. -
Wildcard parameters are arrays of decoded segments instead of one string.
All other values are strings. Convert and validate them yourself: Number(req.params.id) is NaN for bad input, and
nothing in the router checks that a value looks like the thing it names.
import express from 'express';
const app = express();
app.get('/api/v1/books/:isbn', (req, res) => {
console.log(Object.getPrototypeOf(req.params)); // null
console.log(Object.hasOwn(req.params, 'isbn')); // true
res.json({ isbn: req.params.isbn });
});
Source: Express: Request
When a router is mounted under a path that itself contains parameters (app.use('/authors/:authorId', router)), the
router’s handlers do not see authorId unless the router was created with express.Router({ mergeParams: true }); this
is covered in Routers and Modular Apps.
Parameter callbacks: app.param and router.param
app.param(name, callback) and router.param(name, callback) register a function that runs before any route that
declares the parameter name. It is the place to load an entity once, validate a format, or reject early, instead of
repeating the same lines in every handler. The callback receives the usual req, res, next, plus the parameter’s
value and its name.
import express from 'express';
const books = new Map([
['9781788830775', { isbn: '9781788830775', title: 'Full-Stack Web Development with Vue.js and Node' }],
['9781837637959', { isbn: '9781837637959', title: 'Modern Full-Stack React Projects' }],
]);
const app = express();
// Runs once per request for any route with :isbn, in this router and no other.
app.param('isbn', (req, res, next, value, name) => {
if (!/^\d{13}$/.test(value)) {
res.status(400).json({ title: `Invalid ${name}`, status: 400 });
return;
}
const book = books.get(value);
if (!book) {
res.status(404).json({ title: 'Book not found', status: 404 });
return;
}
res.locals.book = book;
next();
});
app.get('/api/v1/books/:isbn', (req, res) => {
res.json(res.locals.book);
});
app.get('/api/v1/books/:isbn/reviews', (req, res) => {
res.json({ isbn: req.params.isbn, reviews: [] });
});
app.listen(3000);
Source: Express: Application, app.param
GET /api/v1/books/abc HTTP/1.1
HTTP/1.1 400 Bad Request
{"title":"Invalid isbn","status":400}
GET /api/v1/books/9781837637959 HTTP/1.1
HTTP/1.1 200 OK
{"isbn":"9781837637959","title":"Modern Full-Stack React Projects"}
Source: Express: Application, app.param
The rules that trip people up:
-
A parameter callback is called once per request-response cycle for a given value, even if several routes with that parameter match in turn (here, a request that falls through two routes calls it once). It is called again if the value changes between routes.
-
Callbacks are local to the router that defines them. A callback registered on
appdoes not run for parameters that belong to a router mounted withapp.use(); register it on that router. -
Only the parameter name is a key. A callback registered for
isbndoes not run for a wildcard or for a parameter with another name, and names are matched without a leading colon. -
A callback runs before the route handlers but after the router decided the route matches, so a callback that sends a response ends the request before any handler runs. To fall through to the next route instead, call
next('route').
Case sensitivity and strict routing
Two settings change how literal text and trailing slashes are compared. Both are disabled by default and can be set on the application or on an individual router.
| Setting | Default | Effect when enabled |
|---|---|---|
|
disabled |
|
|
disabled |
|
import express from 'express';
const app = express();
// Application-wide.
app.set('case sensitive routing', true);
app.set('strict routing', true);
// A router has its own options; it does not inherit the application's settings.
const admin = express.Router({ caseSensitive: false, strict: false });
admin.get('/Reports', (req, res) => {
res.json({ ok: true });
});
app.use('/admin', admin);
// With strict routing on, only the exact form matches.
app.get('/api/v1/books/', (req, res) => {
res.json({ route: 'with trailing slash' });
});
Source: Express: Application, app.set
With strict routing off, a route registered as /books also answers /books/. With it on, GET /api/v1/books returns a
404 from the example above, because only the version with the trailing slash was registered. Prefer redirecting one form
to the other at the edge (a reverse proxy) or leaving both settings off, so that links copied with or without a slash
keep working.
Express 4 patterns that now throw
Express 4 used path-to-regexp 0.1, which accepted regular-expression fragments inside strings and treated a lone * as
"anything". Express 5 does not. Every pattern in the table below is accepted by Express 4.22 and throws when registered in
Express 5.3; the right-hand column is the supported replacement.
| Express 4 pattern | In Express 5.3 | Replacement |
|---|---|---|
|
Throws, parameter name missing |
|
|
Throws |
|
|
Throws |
|
|
Throws |
|
|
Throws, the question mark is reserved |
|
|
Throws |
|
|
Throws, no regular expression in strings |
|
|
Throws |
|
|
Throws |
|
|
Throws, the plus sign is reserved |
RegExp: |
|
Throws |
|
|
Throws |
Named parameter, optional group |
|
Throws |
RegExp, or validate in the handler |
|
The leading colon was removed in Express 5 |
|
Source: Express: Moving to Express 5
One Express 4 pattern does not throw but silently changes meaning. In '/ab*cd', path-to-regexp 8 reads *cd as a
wildcard named cd, so the route matches /ab followed by one or more characters (the cd suffix is no longer
required) and puts the rest in req.params.cd. Rewrite it as the RegExp /^\/ab.*cd$/.
Two replacements need more explanation.
Constraining a parameter. The Express 4 idiom /:id(\\d+) let the router skip a route whose parameter did not look
numeric, so a later route (for example /books/new) could handle the request. Without it, use either of these:
import express from 'express';
const app = express();
// Option 1: a RegExp path restricts the match; a non-matching URL falls through.
app.get(/^\/api\/v1\/books\/(\d+)$/, (req, res) => {
res.json({ id: Number(req.params[0]) });
});
// Option 2: keep the readable path and fall through from a parameter callback.
app.param('id', (req, res, next, value) => {
if (/^\d+$/.test(value)) {
next();
} else {
next('route'); // hand the request to the next matching route
}
});
app.get('/api/v1/authors/:id', (req, res) => {
res.json({ id: Number(req.params.id) });
});
Source: Express: Application, app.param
SPA fallbacks and 404 handlers. A catch-all for a single-page application belongs after the static middleware and the
API routes, and uses the optional wildcard so that / is served too:
import { fileURLToPath } from 'node:url';
import express from 'express';
const app = express();
const indexFile = fileURLToPath(new URL('./dist/index.html', import.meta.url));
app.use(express.static(fileURLToPath(new URL('./dist', import.meta.url))));
app.get('/api/v1/books', (req, res) => {
res.json([]);
});
// Everything else, including "/", receives the application shell.
app.get('/{*splat}', (req, res) => {
res.sendFile(indexFile);
});
app.listen(3000);
Source: Express: Moving to Express 5
How do I write this path in Express 5?
/api/v1/books"] B -- no --> D{"A value in one segment?"} D -- yes --> E{"Is the value optional?"} E -- no --> F["Named parameter
/books/:isbn"] E -- yes --> G["Optional group
/authors{/:id}"] D -- no --> H{"Several segments, any depth?"} H -- yes --> I{"Must it match the bare prefix?"} I -- no --> J["Named wildcard
/files/*path"] I -- yes --> K["Optional wildcard
/{*splat}"] H -- no --> L{"Needs a character pattern
such as digits only?"} L -- yes --> M["RegExp path, or validate
in handler or app.param"] L -- no --> N["Several alternatives?
Use an array of paths"]
The chart ends in the same three escape hatches the rest of this page describes: an array of paths when several fixed URLs share a handler, a RegExp for anything that is really a character-level pattern, and validation in the handler or in a parameter callback for everything else.
Testing a pattern without starting a server
Express delegates matching to path-to-regexp, which can be called directly. That makes it easy to try a pattern in a
script or a unit test before putting it in a route. Install it with npm install path-to-regexp@8 so that the version
matches what Express 5.3 uses; the package has a newer major version whose syntax and API can differ.
import { match } from 'path-to-regexp';
const check = match('/exports/:name{.:ext}');
console.log(check('/exports/catalog.csv'));
// { path: '/exports/catalog.csv', params: { name: 'catalog', ext: 'csv' } }
console.log(check('/exports/catalog'));
// { path: '/exports/catalog', params: { name: 'catalog' } }
console.log(check('/other')); // false
Source: path-to-regexp
Express applies a few options on top of the library defaults (case, trailing slash, whether the pattern must reach the end of the path), so use this only to check syntax, and confirm the final behaviour with a request.
What changed recently
-
Express 5.0 (2024-09-10) introduced path-to-regexp 8 through
router2: named wildcards, brace groups, quoted names, reserved characters, and the removal of regular-expression fragments inside strings.app.param(':name'),app.param(fn)androuter.param(fn)were removed. -
Express 5.0 also changed
req.params: null prototype, omitted unmatched parameters, array wildcards. -
Express 5.3 (2026-10-09) keeps the same path syntax. Its dependency range is
router^2.2.0, which depends onpath-to-regexp^8.0.0; patterns written for 5.0 are valid in 5.3. -
Express 4.22 continues to use the old syntax and is not affected by any of the above, which is why code can behave differently after an upgrade even when no route was edited.
For an automated first pass over a code base, the official codemods rewrite the common cases; review the result, since some replacements (a digits-only constraint, for example) cannot be expressed as a mechanical edit.
Where the books are outdated
The four reference books were written against Express 4.0 to 4.18, so none of them can show the Express 5 path syntax.
| Book usage | Status in Express 5.3 |
|---|---|
|
Throws at startup; use a path-less |
SPA fallback routes (Sharma, chapter 6) |
Same fix: |
Optional parameters written with a trailing question mark, the Express 4 form used throughout the era’s tutorials |
Throws; use brace groups |
Reading a wildcard as the string |
The value is now an array under a name you choose |
|
Removed; pass the plain name |
Treating |
The object has a null prototype; use |
The concepts themselves (parameters, wildcards, parameter callbacks, case and strict routing) are unchanged in purpose and
carry over directly; only the spelling of patterns and the shape of req.params differ.