PLAINFINCH learn by building

Route parameters

You will write one route that serves many pages, picking the right item by the number in the address.

Why not write one route per item?

The bird club has three sightings today and will have three hundred by the end of the year. Writing app.get('/sightings/1'), app.get('/sightings/2') and so on would mean editing the server every time someone spots a heron. The pages all look the same, and only the data changes.

A route parameter lets one route stand for a whole family of paths. /sightings/:id matches /sightings/1, /sightings/2 and /sightings/2026. The handler reads which one was asked for, looks it up, and builds the page. Add a sighting to the data and its page exists at once.

The list page and the item pages then work as a pair. The list builds each link from a sighting's id, and the parameter route reads that same id back out of the URL. As long as both sides use the same field, every link on the list leads to a working page.

How do you read a route parameter?

In a route path, a colon followed by a name marks a parameter: :id, :name, :slug. It matches one segment of the URL, meaning anything up to the next /. Express puts what it matched into req.params under that name, so GET /sightings/2 gives you req.params.id === '2'.

The parameter must be a whole segment's worth of text, and it can't be empty. /sightings/ with nothing after it doesn't match /sightings/:id, and /sightings/2/photos doesn't either, because that has an extra segment. Each of those needs its own route if you want it.

Express also decodes the value for you. A request for /members/ana%20lee gives req.params.name as ana lee, with a real space.

app.get('/sightings/:id', (req, res) => {
  console.log(req.params);   // { id: '2' } for GET /sightings/2
  res.send(`You asked for sighting ${req.params.id}`);
});

Why is req.params.id a string?

URLs are text, so every parameter arrives as a string, even when it looks like a number. The ids in the sightings array are numbers. '2' === 2 is false, so a find() that compares them directly never finds anything, and every page reports that the sighting doesn't exist.

Convert first: Number(req.params.id) turns '2' into 2. Anything that isn't a number becomes NaN, and NaN is never equal to any id, so /sightings/heron falls through to your 404 without extra code. Be careful with parseInt(), which reads '2abc' as 2 and so accepts paths you probably didn't mean to allow.

When ids are text in your data, such as member names or slugs like grey-heron, compare strings with strings and skip the conversion.

What should happen when the item doesn't exist?

find() returns undefined when nothing matches. If your code goes on to read sighting.bird, JavaScript throws a TypeError, Express catches it, and the visitor gets a 500 Internal Server Error. The Log shows the error and the line. That is the wrong answer: the server didn't break, the visitor asked for something that isn't there.

Check for the missing case first and answer 404. Put return in front of that response. Without it, the handler carries on to the next line and tries to send a second answer, which fails with ERR_HTTP_HEADERS_SENT in the Log, or reads a field of undefined first.

The message can be short for now. A later lesson builds a proper not-found page that every route can share.

app.get('/sightings/:id', (req, res) => {
  const id = Number(req.params.id);
  const sighting = sightings.find((s) => s.id === id);
  if (!sighting) {
    return res.status(404).send('<h1>No sighting with that number</h1>');
  }
  res.send(`<h1>${sighting.bird}</h1><p>Seen at ${sighting.place}</p>`);
});
FigureThe address GET /sightings/2 lines up with the route pattern /sightings/:id, so req.params becomes { id: '2' }. Number turns it into 2, find picks Kingfisher. GET /sightings/9 finds nothing and gets a 404.The address GET /sightings/2 lines up with the route pattern /sightings/:id, so req.params becomes { id: '2' }. Number turns it into 2, find picks Kingfisher. GET /sightings/9 finds nothing and gets a 404.
Express matches the address to the pattern piece by piece, and whatever sits where :id is becomes req.params.id, always as a string. Convert it with Number before comparing, and answer 404 when find comes back empty.

Can a path have more than one parameter?

Yes. Each parameter takes one segment, and you can mix them with fixed words. /members/:name/sightings/:id matches /members/ana/sightings/3 and gives you req.params.name as 'ana' and req.params.id as '3'. The names in the path are the names in req.params, letter for letter.

