Updated Jun 23, 2026

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.

r := chi.NewRouter()

r.Get("/articles", listArticles)
r.Post("/articles", createArticle)
r.Get("/articles/{id}", getArticle)
r.Put("/articles/{id}", updateArticle)
r.Delete("/articles/{id}", deleteArticle)

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.Method("GET", "/health", healthHandler)   // takes an http.Handler
r.MethodFunc("GET", "/ping", pingHandler)    // 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:

func getArticle(w http.ResponseWriter, r *http.Request) {
    idStr := chi.URLParam(r, "id")   // always a string, e.g. "42"
    id, err := strconv.Atoi(idStr)
    if err != nil {
        http.Error(w, "id must be a number", http.StatusBadRequest)
        return
    }
    // ... look up article #id and write it back ...
    fmt.Fprintf(w, "you asked for article %d", id)
}

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.Get("/articles/{id:[0-9]+}", getArticle)

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.Route("/articles", func(r chi.Router) {
    r.Get("/", listArticles)            // GET    /articles
    r.Post("/", createArticle)          // POST   /articles
    r.Route("/{id}", func(r chi.Router) {
        r.Get("/", getArticle)          // GET    /articles/{id}
        r.Put("/", updateArticle)       // PUT    /articles/{id}
        r.Delete("/", deleteArticle)    // DELETE /articles/{id}
    })
})

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 r inside the callback shadows the outer r on 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:

func adminRouter() chi.Router {
    r := chi.NewRouter()
    // r.Use(requireAdmin)   // its own middleware (next phase)
    r.Get("/articles", listAllArticles)
    r.Delete("/articles/{id}", forceDeleteArticle)
    return r
}

func main() {
    r := chi.NewRouter()
    r.Get("/articles", listArticles)
    r.Mount("/admin", adminRouter())   // GET /admin/articles, DELETE /admin/articles/{id}
    http.ListenAndServe(":3000", r)
}

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 Route to group routes that share a prefix in the same place; use Mount to 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:

func listArticles(w http.ResponseWriter, r *http.Request) {
    q := r.URL.Query().Get("q")        // /articles?q=go  ->  "go"
    if q == "" {
        fmt.Fprint(w, "all articles")
        return
    }
    fmt.Fprintf(w, "articles matching %q", q)
}

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.MethodFunc take the verb as a string for dynamic cases.
  • {id} in a pattern is a placeholder; read it with chi.URLParam(r, "id"), which always returns a string — convert with strconv.Atoi and validate. Constrain with regex like {id:[0-9]+} when you want routing to reject bad shapes.
  • r.Route groups routes under a shared prefix with a scoped sub-router (nest them for /articles/{id}); r.Mount attaches 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?

Was this page helpful?