← Blog

Routing: How Servers Find the Right Handler

backend

If HTTP methods answer "what are we doing?", routing answers "to what?". Together, the method and the URL path form a unique key — the server looks it up, finds a handler, and runs it. That's the whole mechanism. But the details of how routes are structured matter a lot for API design that stays maintainable.

[Image: Request routing decision tree — method × path → handler]
The server resolves every request by matching both method and path to a registered handler.

Static Routes

The simplest kind: an exact, constant string match. /api/books always maps to the same handler. No variables, no ambiguity. A GET /api/books and a POST /api/books are entirely separate routes — the method is part of the key — so their handlers never collide.

GET  /api/books  →  listBooks()
POST /api/books  →  createBook()

Path Parameters

Dynamic routes embed variable segments directly in the path. Convention uses a colon to mark them server-side:

GET /api/users/:id

A request to /api/users/42 extracts id = "42". One thing to internalise: no matter what you put in a path param — integer, UUID, slug — the routing engine always hands it to you as a string. Parse it explicitly before using it as a number.

Path params carry semantic weight. /api/users/123 clearly reads as "the user with ID 123." That clarity is worth preserving.

Query Parameters

Query params live after the ?, as key-value pairs:

GET /api/books?page=2&limit=20&genre=fiction

They exist to solve a specific problem: GET requests don't have a request body, so complex filtering or pagination arguments have to live somewhere. Query params are that somewhere. Embedding a search term in the path itself — /api/search/somevalue — breaks REST semantics and is painful to maintain. Query params are the right tool.

[Image: URL anatomy — path param :id vs query param ?page=2 annotated side by side]
Path params identify a resource. Query params filter or modify what you get back.

Nested Routes

Nesting expresses resource relationships in the URL hierarchy:

/api/users/123           → user details
/api/users/123/posts     → all posts by user 123
/api/users/123/posts/456 → one specific post by user 123

Where you stop in the chain changes the handler and the semantic entirely. This is what makes a well-designed API readable to a developer who has never seen it before.

Route Versioning

Versioning puts a version indicator in the path:

GET /api/v1/products
GET /api/v2/products

It exists to protect existing clients when the shape of a response changes — say, renaming a field from name to title. Without versioning, that change breaks every client using the old field name. With versioning, both versions run simultaneously until the old clients migrate and v1 is safely deprecated. It's a migration window, not a permanent state.

Catch-All Routes

A catch-all (typically /*) is a fallback that captures anything that didn't match a registered route. Its job is to return a clean 404 rather than letting the server return nothing or hang. One hard rule: the catch-all goes last. If it's placed too early in the routing table, it swallows valid requests.

GET /api/users     →  listUsers()
GET /api/books     →  listBooks()
GET /*             →  notFound()   ← must be last

Routes are the surface area of your API. Getting the structure right — clear path params, query params for filtering, nested routes for relationships, versioning before breaking changes — pays dividends every time someone has to read or extend the code later. In the next post, I'll look at how Go's standard library handles routing and where third-party routers like Chi add value.