Routing: How Express Decides Who Answers
Routing: How Express Decides Who Answers
In Post 2 we saw that Express is Node's req and res with the boring parts
pre-filled, and that one of those boring parts is answering the question "which
function should handle this request?" That question is routing, and this
post is about how Express answers it — and the two or three ways it will
quietly answer it wrong if you don't know the rules.
By the end you'll know how a path like /quotes/42?author=Ada gets picked
apart, why the order you declare routes in matters more than it seems, and which
old routing habits Express 5 no longer accepts.
How to follow along (nothing to install). This post's sandbox is a small quotes API with every route we're about to discuss, running on StackBlitz in a browser tab. Whenever you see a Try it box, do it right there — no local setup, no
npm install. Open the Post 3 sandbox ↗ and keep it in another tab. The preview's front page lists every route. The preview can only send GETs, so for other methods there'snpm run req -- POST /quotesin the terminal.
1. Routing is a list, and the first match wins
Here's the entire mental model. When you write this:
app.get('/quotes', listQuotes);
app.post('/quotes', createQuote);
app.get('/quotes/:id', getQuote);
Express builds a list, in that order. When a request arrives, it walks the list from the top and asks each entry two questions: does the method match? and does the path match? The first entry that says yes to both gets to run. Nothing else does. If nothing says yes, the request falls out of the bottom and Express sends its default 404.
Two things fall out of that immediately:
- Method and path are matched together.
GET /quotesandPOST /quotesare different routes with different handlers, even though the path is the same. That's not a trick, it's just two list entries. - Order is part of your program. Same routes, different order, different behaviour. We'll break something with this in Section 3.
In the sandbox the last entry in the list is a catch-all that returns a JSON 404, so a miss looks like the rest of the API instead of an HTML error page:
// Anything that reaches here matched nothing above.
app.use((req, res) => {
res.status(404).json({ error: `no route for ${req.method} ${req.path}` });
});
Try it: In the sandbox ↗ preview, open
/quotes, then/nope. The second one hits the catch-all at the bottom. Now, in a second terminal (the+next to the terminal tab), runnpm run req -- DELETE /quotes/2. Same path as a route that exists, wrong method, so it walks pastapp.get('/quotes/:id')and lands in the 404 too.
2. Route params: the :id in the path
Hard-coding /quotes/1, /quotes/2, /quotes/3 is obviously not the plan. A
route parameter is a named placeholder segment:
app.get('/quotes/:id', (req, res) => {
const id = Number(req.params.id); // '2' -> 2. Without this, 2 !== '2'.
const quote = quotes.find((q) => q.id === id);
if (!quote) {
return res.status(404).json({ error: `no quote with id ${req.params.id}` });
}
res.json(quote);
});
:id matches exactly one path segment, and whatever it matched shows up in
req.params.id. You can have several: /authors/:name/quotes gives you
req.params.name. Nothing magic — Express turned your path pattern into a
regular expression and named the capture groups.
The thing to tattoo on your brain: route params are always strings. The URL
is text, so req.params.id is '2', not 2. If your data uses numeric ids,
quotes.find(q => q.id === req.params.id) will never match, and you'll stare
at a 404 for an id you can see right there in the array. Convert on the way in,
and decide what a non-numeric id means (here, a plain 404).
Try it: Open
/quotes/2in the preview, then/quotes/999, then/quotes/abc. Now inserver.jsdelete theNumber(...)wrapper so the line readsconst id = req.params.id;. Save, refresh/quotes/2. It's a 404 now, and nothing in the code looks wrong. That's the string-vs-number bug in its natural habitat. Put theNumber()back.
3. The order bug
Time to break something on purpose. The sandbox has two routes under /quotes/:
app.get('/quotes/random', (req, res) => { /* one random quote */ });
app.get('/quotes/:id', (req, res) => { /* one quote by id */ });
In that order, everything works. /quotes/random hits the first entry;
/quotes/2 skips it (the segment isn't literally random) and hits the second.
Now swap them, so /quotes/:id is declared first. Ask for /quotes/random.
Express walks the list, reaches /quotes/:id, asks "does random fit in a
:id slot?" — and yes, it does, because a param matches any segment. So the
id handler runs with req.params.id === 'random', finds no such quote, and you
get { error: "no quote with id random" }. The random route never gets a turn.
No error, no warning. Just a specific route silently shadowed by a general one declared above it. This is the single most common routing bug in Express, and it gets more likely as the file grows and people add routes at the bottom.
The rule: specific before general. Literal paths above param paths, more
segments above fewer, and anything that could swallow a whole segment
(:param, wildcards) as low as it can go.
Try it: In the sandbox ↗, cut the whole
app.get('/quotes/random', ...)block and paste it below the/quotes/:idroute. Save. Refresh/quotes/randoma few times: you'll get the "no quote with id random" 404 every time. Move it back above and it's random again. Same code, different order, different program.
4. Query strings: the part after the ?
Params are for which thing. Query strings are for how — filtering, paging,
sorting. Express parses ?author=Ada&limit=2 into req.query for you:
app.get('/quotes', (req, res) => {
let result = quotes;
if (req.query.author) {
result = result.filter((q) => q.author === req.query.author);
}
if (req.query.limit) {
const limit = Number(req.query.limit);
if (!Number.isInteger(limit) || limit < 1) {
return res.status(400).json({ error: 'limit must be a positive integer' });
}
result = result.slice(0, limit);
}
res.json(result);
});
Notice the query string doesn't take part in matching at all. /quotes,
/quotes?author=Ada and /quotes?nonsense=1 all hit the same route; the
handler decides what to do with the extras. Three things worth knowing:
- Values are strings. Same lesson as params.
req.query.limitis'2'. - Anything can be missing or garbage. The client controls this.
?limit=abcis a normal Tuesday. Validate, and send a 400 that says what you wanted. - A repeated key becomes an array.
?author=Ada&author=Gracegivesreq.query.author === ['Ada', 'Grace']. Our filter compares a string to an array, so it returns nothing. If you accept repeats, normalise with[].concat(req.query.author).
One Express 5 note: the default query parser is now the "simple" one, so
?page[size]=5 gives you a key literally named page[size], not a nested
object. If you want nested parsing back, opt in with
app.set('query parser', 'extended').
Try it: In the preview, open
/quotes?author=Ada, then/quotes?limit=2, then/quotes?limit=abc(a 400 with a useful message). Now try/quotes?author=Ada&author=Grace: an empty array, becauseauthoris now a list. Fix it inserver.jsby filtering with[].concat(req.query.author).includes(q.author)and refresh.
5. Wildcards, optional bits, and what Express 5 changed
Sometimes a route needs to swallow the rest of the path: a file server, a proxy, a "catch everything under here." That's a wildcard:
app.get('/files/*path', (req, res) => {
// req.params.path is an array of the matched segments.
res.json({ segments: req.params.path, joined: req.params.path.join('/') });
});
/files/a/b/c.txt gives req.params.path === ['a', 'b', 'c.txt'].
This is where Express 5 bites people who learned on Express 4, because the path
syntax underneath (a library called path-to-regexp) was tightened up. The
changes you'll actually hit:
| Express 4 habit | Express 5 |
|---|---|
Bare wildcard '/files/*' |
Throws at startup. Wildcards need a name: '/files/*path'. |
Optional param '/quotes/:id?' |
Throws. Use braces: '/quotes{/:id}'. |
Regex in the string '/quotes/:id(\\d+)' |
Throws. Validate in the handler, or pass a real RegExp as the path. |
The good news: all three fail loudly when the server starts, not silently at request time. The error message even links to an explanation.
Two defaults that didn't change and still surprise people: matching is
case-insensitive (/QUOTES/2 works) and a trailing slash is ignored
(/quotes/2/ works). Both can be switched off with app.set('case sensitive routing', true) and app.set('strict routing', true), but the defaults are
usually what you want.
Try it: Open
/files/a/b/c.txtin the preview and look at the array. Then inserver.jschange'/files/*path'to'/files/*'and save. Watch the terminal: the server refuses to start and tells you exactly why. Change it back. Now try/QUOTES/2and/quotes/2/in the preview; both work.
The whole thing, live
Everything above is one file, server.js, with the routes deliberately declared
in the order the post discusses them. The preview's front page lists every URL
worth clicking.
👉 Open the live demo in StackBlitz ↗
Dig deeper
- Read the routing section of the official Express 5 migration guide. It's short and lists every path-syntax change with before/after examples.
- Skim the
path-to-regexpREADME. That library is Express routing; once you see its syntax,:id,*pathand{/:id}stop looking arbitrary. - Look up
app.route('/quotes'). It lets you chain.get(),.post()and.delete()for one path, which keeps related routes together in the list.
Try this too
Add DELETE /quotes/:id to the sandbox: remove the quote and reply with 204
and no body, or 404 if it doesn't exist. Test it with
npm run req -- DELETE /quotes/2, then GET /quotes to confirm it's gone.
Then add GET /quotes/latest (the highest id). Before you run it, decide where
in the list it has to go, and why. If you get a "no quote with id latest" 404,
you already know the fix.
Next up — Part 4: Middleware, the real mental model. Every route in this
post was really a piece of middleware with a path attached. Next time we pull
that thread: the (req, res, next) pipeline, why next() exists, and how
express.json() from Post 2 actually works.