Routing
|
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 5.3.0, released 2026-10-09; the last Express 4 release is 4.22.3), ESM and node: imports, and was checked on 2026-10-29. Express 5 itself runs on Node.js 18 and later, but the examples assume Node.js 20.19.3 or later on the 20 line, or 22.2.0 or later, because app.query() needs it (see app.query() and the HTTP QUERY method). Every example belongs to the Bookshelf scenario used throughout this section, and the outputs shown are what the applications return when they are exercised with curl.
Routing is how an Express application decides which code answers a request: a request is matched on its HTTP method and its path, and the matching route runs one or more handlers. A route is a small piece of the middleware machinery: its handlers have the same (req, res, next) signature, and the handlers of one route are called in order until one of them ends the response. This page covers the routing methods of the application object, multiple handlers and next('route'), chained routes, the order in which routes are tried and what happens when none matches, and three of the official examples. The path syntax itself (named parameters, wildcards, optional segments) is on Route Paths and Parameters, and grouping routes into mountable modules is on Routers and Modular Apps.
Anatomy of a route
A route definition has the shape app.METHOD(path, handler, ...):
-
appis an instance ofexpress()(or anexpress.Router(), which exposes the same routing methods). -
METHODis an HTTP method in lower case:get,post,put,delete, and so on. -
pathis a string, a regular expression or an array of either, matched against the request path. -
handleris one or more functions: a function, a comma-separated list, an array, or a mix of the three.
A route matches only when the method and the whole path match. A string path such as /books matches /books but not /books/1, whereas app.use('/books', ...) matches both because it mounts middleware on a prefix. Regular expressions are matched against any part of the path unless anchored with ^ and $.
Express 5 builds its routes on the standalone router package (version 2.x), which uses path-to-regexp 8. The behaviour described here is that of router, so it is the same for the application and for express.Router().
Source: Routing; Basic routing.
app.METHOD() for every HTTP verb
Express creates one routing method per method known to the running Node.js: the list is http.METHODS converted to lower case. The official documentation lists the following, and states that the API reference has explicit entries only for get, post, put, delete and query, the others working in exactly the same way:
checkout, copy, delete, get, head, lock, merge, mkactivity, mkcol, move, m-search, notify, options, patch, post, purge, put, query, report, search, subscribe, trace, unlock, unsubscribe.
Because the list comes from Node.js, others such as propfind or link exist too, and app.query exists only on a Node.js line whose http.METHODS contains QUERY (see app.query() and the HTTP QUERY method). A method whose name is not a valid JavaScript identifier needs bracket notation: app['m-search'](path, handler).
The usual REST verbs of the Bookshelf API look like this.
import { METHODS } from 'node:http';
import express from 'express';
const app = express();
const books = new Map([[1, { id: 1, title: 'Dune', year: 1965 }]]);
let nextId = 2;
app.use(express.json());
// Every verb has a lower-case method on the app.
app.get('/books', (req, res) => {
res.json([...books.values()]);
});
app.post('/books', (req, res) => {
const book = { id: nextId++, ...req.body };
books.set(book.id, book);
res.status(201).location(`/books/${book.id}`).json(book);
});
app.put('/books/:id', (req, res) => {
const id = Number(req.params.id);
books.set(id, { id, ...req.body });
res.json(books.get(id));
});
app.delete('/books/:id', (req, res) => {
res.sendStatus(books.delete(Number(req.params.id)) ? 204 : 404);
});
// app.all() matches every verb, whatever the request method is.
app.all('/books/audit', (req, res, next) => {
console.log(`audit: ${req.method} ${req.originalUrl}`);
next();
});
// QUERY: a safe, idempotent read whose criteria travel in the body.
// Registered only when this Node.js line knows the verb.
if (METHODS.includes('QUERY')) {
app.query('/books/search', (req, res) => {
const { titleContains = '', yearFrom = 0 } = req.body ?? {};
const found = [...books.values()].filter(
(b) => b.title.toLowerCase().includes(String(titleContains).toLowerCase()) && b.year >= yearFrom,
);
res.json(found);
});
}
app.listen(3000, (error) => {
if (error) throw error;
console.log('Bookshelf listening on http://localhost:3000');
});
Observed behaviour, with the server above running:
| Request | Response | Why |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
There is no |
|
|
Express answers |
Three details are worth remembering:
-
A
GETroute also handlesHEAD, unless aHEADroute is registered before it. -
Express does not send
405 Method Not Allowedon its own. A path that exists forGETbut is requested withPUTsimply falls through to the 404 handler, so an API that wants405and anAllowheader has to add it (see Route order and the 404 fall-through). -
app.METHOD()registers a route on the application, whileapp.use()registers middleware. The first one matches the full path and one method, the second matches a path prefix and every method.
Source: app.METHOD(); Routing, "Route methods".
app.all()
app.all(path, handler, ...) is app.METHOD() for every HTTP method. It is the usual place for code that must run for a path regardless of the verb: logging, loading a record that every verb needs, or a pre-condition. Its handlers behave exactly like middleware, so they must call next() to let the specific verb routes run; the example above does so, and the PATCH request still falls through because nothing else matches.
app.all('*', ...) is a common Express 4 idiom for "every path". It no longer works in Express 5, because a bare * is not valid path syntax. Use a named wildcard (app.all('/*splat', ...)) or, more simply, app.use() without a path.
Source: app.all().
app.query() and the HTTP QUERY method
app.query() was added in Express 5.3. It routes the HTTP QUERY method, which the official documentation describes as standardised on 2026-06-15 as RFC 10008. QUERY is a read operation that, like POST, carries its input in the request body, but, like GET, is safe and idempotent. Intermediaries may retry it automatically, and its responses are cacheable with the request body part of the cache key. It is meant for queries whose input is too large, too sensitive or too structured for a URL: deeply nested filters, search criteria that should not end up in server logs or browser history, or lists of identifiers that exceed URL length limits.
Four rules apply:
-
Node.js requirement.
app.query()exists only when the running Node.js knows theQUERYmethod inhttp.METHODS. The API reference gives the requirement as Node.js>=20.19.3 <21 || >=22.2.0, whereas Express 5.3 itself declaresengines.nodeas>= 18. On Node.js 18 or on older 20 and 22 releases the method is not defined: calling it throws aTypeError, and the HTTP parser of such a release does not recognise the verb either. The example guards the registration withhttp.METHODS, so that the same file runs everywhere. -
The body is not parsed for you.
req.bodyisundefineduntil a body parser such asexpress.json()orexpress.urlencoded()runs for a matchingContent-Type. Mount it before the route. -
app.all()includesQUERY. A catch-all handler also runs forQUERYrequests where Node.js supports them. -
Support outside the server is limited. Browsers,
fetchwrappers, proxies, CDNs and API gateways may not know the verb yet. Verify every hop between the client and the application before relying on it in production, and considerPOST /books/searchas the portable alternative.
A curl call that exercises the route of the example:
curl -X QUERY http://localhost:3000/books/search \
-H 'content-type: application/json' \
-d '{"titleContains": "em", "yearFrom": 1800}'
Source: app.query(); History.md. The method specification itself is published by the RFC Editor as RFC 10008.
Multiple handlers, next() and next('route')
A route accepts any number of handlers. Each one is called with (req, res, next); the chain continues only when a handler calls next(), and stops when a handler ends the response (any of the methods in The response methods) or calls next(err). A handler that neither responds nor calls next leaves the client waiting until it times out.
Handlers can be passed as separate arguments, as an array, or as a combination of both, and Express flattens them. That makes it easy to share a stack such as "log, then load the shelf" between routes.
next() and next('route') differ in how far they skip:
| Call | Effect |
|---|---|
|
Go to the next handler of the same route; after the last handler, go to the next matching route or middleware. |
|
Skip the remaining handlers of the current route and continue with the next route that matches the request. It works only in handlers registered with |
|
Leave the current router altogether (covered in Routers and Modular Apps). |
|
Skip all remaining non-error handlers and go to the error-handling middleware (see Error Handling). Any truthy argument other than the strings |
next('route') is the tool for pre-conditions: "this handler stack only applies if a condition holds, otherwise let a later route answer". The example sends members to a members-only response and everybody else to a public one, for the same method and path.
import express from 'express';
const app = express();
const members = new Set(['ana', 'luis']);
// A pre-condition handler: members go on, everybody else is sent to the next matching route.
function membersOnly(req, res, next) {
if (members.has(req.get('x-user') ?? '')) {
next(); // continue with the next handler of THIS route
} else {
next('route'); // skip the remaining handlers of this route
}
}
app.get('/library/books', membersOnly, (req, res) => {
res.json({ audience: 'members', books: ['Dune', 'Emma', 'Ulysses'] });
});
// Reached by non-members because of next('route'), not by members.
app.get('/library/books', (req, res) => {
res.json({ audience: 'public', books: ['Dune'] });
});
// An array of handlers, a list of handlers or a mix of both is accepted.
const logRequest = (req, res, next) => {
console.log(`${req.method} ${req.path}`);
next();
};
const loadShelf = (req, res, next) => {
res.locals.shelf = 'fiction';
next();
};
app.get('/library/shelf', [logRequest, loadShelf], (req, res) => {
res.json({ shelf: res.locals.shelf });
});
app.listen(3000, (error) => {
if (error) throw error;
console.log('Listening on http://localhost:3000');
});
Observed behaviour:
| Request | Response |
|---|---|
|
|
|
|
|
|
The diagram summarises where each call sends the request.
Express 5 forwards the outcome of async handlers for you: a rejected promise (or a thrown error) in a handler is passed to next(err) automatically. In Express 4 an async handler had to catch the error and call next(err) itself, or the request hung and Node.js reported an unhandled rejection. A handler that calls next() and then also writes a response is a bug in both versions, and produces the error Cannot set headers after they are sent.
Chaining routes with app.route()
app.route(path) returns a single Route instance for one path, on which every verb is attached by chaining. The path is written once, which removes the typos that arise when /books/:id is repeated for get, put and delete, and .all() on the route runs before the specific verb for every method.
import express from 'express';
const app = express();
const books = new Map([[1, { id: 1, title: 'Dune' }]]);
app.use(express.json());
// The path is written once; each verb adds its handlers.
app
.route('/books/:id')
.all((req, res, next) => {
const book = books.get(Number(req.params.id));
if (!book && req.method !== 'PUT') {
res.status(404).json({ error: 'book not found' });
return;
}
res.locals.book = book;
next();
})
.get((req, res) => {
res.json(res.locals.book);
})
.put((req, res) => {
const id = Number(req.params.id);
books.set(id, { id, ...req.body });
res.json(books.get(id));
})
.delete((req, res) => {
books.delete(Number(req.params.id));
res.sendStatus(204);
});
app.listen(3000, (error) => {
if (error) throw error;
console.log('Listening on http://localhost:3000');
});
Observed behaviour:
| Request | Response |
|---|---|
|
|
|
|
|
|
|
|
A route created by app.route() has no mount path of its own and cannot be reused elsewhere. For a group of related routes, express.Router() is the better tool (see Routers and Modular Apps), and a router exposes the same route() method.
Source: Routing, "app.route()"; app.route().
The response methods
A handler must end the request-response cycle. These are the methods of res that do:
| Method | Description |
|---|---|
|
Prompts the client to save a file; see File Uploads and Downloads. |
|
Ends the response without data (the low-level |
|
Sends a JSON response with the right |
|
Sends a JSON response with JSONP support. |
|
Redirects the request. |
|
Renders a view template and sends the HTML. |
|
Sends a response of various types (string, |
|
Sends a file as an octet stream, with a |
|
Sets the status code and sends its standard text as the body. |
Their details are on Response. Two Express 5 changes matter in routes: the forms res.json(obj, status), res.send(body, status), res.send(status) and res.redirect(url, status) were removed, so the status is always set with res.status(code) first (res.status(201).json(book)), and res.status() accepts only integers from 100 to 999.
Source: Routing, "Response methods".
Route order and the 404 fall-through
Express tries middleware and routes in the order in which they were registered, and the first handler that ends the response wins. Two consequences follow.
Specific routes before general ones. /books/latest also matches /books/:id, so whichever is registered first answers. Put literal paths ahead of parameterised ones, and narrower paths ahead of wildcards.
Nothing matched means 404. When a request has gone through every layer without being answered, Express hands it to its final handler, which sends 404 Not Found with an HTML body such as Cannot GET /nope. Express does not treat this as an error; it is the absence of a match. To produce your own response, add a middleware after all routes, with no path and the normal three-argument signature, and, after it, a four-argument error-handling middleware.
import express from 'express';
const app = express();
// 1. A specific route first ...
app.get('/books/latest', (req, res) => {
res.json({ id: 42, title: 'Latest arrival' });
});
// 2. ... then the parameterised one. Swap them and /books/latest would match :id.
app.get('/books/:id', (req, res) => {
res.json({ id: req.params.id });
});
// 3. Nothing matched: a final middleware turns the fall-through into a 404.
app.use((req, res) => {
res.status(404).json({
type: 'about:blank',
title: 'Not Found',
status: 404,
detail: `No route for ${req.method} ${req.originalUrl}`,
});
});
// 4. Any error that reaches this four-argument handler ends here.
app.use((err, req, res, next) => {
if (res.headersSent) {
next(err);
return;
}
res.status(err.status ?? 500).json({ title: 'Error', status: err.status ?? 500 });
});
app.listen(3000, (error) => {
if (error) throw error;
console.log('Listening on http://localhost:3000');
});
Observed behaviour:
| Request | Response |
|---|---|
|
|
|
|
|
|
Practical points:
-
The 404 middleware must be registered last, before only the error handler. Registered earlier, it answers every request that reaches it and hides every later route.
-
To return
405 Method Not Allowed, register a catch-all withapp.all('/books', handler)for the path after its verb routes, and have the handler set theAllowheader and callres.sendStatus(405). -
A trailing slash and letter case are ignored by default:
/Books/matches/books. Thestrict routingandcase sensitive routingsettings change that, and are described on Route Paths and Parameters. -
Express 5 brings
app.routerback as a property, so tooling that inspects the router (for instance to list the routes) can reach it again.
Official example: resource routing
The official resource example registers the routes of a collection with a small helper, and shows an interesting path, /users/:a..:b{.:format}: two named parameters separated by two literal dots, and an optional format suffix in braces. This version applies the same ideas to books. It is Express 5 syntax; in Express 4 the optional suffix was written with a question mark (:format?), which now throws a path error.
import express from 'express';
const app = express();
// An ad-hoc helper that registers the usual routes of a collection.
function resource(path, controller) {
app.get(path, controller.index);
app.get(`${path}/:a..:b{.:format}`, (req, res) => {
const a = Number.parseInt(req.params.a, 10);
const b = Number.parseInt(req.params.b, 10);
controller.range(req, res, a, b, req.params.format);
});
app.get(`${path}/:id`, controller.show);
app.delete(`${path}/:id`, (req, res) => {
controller.destroy(req, res, Number.parseInt(req.params.id, 10));
});
}
const books = [
{ title: 'Dune' },
{ title: 'Emma' },
{ title: 'Ulysses' },
{ title: 'Middlemarch' },
{ title: 'Persuasion' },
];
const BookController = {
index(req, res) {
res.json(books);
},
show(req, res) {
const book = books[Number(req.params.id)];
if (!book) {
res.status(404).json({ error: 'Cannot find book' });
return;
}
res.json(book);
},
destroy(req, res, id) {
const exists = id in books;
delete books[id];
res.status(exists ? 200 : 404).send(exists ? 'destroyed' : 'Cannot find book');
},
range(req, res, a, b, format) {
const slice = books.slice(a, b + 1);
if (format === 'json') {
res.json(slice);
return;
}
res.send(`<ul>${slice.map((book) => `<li>${book.title}</li>`).join('')}</ul>`);
},
};
resource('/books', BookController);
app.listen(3000, (error) => {
if (error) throw error;
console.log('Listening on http://localhost:3000');
});
Observed behaviour:
| Request | Response |
|---|---|
|
|
|
|
|
|
|
|
|
|
Order matters here too. /books/:a..:b{.:format} is registered before /books/:id, otherwise 1..3 would be captured as an id. The helper uses delete(...), which is the Express 5 name; app.del() was removed.
Source: expressjs/express, examples/resource.
Official example: params
app.param(name, callback) registers a callback that runs once per request, before the first route handler that has a parameter of that name, however many routes in the request cycle declare it. The callback receives (req, res, next, value, name). The official params example uses it to convert two parameters to integers and to load a user. An array of names registers the same callback for each name.
import express from 'express';
const app = express();
class HttpError extends Error {
constructor(status, message) {
super(message);
this.status = status; // finalhandler uses it as the response status
}
}
const books = [{ title: 'Dune' }, { title: 'Emma' }, { title: 'Ulysses' }, { title: 'Persuasion' }];
// Runs once per request for every route that declares :from or :to.
app.param(['from', 'to'], (req, res, next, value, name) => {
const number = Number.parseInt(value, 10);
if (Number.isNaN(number)) {
next(new HttpError(400, `failed to parse ${name}: ${value}`));
return;
}
req.params[name] = number;
next();
});
// Load the book once; the handler can rely on req.book.
app.param('book', (req, res, next, id) => {
const book = books[id];
if (!book) {
next(new HttpError(404, 'failed to find book'));
return;
}
req.book = book;
next();
});
app.get('/', (req, res) => {
res.send('Visit /book/0 or /books/0-2');
});
app.get('/book/:book', (req, res) => {
res.json(req.book);
});
app.get('/books/:from-:to', (req, res) => {
const { from, to } = req.params;
res.json(books.slice(from, to + 1).map((book) => book.title));
});
app.listen(3000, (error) => {
if (error) throw error;
console.log('Listening on http://localhost:3000');
});
Observed behaviour:
| Request | Response |
|---|---|
|
|
|
|
|
|
|
|
When NODE_ENV is not production, the default error handler (finalhandler) sends the stack trace as the response body; with NODE_ENV=production it sends only the status text, such as Not Found, and never the error message. A status from err.status or err.statusCode is honoured when it is between 400 and 599.
Express 5 changes how app.param() behaves: the form that took a single function (app.param(fn)) was removed, and so was a leading colon in the name (app.param(':name')). router.param() rejects an array of names in 5.x, so register one callback per name there. Param callbacks are local to the router that defines them: they do not fire for parameters of routes in another router, nor for parameters that appear only in a mount path.
Official example: route-specific middleware
The official route-middleware example shows that a route’s handler list is a convenient place for authorisation. Functions that load a record or check a permission are written once and listed before the final handler, and a factory turns configuration into a middleware (restrictTo('admin')). Failing a check calls next(err). The official example passes plain errors, which the default handler turns into 500; here the errors carry an HTTP status.
import express from 'express';
const app = express();
const users = [
{ id: 0, name: 'ana', role: 'member' },
{ id: 1, name: 'luis', role: 'member' },
{ id: 2, name: 'marta', role: 'admin' },
];
class HttpError extends Error {
constructor(status, message) {
super(message);
this.status = status;
}
}
// Route-level middleware: plain functions placed before the final handler.
function loadUser(req, res, next) {
const user = users[Number(req.params.id)];
if (!user) {
next(new HttpError(404, `Failed to load user ${req.params.id}`));
return;
}
req.user = user;
next();
}
function restrictToSelf(req, res, next) {
if (req.authenticatedUser.id === req.user.id) {
next();
} else {
next(new HttpError(403, 'Forbidden'));
}
}
// A factory returns a configured middleware.
function restrictTo(role) {
return (req, res, next) => {
if (req.authenticatedUser.role === role) {
next();
} else {
next(new HttpError(403, 'Forbidden'));
}
};
}
// Fake authentication. A real application would verify a session or a token.
app.use((req, res, next) => {
req.authenticatedUser = users[Number(req.get('x-demo-user') ?? 0)];
next();
});
app.get('/user/:id', loadUser, (req, res) => {
res.send(`Viewing user ${req.user.name}`);
});
app.get('/user/:id/edit', loadUser, restrictToSelf, (req, res) => {
res.send(`Editing user ${req.user.name}`);
});
app.delete('/user/:id', loadUser, restrictTo('admin'), (req, res) => {
res.send(`Deleted user ${req.user.name}`);
});
app.listen(3000, (error) => {
if (error) throw error;
console.log('Listening on http://localhost:3000');
});
Observed behaviour:
| Request | Response |
|---|---|
|
|
|
|
|
|
|
|
|
|
The x-demo-user header is only a stand-in for authentication; a real application would verify a session or a token, as shown on Authentication and Authorization. A header that the client sends must never decide who a user is.
Express 4 versus Express 5
| Topic | Express 4 | Express 5 |
|---|---|---|
|
|
Removed; use |
Bare wildcard |
|
Invalid; use |
Optional parameter |
|
|
Async handlers |
A rejected promise had to be caught and forwarded |
Rejections and thrown errors reach |
|
Allowed |
Removed. |
|
An ordinary object |
An object with a null prototype; unmatched optional parameters are absent. |
|
Deprecated forms |
Removed; set the status first with |
|
Not available |
|
The full migration, with the codemods that rewrite most of these, is on Migrating and What Changed.
What changed recently
-
Express 5.3.0 (2026-10-09) added
app.query()for the HTTPQUERYmethod and fixedreq.freshfor it, together with dependency security updates. -
Express 5.1.0 (2025-03-31) made Express 5 the default
latestrelease on npm; routes written for 5.x usepath-to-regexp8 syntax. -
Express 4.22.3 (2026-09-14) is the latest 4.x. Version 4 keeps receiving fixes, but it has neither
app.query()nor automatic forwarding of rejected promises.
Where the books are outdated
The requester-provided books that this section draws on were all written for Express 4. Where routing is concerned, their samples need the following adjustments for Express 5.
| In the books | Today |
|---|---|
Routes registered with |
The verbs are unchanged. Use ESM, |
|
Removed in Express 5: use |
|
Invalid path syntax in Express 5: use |
Optional parameters such as |
Rewritten with braces and named wildcards. |
Chained routes with |
Still supported and unchanged; |
|
Removed: use |
|
Removed from Express long ago: use |