← back to the desk

Routing, from method and path to handler

2 September 2026 · 4 min read · #backend #routing #api

written from this video


A method expresses the what of a request. GET means fetch, POST means create, DELETE means remove. It says nothing about the subject. Routing supplies the where: which resource that intent lands on.

Neither half identifies a handler on its own. The server concatenates them:

GET  /api/books  ->  listBooks
POST /api/books  ->  createBook

Same path, two entries in the table, no ambiguity. That is the entire mechanism — routing is mapping a (method, path) pair to server-side logic. Everything below is a variation on how the path half of that key is written.

Static routes

/api/books is a static route. Nothing in it varies, it matches one string exactly, and it always addresses the same collection. Most APIs are mostly static routes and that's fine.

Dynamic routes

Sooner or later you need one book, not all of them. The path grows a slot:

GET /api/users/:id  ->  getUser

A request to /api/users/123 matches. The router extracts 123, hands it to the handler as id, and the handler goes to the database. The colon prefix is convention, not spec — Express, Gin, Spring, FastAPI, Axum all use some variant of it, and you'll recognise it in any codebase you open.

One detail that bites people: 123 arrives as the string "123". Path segments are always strings. If your handler does SELECT * FROM users WHERE id = $1 against an integer column and your driver isn't coercing, that's your bug. Parse it, and parse it at the edge of the handler so the rest of your code gets a real integer.

The segment is called a path parameter or route parameter, because it sits directly in the path between slashes. This matters because there's a second kind of parameter and the names get mixed up constantly.

Query parameters

GET requests have no body. So when the client needs to send data that isn't the identity of a resource — a search term, a page number, a sort order — it goes after a ?:

GET /api/search?query=some+value
GET /api/books?page=2&limit=20&sort=title&order=asc

You could stuff a search term into the path: /api/search/some+value. It works. It's also wrong, and the reason is semantic rather than technical. A path parameter names a resource — /api/users/123 reads as "the user whose id is 123". A search term isn't a resource, it's a modifier on a request. Filtering, sorting, pagination and free text all belong in the query string; identity belongs in the path.

The test I use: if changing the value points at a different thing, it's a path parameter. If it changes how the same thing is presented, it's a query parameter.

Pagination is the clearest case. Call /api/books and the server picks a default limit and returns the first slice with some metadata:

{
  "data": [ /* 20 books */ ],
  "meta": { "total": 100, "page": 1, "limit": 20, "totalPages": 5 }
}

The client reads that metadata and asks for /api/books?page=2. The resource never changed. The window over it did.

Nested routes

Nesting isn't a distinct routing type. It's what happens when you express containment in the path, and each depth is a separate, legitimate route with its own handler:

GET /api/users                   -> all users
GET /api/users/123               -> one user
GET /api/users/123/posts         -> that user's posts
GET /api/users/123/posts/456     -> one post by that user

Read the last one out loud and it says what it does. That readability is the point of REST — the URL is a sentence, not an opaque token. The cost is that four routes means four handlers and four authorization checks, and it's easy to nest a level deeper than anyone needs. Two levels of nesting covers almost everything. Past that, consider whether the child is really a resource in its own right — /api/posts/456 may be the better route, with the user id as a query filter.

Versioning

Put a version in the path from day one:

GET /api/v1/products  ->  { "id": 1, "name": "...",  "price": 9.99 }
GET /api/v2/products  ->  { "id": 1, "title": "...", "price": 9.99 }

name became title. That's a breaking change, and without versioning your options are to break every existing client at once or to invent /api/new-products, which is a version number wearing a disguise.

With a version segment you run both. Ship v2, tell the client teams v1 is deprecated, give them a migration window with a real date, then delete v1. The mechanism isn't clever — it's a prefix on a string — but it converts "we can't change the response shape" into a scheduling problem. Adding v1 to a URL costs nothing today; retrofitting it after five teams have hardcoded your endpoints costs a quarter.

The catch-all

Registered routes are checked in order. Anything that matches nothing falls through, and the default behaviour of falling through is usually an empty response or a bare framework error page. Neither tells the caller what went wrong.

Register a catch-all last:

ALL /*  ->  404 { "error": "Route not found", "path": "/api/v3/products" }

It must be last, because it matches everything — put it above your real routes and it swallows them. Echoing the path back is worth the three extra characters; half the time the caller has a typo or a stale base URL and the response tells them so immediately.

That's the whole surface. A table keyed on method plus path, some slots in the path for identity, a query string for everything else, a version prefix so you can change your mind later, and a handler at the bottom that admits when nothing matched.