Routing, URL Params & Sub-routers
Here's the whole mental model, and once it clicks the rest of chi is detail: a chi router is a
lookup table. Each entry is a method + a URL pattern on the left, and a plain
http.HandlerFunc on the right. When a request arrives, chi reads its method (GET, POST, …)
and its path (/articles/42), finds the matching entry, and calls that function. That's it. No
magic context object, no special handler signature — the right-hand side is the exact same
func(w http.ResponseWriter, r *http.Request) you'd write for the standard library.
The one thing chi adds on top of a flat lookup table is placeholders. A pattern like
/articles/{id} matches /articles/42 and /articles/hello alike, and stashes whatever was in
the {id} slot so your handler can read it back. And because real apps have dozens of routes,
chi lets you group related ones under a shared prefix instead of repeating yourself — that's
what sub-routers are for.
📝 We're growing one example through the whole guide: a small articles API. An article is just
Article{id, title, body}. By the end of this phase you'll have all the URLs that API needs — listing, creating, fetching one, updating, deleting — wired up cleanly.
Methods: one function per verb
chi gives you a method on the router for each HTTP verb. The pattern is always the same: path first, handler second.
chi
r
r
r
r
r
What just happened: we registered five routes. Notice that /articles and /articles/{id}
are different patterns, and that the same path (/articles/{id}) can carry different handlers
depending on the verb — GET reads an article, PUT replaces it, DELETE removes it. chi
matches on method and path together, so there's no collision.
Beyond these named helpers (Get, Post, Put, Patch, Delete, Head, Options), there
are two escape hatches for when the verb is dynamic or unusual:
r // takes an http.Handler
r // takes an http.HandlerFunc
What just happened: r.Method and r.MethodFunc do exactly what r.Get does, except you pass
the verb as a string. Reach for these only when you genuinely need a verb as data — for everyday
routes, the named helpers read better.
URL params: reading {id} back out
The handlers above use /articles/{id}, but how does getArticle find out which id? With
chi.URLParam:
What just happened: chi.URLParam(r, "id") pulls the value that filled the {id} slot in the
pattern. The name you pass ("id") must match the name inside the braces. The big gotcha worth
burning into memory: it always returns a string. A request to /articles/42 gives you "42",
not 42. If you need a number, you convert it yourself with strconv.Atoi — and you check the
error, because nothing stops someone from requesting /articles/banana.
⚠️ A common early bug: comparing
chi.URLParam(r, "id")directly to an integer, or forgetting the conversion can fail. Treat the param as untrusted user input — convert and validate before you use it.
If you'd rather reject non-numeric ids at the routing layer instead of inside the handler, chi lets you constrain a placeholder with a regular expression:
r
What just happened: the :[0-9]+ part says "only match if this segment is one or more digits."
Now /articles/42 reaches getArticle, but /articles/banana doesn't match this route at all
and falls through to a 404. You still read the value with chi.URLParam(r, "id") (the regex part
isn't included in the name). Handy, but don't overdo it — for anything beyond simple shapes,
validating inside the handler is usually clearer.
💡 Query strings (
/articles?q=go) are not URL params and chi doesn't wrap them. They come from the standard library:r.URL.Query().Get("q"). More on that at the end of this phase.
Sub-routers: grouping routes with Route
Writing /articles and /articles/{id} over and over gets noisy, and it scatters related routes
across your file. r.Route fixes both. It carves out a prefix and gives you a fresh router scoped
to it, so every route you register inside is relative to that prefix:
r
What just happened: this registers the exact same five routes as our flat list earlier, but now
they're visually grouped by resource. Inside r.Route("/articles", ...), the path "/" means
"the prefix itself" (/articles), and the nested r.Route("/{id}", ...) stacks another segment
on top, so "/" inside it means /articles/{id}. The id param is still read the same way:
chi.URLParam(r, "id"). This nesting is the idiomatic chi way to organize a resource — all the
"things you can do to articles" live in one block.
📝 The
rinside the callback shadows the outerron purpose. It's a new sub-router whose routes are automatically prefixed. Reusing the name keeps the calls looking identical at every level —r.Get,r.Post, and so on — which is exactly the point.
Mount: bolting a whole router onto a path
Route is for grouping routes inline. Mount is for attaching an entire pre-built router —
with its own routes and its own middleware — at a path. This is how you compose an app out of
self-contained modules:
.Router
What just happened: adminRouter() builds a complete, independent router. r.Mount("/admin", …)
hangs it off the /admin prefix, so its /articles route becomes /admin/articles. The admin
router can declare its own middleware that applies only to its routes, and main doesn't need to
know any of its internals — it just knows there's an admin module living under /admin. As an app
grows, this lets each feature own a file and a router, and main stays a short list of mounts.
💡 Rule of thumb: use
Routeto group routes that share a prefix in the same place; useMountto plug in a router that was built somewhere else (often in its own package or file).
Query params come from the standard library
One last thing, because people expect chi to have a helper for it and it doesn't — on purpose.
Query string values aren't part of the route, so chi leaves them to net/http:
What just happened: r.URL.Query() parses the query string into a map-like value, and .Get("q")
reads one key (returning "" if it's absent). This is plain standard library — the same code works
without chi at all. It's a perfect little illustration of chi's whole philosophy: it adds the router
and the URL params the stdlib lacked, and for everything else it gets out of your way.
Recap
- A chi router is a lookup table mapping method + pattern to a plain
http.HandlerFunc— no special handler signature, no magic context. - Use the named verb helpers (
r.Get,r.Post,r.Put,r.Delete, …);r.Method/r.MethodFunctake the verb as a string for dynamic cases. {id}in a pattern is a placeholder; read it withchi.URLParam(r, "id"), which always returns a string — convert withstrconv.Atoiand validate. Constrain with regex like{id:[0-9]+}when you want routing to reject bad shapes.r.Routegroups routes under a shared prefix with a scoped sub-router (nest them for/articles/{id});r.Mountattaches a whole pre-built router (with its own middleware) at a path.- Query params aren't routing — read them with the standard library:
r.URL.Query().Get("q").
Quick check
Test the mental model before moving on.
[
{
"q": "A request hits GET /articles/42 on a route registered as \"/articles/{id}\". What does chi.URLParam(r, \"id\") return?",
"choices": ["The integer 42", "The string \"42\"", "An error, because id isn't numeric", "nil until you call strconv.Atoi"],
"answer": 1,
"explain": "chi.URLParam always returns a string. You convert it yourself (e.g. strconv.Atoi) when you need a number."
},
{
"q": "You built a complete, self-contained adminRouter() in its own file and want to attach it under /admin. Which call do you use?",
"choices": ["r.Route(\"/admin\", adminRouter)", "r.Mount(\"/admin\", adminRouter())", "r.Get(\"/admin\", adminRouter())", "r.Group(adminRouter())"],
"answer": 1,
"explain": "r.Mount attaches an entire pre-built router (with its own routes and middleware) at a path. r.Route is for grouping routes inline."
},
{
"q": "How do you read the value of q in a request to /articles?q=go?",
"choices": ["chi.URLParam(r, \"q\")", "r.URL.Query().Get(\"q\")", "chi.QueryParam(r, \"q\")", "r.FormParam(\"q\")"],
"answer": 1,
"explain": "Query strings aren't route params, so chi doesn't wrap them. You use the standard library: r.URL.Query().Get(\"q\")."
}
]
← Phase 1: What chi Is · Guide overview · Phase 3: Middleware the Standard Way →
Check your understanding
1. A request hits GET /articles/42 on a route registered as "/articles/{id}". What does chi.URLParam(r, "id") return?
2. You built a complete, self-contained adminRouter() in its own file and want to attach it under /admin. Which call do you use?
3. How do you read the value of q in a request to /articles?q=go?