Skip to content

Routing: How a Request Finds Its Code

How a backend matches an HTTP method and URL to a handler, including path parameters, query parameters, nesting, versioning, and fallbacks.

Stage 5intro5 min read

When someone types a URL into a browser, the backend does not receive an instruction like “run getProject.” It receives an HTTP method, a path, headers, and maybe a body. Routing is the small but important piece that decides which code should get that request.

Think of a hotel reception desk. The guest says both what they need and which room it concerns. “Bring towels to room 42” is different from “check out room 42.” In HTTP, the method is the verb and the path identifies the thing being discussed. GET /projects/42 and DELETE /projects/42 therefore go to different handlers.

Loading diagram…

A route is a match, not just a URL

A route normally has three parts:

HTTP method + path pattern + handler

GET  /projects/:projectId  -> getProject
POST /projects             -> createProject

The router checks routes in the order the framework defines, finds the best match, extracts any parameters, and hands control on. Frameworks differ in syntax, but the idea is unchanged:

app.get("/projects/:projectId", async (req, res) => {
  const project = await projectService.get(req.params.projectId);
  res.json(project);
});

The handler should not need to know how the URL was matched. It receives the values it needs and focuses on the request.

Static paths, path parameters, and query parameters

These look similar in a browser, but they mean different things.

Static paths name a fixed place

Use a static path when there is no variable part:

GET /health
GET /projects
POST /sessions

/health is one known endpoint. It is often used by a load balancer to ask, “is this server alive?”

Path parameters identify one resource

Put the identity of the main thing in the path:

GET /projects/42
PATCH /projects/42
GET /projects/42/tasks/7

In the route definition, the changing part gets a name:

/projects/:projectId/tasks/:taskId

Treat path parameters as untrusted strings. /projects/0042 and /projects/not-a-number both arrive as text. Parse and validate them before a database query. That one boring step prevents surprising errors and makes a bad request a clean 400, rather than an accidental 500.

Query parameters refine a collection

Query parameters are best for optional controls: filters, sorting, pagination, and an explicit choice of representation.

GET /projects?owner=me&status=active&sort=-updatedAt&page=2&limit=20

This still asks for the projects collection; it merely narrows it. A useful rule of thumb is:

  • use the path for what is being addressed;
  • use the query string for how the result should be selected or shaped.

Do not use a query parameter to hide an action, such as POST /users?action=delete. HTTP already has a method and a clear path for that.

Nested routes: useful context, not a family tree

Nested routes communicate a real parent-child relationship:

GET /projects/42/tasks
POST /projects/42/tasks
GET /projects/42/tasks/7

Here the project is useful context: it makes it clear that task 7 is being viewed within project 42, and it lets the server check that they agree. But nesting can become a maze:

/organisations/9/teams/3/projects/42/tasks/7/comments

Once a child has a stable identity, a flatter endpoint is often kinder to clients:

GET /tasks/7
GET /comments?taskId=7

Use nesting when the parent changes the meaning or permission check. Do not nest solely because the database has foreign keys.

Order and conflicts matter

Suppose an application has both of these routes:

GET /projects/new
GET /projects/:projectId

If the parameterised route wins first, new may be treated as a project id. Most routers have rules to resolve this, but it is still worth testing the awkward paths: new, search, settings, and malformed ids.

Similarly, middleware that authenticates /projects/* needs to be deliberately placed. A public GET /projects route and a private POST /projects route should be clear in the route table, not discovered by accident in production.

Versioning is a promise-management tool

Clients may be mobile apps that users do not update for months. If a change breaks the contract, the server needs a migration path.

One straightforward approach is path versioning:

GET /api/v1/projects/42
GET /api/v2/projects/42

Versioning is not a substitute for care. Adding an optional response field usually does not need a new version; renaming ownerId to createdBy probably does. When a new version is needed, keep the old one alive for a stated period, log its use, and tell clients what to change. “Deprecated” without a date is just a warning nobody can plan around.

The route that matches nothing

Every API should have a final fallback that returns a consistent 404 Not Found response. It is often the first clue a frontend developer gets that they misspelled a path or called the wrong version.

{
  "error": {
    "code": "ROUTE_NOT_FOUND",
    "message": "No route matches GET /project/42"
  }
}

Avoid exposing framework internals or the full list of private endpoints in that response. The message should help a legitimate caller without becoming a map for an attacker.

A small route-design checklist

Before adding a route, pause for thirty seconds:

  1. Is the HTTP method honest about what happens?
  2. Is the path a noun or resource, rather than a verb hidden in the URL?
  3. Which values are identifiers, and which are optional filters?
  4. What should a malformed id, absent resource, or unauthenticated caller receive?
  5. Will a new client understand this route without a meeting?

Routing is plumbing, but good plumbing makes the rest of the house calmer. A clean route map gives validation, authorization, observability, and documentation a predictable place to start.

Walk one request through the map

Suppose the project board opens and asks for open tasks, newest first:

GET /projects/42/tasks?status=open&sort=-createdAt&limit=25
Authorization: Bearer <access token>

The router matches GET /projects/:projectId/tasks. It extracts projectId = "42"; the query parser receives three optional controls. Authentication can then establish who is asking, validation can reject an invalid limit, and the service can decide whether that person belongs to project 42. Routing has not done any of those jobs—it has merely sent the request to the right front door.

That separation is useful when something goes wrong. A request for /projects/42/task is a route problem and deserves 404. A request for /projects/nope/tasks that matches the route but contains a malformed id is a request problem and deserves 400. A valid project id that does not exist is a resource problem and normally deserves 404. Being precise here makes client bugs much easier to diagnose.

One final detail: URLs can be logged, bookmarked, and copied into bug reports. Never place passwords, access tokens, or other secrets in a path or query parameter. Use headers or a request body for those values.

In this topic

Done with this topic?

How well do you know it?