Skip to main content

Command Palette

Search for a command to run...

Routing: How Express Decides Who Answers

Updated
9 min readView as Markdown

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'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:

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:

// 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), 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:

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/:

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/: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:

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:

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 ↗

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.

Express, Deeply

Part 1 of 3

A beginner-to-advanced walk through Express.js for people who are tired of pasting `npm install express` without knowing why. We start with when to pick Express at all, tear it open to see what it does on top of raw Node, then work up through routing, middleware, error handling, auth, testing and production hardening. Every post has small original example apps you can run in your browser, a "Try it" box in each section, and an exercise at the end. No hype, no memorising API surface, just the mental model that makes the framework make sense.

Up next

What Express Actually Is (and Why It Exists)

What Express Actually Is (and Why It Exists) In Post 1 we figured out when to reach for Express and who's running it. Now the fun part: figuring out what it's actually doing for you. And the best way