Skip to content
Express.js ex routing 4 min read

Route Parameters

Most useful URLs aren’t static — /users/42 and /users/99 should hit the same handler but operate on different records. Route parameters let you capture those dynamic segments of a path by name, so a single route definition can serve an entire family of URLs. Express parses the matched values out of the URL and hands them to you on req.params, ready to drive a database lookup or any other logic.

The :param syntax

You mark a path segment as dynamic by prefixing it with a colon. The name after the colon becomes the key under which Express stores the captured value:

const express = require("express");
const app = express();

app.get("/users/:id", (req, res) => {
  res.json({ userId: req.params.id });
});

app.listen(3000);

The path /users/:id matches any single-segment URL of the form /users/<something>. When a request arrives, Express splits the URL, lines it up against the pattern, and fills in req.params with the matched piece.

A request for GET /users/42 produces:

Output:

{ "userId": "42" }

Note: Every value in req.params is a string, even when it looks like a number. If you need a real number, convert it explicitly with Number(req.params.id) or parseInt(req.params.id, 10) and validate the result.

Multiple parameters

A path can contain as many named parameters as you need, each separated by a literal segment. This is the natural way to express nested or hierarchical resources:

app.get("/users/:userId/posts/:postId", (req, res) => {
  const { userId, postId } = req.params;
  res.json({ userId, postId });
});

A request for GET /users/7/posts/13 returns:

Output:

{ "userId": "7", "postId": "13" }

Destructuring req.params keeps handlers readable once more than one parameter is involved. Parameter names must be made up of word characters ([A-Za-z0-9_]), and each name has to be unique within a single route.

A realistic /users/:id example

In practice a parameter feeds a lookup. Here an async handler awaits a database call, validates the input, and responds with the right status code:

app.get("/users/:id", async (req, res, next) => {
  const id = Number(req.params.id);

  if (!Number.isInteger(id)) {
    return res.status(400).json({ error: "id must be an integer" });
  }

  try {
    const user = await db.findUser(id);
    if (!user) {
      return res.status(404).json({ error: "User not found" });
    }
    res.json(user);
  } catch (err) {
    next(err);
  }
});

This pattern — parse, validate, look up, respond — covers the vast majority of parameterized routes.

Optional parameters

Sometimes a segment may or may not be present. The syntax for optional parameters differs between Express versions, which is a common source of confusion:

VersionOptional syntaxExample
Express 4.xtrailing ?/users/:id?
Express 5.xbraces {}/users{/:id}

In Express 5, route matching moved to path-to-regexp v8, where the ? suffix was removed in favor of wrapping the optional part in braces:

// Express 4.x — :format is optional
app.get("/report/:year/:format?", (req, res) => {
  res.json({ year: req.params.year, format: req.params.format ?? "html" });
});

// Express 5.x — equivalent route
app.get("/report/:year{/:format}", (req, res) => {
  res.json({ year: req.params.year, format: req.params.format ?? "html" });
});

When the optional segment is absent, its key is simply undefined on req.params, so supply a fallback (here with ??) where it matters.

Constraining parameters with patterns

You often want a parameter to match only certain values — digits, for instance. In Express 4 you can attach an inline regular expression to a parameter directly in the path:

// Express 4.x — :id must be one or more digits
app.get("/users/:id(\\d+)", (req, res) => {
  res.json({ id: Number(req.params.id) });
});

Now /users/42 matches but /users/abc falls through to the next route (typically a 404). Inline parameter regexes were removed in Express 5; the recommended replacement is to validate inside the handler, or to centralize the check with app.param:

// Works in Express 4 and 5 — runs before any route using :id
app.param("id", (req, res, next, value) => {
  if (!/^\d+$/.test(value)) {
    return res.status(400).json({ error: "id must be numeric" });
  }
  req.userId = Number(value);
  next();
});

app.get("/users/:id", (req, res) => {
  res.json({ id: req.userId });
});

app.param registers a trigger that fires whenever a route with that named parameter matches, letting you validate or pre-load a resource in one place instead of repeating the logic across handlers.

Tip: Reserve literal routes like /users/me for before their parameterized sibling /users/:id. Because the first matching route wins, defining /users/:id first would capture me as an id and shadow the literal route.

Best Practices

  • Treat every req.params value as a string and convert/validate it before use — never trust raw URL input.
  • Use clear, singular parameter names (:userId, :postId) that describe the resource the segment identifies.
  • Validate parameters early and return 400 for malformed input, 404 for valid-but-missing resources.
  • Centralize repeated parameter validation or resource loading with app.param rather than duplicating it in each handler.
  • Register specific literal routes before parameterized ones so the dynamic pattern doesn’t swallow them.
  • Be explicit about Express version when using optional (? vs {}) or pattern ((\\d+)) syntax — the rules changed in 5.x.
Last updated June 14, 2026
Was this helpful?