Lesson 9 of 20

Express Routing

Route Parameters and Query Strings

A route in Express is a method, a path, and one or more functions. The interesting part is the path, because most useful URLs contain a value that changes: a user id, a slug, a year. A colon marks that segment as a route parameter, so /users/:id matches /users/42 and /users/ananya alike, and Express hands you the captured text on req.params.

Parameters and query strings answer different questions, and choosing correctly is most of what makes an API feel designed rather than assembled. A parameter identifies which resource you mean and is part of that resource's address: /books/42 is one particular book. A query string modifies how a collection is presented — filtering, sorting, pagination — and is optional by nature: /books?author=orwell&page=2. If removing the value leaves a URL that no longer identifies anything, it belonged in the path.

Both arrive as strings, without exception. req.params.id is '42'; req.query.page is '2'. Convert them and then check that the conversion worked. Number('abc') gives NaN, and a NaN that reaches a database query produces an error message about the database rather than about the input that caused it. parseInt('12abc') is worse still: it quietly returns 12 and accepts nonsense.

Query strings have one shape that surprises everybody once. A key repeated twice becomes an array: ?tag=node&tag=api gives you ['node', 'api'], while ?tag=node gives you the plain string 'node'. Code that calls .toLowerCase() on that value works perfectly until a client sends two tags, then crashes with "not a function". If a parameter can legitimately repeat, normalise it with [].concat(value) and treat it as a list always.

Give defaults to optional query values, and clamp the ones that cost you something. limit deserves particular care: a client asking for ?limit=1000000 will cheerfully make your server load a million rows into memory to answer one request, and nobody had to be malicious for that to happen. Destructuring defaults keep this tidy, but a default is not validation — you need both.

Example
const express = require('express');
const app = express();

// Route parameters — capture dynamic segments
app.get('/users/:id', (req, res) => {
  const id = Number(req.params.id);   // req.params.id is the STRING '42'
  if (!Number.isInteger(id) || id < 1) {
    return res.status(400).json({ error: 'id must be a positive integer' });
  }
  res.json({ message: `User ${id} details` });
});
// GET /users/42 → { message: "User 42 details" }

// Multiple parameters
app.get('/posts/:year/:month', (req, res) => {
  res.json({
    year: req.params.year,
    month: req.params.month
  });
});
// GET /posts/2025/03 → { year: "2025", month: "03" }

// Query strings — parsed automatically, but every value is a string
app.get('/search', (req, res) => {
  const q = req.query.q;

  // A repeated key becomes an ARRAY: ?tag=node&tag=api -> ['node','api']
  const tags = [].concat(req.query.tag || []);

  // Defaults are not validation. Clamp anything that costs you memory.
  const page  = Math.max(1, Number(req.query.page) || 1);
  const limit = Math.min(100, Math.max(1, Number(req.query.limit) || 10));

  res.json({ query: q, tags, page, limit });
});
// GET /search?q=nodejs&page=2 -> { query: "nodejs", tags: [], page: 2, limit: 10 }
  • :param — a named segment; the captured text arrives on req.params
  • req.params says which resource; req.query says how to return it
  • Every value in both is a string — convert, then check that the conversion worked
  • A repeated query key becomes an array; normalise with [].concat(value)
  • Give defaults to optional query values, and clamp limit to something sane
  • Number('abc') is NaN; parseInt('12abc') is 12 — prefer Number with a check
Notes
  • Never pass req.params.id straight into a database call. Even with MongoDB, where an id looks like ordinary text, an unchecked value produces a cast error you then have to translate into a sensible response — and with SQL it is the opening move of an injection attack. One conversion and one check at the top of the handler removes both problems together.

Route Matching Order

Express keeps your routes in a list and, for every request, tries them from the top until one matches. First match wins; the rest are never consulted. That single sentence explains most routing behaviour that otherwise looks arbitrary.

The consequence is that a parametrised route swallows anything shaped like it. /users/:id matches /users/new, /users/export and /users/me, because :id means "any one segment" and has no idea that some of those were meant to be commands rather than identifiers. Register the specific paths first and the parametrised one last, and each gets exactly the requests it was written for.

Matching includes the method, which produces a confusing bug report the first time it happens. A POST to a path you only defined for GET does not run the GET handler; it falls through to your 404 as though the path did not exist at all. "The endpoint returns 404 but I can see it right there in the code" is nearly always this, and the fix is to look at the method rather than the path.

A catch-all 404 handler must therefore be the last thing registered. app.use((req, res) => res.status(404).json(...)) placed halfway down the file answers every request that reaches it, and every route below becomes permanently unreachable — no error, no warning, just dead code that looks alive. The error handler, with its four arguments, comes after even that.

Two smaller details. With Express's default settings, paths are matched without regard to case and a trailing slash is tolerated, so /Books/ and /books reach the same handler; both settings are configurable, and you should not rely on the leniency if you also care about having one canonical URL for search engines. And a router mounted under a prefix strips that prefix before matching inside itself, which is the subject of the next section.

