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.
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.
:id vs query param ?page=2 annotated side by side]
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.