Use the fixed words to make the URL read well and to separate kinds of thing. /members/ana/sightings is clearer than /ana/s, and it leaves room for /members/ana/photos later. Keep parameter names short and descriptive, because you'll type them in every handler.

app.get('/members/:name/sightings/:id', (req, res) => {
  const { name, id } = req.params;
  const s = sightings.find((x) => x.by === name && x.id === Number(id));
  if (!s) return res.status(404).send('Not found');
  res.send(`<h1>${s.bird}</h1><p>Seen by ${name}</p>`);
});

Why does route order matter with parameters?

Express tries routes in the order you added them. /sightings/:id matches any single segment after /sightings/, including the word latest. If that route comes first, GET /sightings/latest lands in it with req.params.id === 'latest', the lookup fails, and the visitor gets a 404 from a route that was never meant for them.

Put fixed paths before parameter routes that could catch them: /sightings/latest first, then /sightings/:id. The Requests view names the route that answered each request, which makes this bug quick to find once you know to look.

What changed about route paths in Express 5?

Express 5 uses a stricter path syntax than Express 4, and older examples break in it. An optional parameter is written with braces, /sightings{/:id}, where Express 4 wrote /sightings/:id?. A wildcard needs a name, /files/*path, where Express 4 accepted a bare *. Regular expression characters inside path strings are no longer supported.

If the server refuses to start and the error mentions a path, check it for those older forms. For the routes in this course, plain colon parameters are all you need, and they work the same in both versions.

How do you test a parameter route properly?

One good request proves very little. Send at least three: an id that exists, such as GET /sightings/2, an id that doesn't, such as GET /sightings/99, and something that isn't a number at all, such as GET /sightings/heron. The first should answer 200 with the right bird, and the other two should answer 404. None of them should ever answer 500.

Read the Requests view for each one. All three should name the same route, app.get('/sightings/:id'), because the path decides which route runs and the data decides what it answers. If the Log shows a TypeError for the missing id, your handler read a field before checking that the sighting exists.

Then try the links from the list page in the Browser view. Each link is built from the same ids the route looks up, so a link that leads to a 404 points to a mismatch between the two, often a number stored as text in one place.

When should you use a parameter instead of a query string?

Use a parameter when the value picks out one thing and the page wouldn't make sense without it: a sighting, a member, a menu item. Use a query string, the part after ?, for optional settings on a page that exists anyway, like sorting or filtering a list. The next lesson covers query strings.

When an AI tool writes parameter routes, check three things. Is the parameter converted before it is compared with numbers? Is there a 404 with a return for the missing case? And are fixed paths placed before the parameter routes? Then send a good id, a missing id and a word, and read all three answers in the Requests view.

Field notes

  • A colon in a route path, like :id, matches one part of the address and names it.
  • The value arrives in req.params, so /sightings/2 gives req.params.id.
  • Parameters are always text, so convert the id to a number before comparing.
  • When nothing matches the id, answer 404 with a short message instead of crashing.

Easy to get wrong

Parameters are always strings. A string never equals a number with ===, so the lookup fails for every id.

Without return, the handler keeps going after the 404 and reads a field of undefined. The extra code runs after the answer has been sent.

The first matching route wins. :id matches latest, so the fixed path must come first.

Express 5 dropped the ? form for optional parameters. Braces mark the optional part instead.

Questions people ask

Can I limit a parameter to digits in the path?

Express 5 no longer accepts regular expressions inside path strings. Check the value in the handler instead, for example with Number.isInteger(id), and answer 404 when it doesn't fit.

Are route paths case-sensitive?

Not by default. /Sightings/2 matches /sightings/:id, and the parameter keeps the case it was sent in. app.set('case sensitive routing', true) changes that.

What is app.param()?

It runs a function whenever a named parameter appears in a matched route, which can load an item once for several routes. Most small servers do the lookup in the handler and never need it.

Should ids in URLs be the database ids?

Often they are, and that is fine for public things like sightings. For private records, remember that anyone can change the number in the address bar, so the server must check the visitor is allowed to see that record.

Keep going