Example
const express = require('express');
const app = express();

// WRONG ORDER — /users/new never reaches its own handler
app.get('/users/:id', (req, res) => res.json({ id: req.params.id }));
app.get('/users/new', (req, res) => res.json({ form: true }));
// GET /users/new  ->  { "id": "new" }   ...then a database error about "new"

// RIGHT ORDER — specific paths above parametrised ones
app.get('/users/new', (req, res) => res.json({ form: true }));
app.get('/users/:id', (req, res) => res.json({ id: req.params.id }));

// The method matters as much as the path
app.get('/books', listBooks);
// POST /books  ->  404, because no POST route for /books was ever defined

// The 404 catch-all goes LAST. Anything registered below it is unreachable.
app.use((req, res) => {
  res.status(404).json({ error: `Cannot ${req.method} ${req.originalUrl}` });
});

// The error handler (four arguments) comes after even that — see lesson 10
app.use((err, req, res, next) => {
  res.status(500).json({ error: 'Something went wrong' });
});
  • First match wins — Express stops at the first route whose method and path both fit
  • Specific paths above parametrised ones: /users/new before /users/:id
  • A wrong method produces a 404, not a 405, unless you handle that case yourself
  • The 404 catch-all must be registered after every real route
  • The four-argument error handler comes after the 404 handler
  • Case and trailing slashes are forgiving by default; both are configurable settings
Notes
  • If a route "does nothing", read your route registrations in order as though they were a list of if statements, because that is precisely what they are. The bug is almost always a route above the one you are debugging that quietly matched first, or a path-less app.use sitting higher in the file than you remembered putting it.

Router Modules for Organisation

express.Router() gives you a miniature application. It accepts routes and middleware exactly as app does, but it never listens for anything itself. You build one per resource — users, books, orders — and mount each on the main app under a prefix.

Paths inside a router are relative to wherever it is mounted. app.use('/api/users', userRoutes) together with router.get('/') gives you GET /api/users, and router.get('/:id') gives you GET /api/users/:id. Repeating the prefix inside the router is the classic mistake: it produces /api/users/api/users, which then 404s in a way that looks exactly like the router was never mounted.

Routers carry their own middleware, and much of their value lives there. router.use(requireAuth) at the top of a task router protects every route in the file with one line, and — more importantly — it cannot be forgotten on the route you add next month. It documents intent as well: anyone opening the file sees immediately that everything below requires a logged-in user.

Mounting is also where API versioning lives. app.use('/api/v1/users', usersV1) alongside app.use('/api/v2/users', usersV2) lets a new response shape exist next to the old one, so a mobile app nobody has updated keeps working while your web client moves on. The route files themselves are unaware of any of it; only the mount line differs.

One detail catches people building nested resources. A router mounted at /users/:userId/posts cannot see req.params.userId by default, because a router does not inherit its parent's parameters. express.Router({ mergeParams: true }) is what switches that on, and forgetting it produces an undefined in a place where you were certain a value existed.

Example
// routes/users.js
const express = require('express');
const router = express.Router();

// Applies to EVERY route in this file — impossible to forget on a new one
// router.use(requireAuth);

// Paths here are RELATIVE to the mount point: '/' becomes /api/users
router.get('/', (req, res) => {
  res.json([{ id: 1, name: 'Alice' }]);
});

router.get('/:id', (req, res) => {
  res.json({ id: req.params.id, name: 'Alice' });
});

router.post('/', (req, res) => {
  res.status(201).json({ message: 'User created', data: req.body });
});

module.exports = router;

// app.js — Mount the router
const express = require('express');
const userRoutes = require('./routes/users');
const app = express();

app.use(express.json());
app.use('/api/users', userRoutes);   // every route above gains this prefix

// Versioning is nothing more than a second mount point
// app.use('/api/v2/users', userRoutesV2);

// A nested router needs mergeParams to see the parent's :userId
// const postRoutes = express.Router({ mergeParams: true });
// app.use('/api/users/:userId/posts', postRoutes);

app.listen(process.env.PORT || 3000);
  • express.Router() — a mini app with routes and middleware, but no listen
  • app.use('/api/users', router) — mount it; paths inside are relative to the prefix
  • Never repeat the prefix inside the router, or you get /api/users/api/users
  • router.use(fn) — apply middleware to every route in that file at once
  • express.Router({ mergeParams: true }) — see the parent router's parameters
  • One router file per resource, all mounted together in app.js
Notes
  • The app.use lines in app.js become a table of contents for your API. If somebody can read those six or seven lines and correctly describe what your service does, the structure is right. If they cannot, the routes are probably grouped by accident rather than by resource.

Chained Handlers, app.route and router.param

A route can take more than one function, and they run in order like any other middleware chain. router.get('/stats', requireAuth, requireAdmin, handler) reads as a sentence: authenticate, check the role, then do the work. Each link either calls next() or answers, so the handler at the end may assume everything before it succeeded.

