# 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 ↗](https://stackblitz.com/edit/stackblitz-starters-hukwpgm7?file=server.js)**
> 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's
> `npm run req -- POST /quotes` in the terminal.

---

## 1. Routing is a list, and the first match wins

Here's the entire mental model. When you write this:

```js
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 /quotes` and `POST /quotes`
  are 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:

```js
// 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 ↗](https://stackblitz.com/edit/stackblitz-starters-hukwpgm7?file=server.js)
> 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), run
> `npm run req -- DELETE /quotes/2`. Same path as a route that exists, wrong
> method, so it walks past `app.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:

```js
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/2` in the preview, then `/quotes/999`, then
> `/quotes/abc`. Now in `server.js` delete the `Number(...)` wrapper so the line
> reads `const 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 the `Number()` back.

---

## 3. The order bug

Time to break something on purpose. The sandbox has two routes under `/quotes/`:

```js
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 ↗](https://stackblitz.com/edit/stackblitz-starters-hukwpgm7?file=server.js),
> cut the whole `app.get('/quotes/random', ...)` block and paste it *below* the
> `/quotes/:id` route. Save. Refresh `/quotes/random` a 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:

```js
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.limit` is `'2'`.
- **Anything can be missing or garbage.** The client controls this. `?limit=abc`
  is a normal Tuesday. Validate, and send a 400 that says what you wanted.
- **A repeated key becomes an array.** `?author=Ada&author=Grace` gives
  `req.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, because `author` is now a
> list. Fix it in `server.js` by 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:

```js
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.txt` in the preview and look at the array. Then
> in `server.js` change `'/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/2` and `/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 ↗](https://stackblitz.com/edit/stackblitz-starters-hukwpgm7?file=server.js)**

<!-- AUTHOR NOTE — do not publish. Project id: stackblitz-starters-hukwpgm7 (Post 3,
     see SANDBOX.md). Source: sandboxes/post-03-routing/
     (package.json, server.js, request.js). All routing claims in this post were
     verified against Express 5.2.1 on 2026-09-06. -->

## 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-regexp` README. That library *is* Express routing; once you
  see its syntax, `:id`, `*path` and `{/: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.