That is the cleanest way to express a precondition. Instead of three if blocks at the top of a swollen handler, each check becomes a small named function that can be reused on the next route and tested on its own. What remains in the handler is the part that is genuinely about books, or tasks, or orders.

app.route('/books') groups the methods that share a path, so the GET and the POST for one collection sit next to each other instead of drifting apart as the file grows. It changes nothing about behaviour; it changes how much you have to hold in your head while reading.

router.param('id', fn) runs a function whenever a route in that router contains an :id. The usual use is to look the resource up once, attach it to req, and answer 404 right there — so five handlers stop repeating the same three lines. It is genuinely useful and also easy to overuse: a param handler doing something surprising is invisible from the route it affects, so keep it to fetching and validating.

Where should a check live? A rule that holds up in practice: middleware for things that are true of the request — is there a valid token, is the content type right, is this user an admin — and handler code for things about the resource — does this book exist, does it belong to this user. Mixing them produces middleware that only makes sense on one route, which is a reliable sign it should have been an ordinary function call.

Example
const express = require('express');
const router = express.Router();

// Small, named, reusable checks
function requireAuth(req, res, next) {
  if (!req.headers.authorization) {
    return res.status(401).json({ error: 'Login required' });
  }
  req.user = { id: 7, role: 'admin' };   // decoded from a token in lesson 16
  return next();
}

function requireAdmin(req, res, next) {
  if (req.user.role !== 'admin') {
    return res.status(403).json({ error: 'Admin only' });
  }
  return next();
}

// A chain, read left to right
router.get('/stats', requireAuth, requireAdmin, (req, res) => {
  res.json({ books: 120 });
});

// Keep one path's methods together
router.route('/')
  .get(listBooks)
  .post(requireAuth, createBook);

// Runs for every route in this router that has an :id
router.param('id', async (req, res, next, value) => {
  try {
    const book = await Book.findById(value);
    if (!book) return res.status(404).json({ error: 'Book not found' });
    req.book = book;        // handlers below can now assume it exists
    return next();
  } catch (err) {
    return next(err);
  }
});

router.get('/:id', (req, res) => res.json(req.book));
router.delete('/:id', requireAuth, async (req, res) => {
  await req.book.deleteOne();
  res.status(204).send();
});

module.exports = router;
  • router.get(path, a, b, handler) — a chain; each link answers or calls next()
  • Preconditions belong in middleware; resource lookups belong close to the handler
  • router.route(path).get(...).post(...) — one path's methods in one place
  • router.param('id', fn) — run a lookup once for every route with that parameter
  • Attach what you loaded to req so later functions in the chain can use it
Notes
  • Attaching your own data to req is normal practice, but choose names nobody else will use. req.user is an established convention; req.data or req.id is an invitation for some library to have had the same idea. When a value mysteriously changes between two middlewares, an overwritten property on req is a strong suspect.

Routing Mistakes That Cost an Afternoon

The duplicated prefix comes first because it is the most common. router.get('/api/users/:id') inside a router already mounted at /api/users produces /api/users/api/users/:id. Nothing errors; the route simply is not where you think it is. When a route 404s and you are certain it exists, work out its real path by joining the mount point to the route path before touching anything else.

Next, the unreachable route. Any app.use registered without a path applies to every request, so a catch-all 404 or a misplaced app.use(express.static(...)) sitting above your API routes answers first and the routes below never run. It is the routing version of the middleware-order rule, and it produces the same symptom: code that is obviously correct and never executes.

Then, trusting the parameter. req.params.id is user input in the most literal sense — it comes from the URL, and anyone can type a URL. Length limits, type checks and the path-traversal rules from lesson 5 all apply here. A route like /files/:name that passes the value to the file system is one of the easiest ways there is to leak your own .env.

Query values are not guaranteed to be strings either. Depending on which query parser your Express version uses, something like ?filter[role]=admin can arrive as a nested object rather than text, and code that calls req.query.role.toLowerCase() then throws on input nobody expected. Check the type, not merely the presence, of anything you are about to treat as a string.

And one that is not a routing mistake at all: a route that works from curl but fails when your frontend calls it. That is CORS — a rule browsers enforce about which origins may read a response. Your server answered correctly and the browser refused to hand the result to the page. No amount of rearranging routes will fix it, and lesson 10 shows what does.

  • Duplicated prefix — a router's paths are relative to its mount point, never absolute
  • A path-less app.use above your routes answers first and hides everything below
  • req.params is user input; validate its type, length and shape before use
  • Query values may not be strings — check the type before calling a string method
  • Works in curl, fails in the browser — that is CORS, not routing
  • 404 on a route you can see in the code — check the HTTP method as well as the path
Notes
  • The most useful routing debug tool is one line, registered before everything else: app.use((req, res, next) => { console.log(req.method, req.originalUrl); next(); }). req.originalUrl shows the full path including any mount prefix, which is exactly the piece of information a routing bug is hiding from you.
Ask AI