chi: Lightweight Routing & Middleware
chi adds method-aware routing, URL parameters, and middleware composition on top of net/http without introducing a custom handler type.
Handlers stay func(http.ResponseWriter, *http.Request), so chi fits teams that want router ergonomics with stdlib portability.
Summary
chi builds a radix tree of routes at registration time and dispatches incoming requests through a middleware stack before your handler runs.
Route groups (Route, Group, Mount) let you scope middleware and prefixes to subtrees.
Because chi.Router implements http.Handler, you drop it into http.Server, integration tests, and reverse proxies the same way you would a plain ServeMux.
Recipe
Quick-reference recipe card - copy-paste ready.
r := chi.NewRouter()
r.Use(middleware.RequestID, middleware.Recoverer)
r.Route("/api", func(r chi.Router) {
r.Get("/users/{id}", getUser)
})
http.ListenAndServe(":8080", r)When to reach for this:
- REST APIs that need grouped routes and middleware without framework lock-in
- Services that must compose with stdlib
http.Handlermiddleware (OTel, auth gateways) - Libraries exposing an
http.Handlerfor consumers to mount - Incremental migration off
http.ServeMuxwhile keeping existing handlers
Working Example
package main
import (
"encoding/json"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
type user struct {
ID int `json:"id"`
Name string `json:"name"`
}
var users = map[int]user{1: {ID: 1, Name: "Ada"}}
func getUser(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
u, ok := users[id]
if !ok {
http.Error(w, "not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(u)
}
func main() {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(30 * time.Second))
r.Route("/api/v1", func(r chi.Router) {
r.Get("/users/{id}", getUser)
r.Post("/users", func(w http.ResponseWriter, r *http.Request) {
var in user
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
users[in.ID] = in
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(in)
})
})
srv := &http.Server{Addr: ":8080", Handler: r}
srv.ListenAndServe()
}What this demonstrates:
- Middleware stack applied to all
/api/v1routes - URL param parsing with validation before domain lookup
- JSON read/write without a framework context wrapper
- Production-oriented timeouts and panic recovery from chi middleware
Deep Dive
How It Works
chi.NewRouter()allocates a route tree; eachGet/Postregisters a method + pattern node.Usewraps the current router's handler chain; sub-routers inherit parent middleware unless registered before a branch.Mount("/prefix", sub)strips the prefix and delegates matching tosub.- Unmatched requests fall through to
http.NotFoundunless you setr.NotFoundandr.MethodNotAllowed.
Route Patterns at a Glance
| Pattern | Matches | Param access |
|---|---|---|
/users/{id} | /users/42 | chi.URLParam(r, "id") |
/files/{path:*} | /files/a/b/c | greedy rest segment |
/health | exact path | none |
Middleware Ordering
Outer middleware runs first on the way in and last on the way out.
Register logging and recovery early; place auth after logging so rejected requests still leave an audit trail.
Timeout middleware should sit outside handlers that may block.
Go Notes
import "context"
// Custom middleware: attach a value to context, stdlib style
func withTenant(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tenant := r.Header.Get("X-Tenant")
type tenantKeyType struct{}
ctx := context.WithValue(r.Context(), tenantKeyType{}, tenant)
next.ServeHTTP(w, r.WithContext(ctx))
})
}Prefer typed context keys (custom int type) over bare strings to avoid collisions.
Gotchas
- Mount prefix stripping - Handlers on mounted routers see paths without the mount prefix; logging the raw
r.URL.Pathconfuses operators. Fix: logchi.RouteContext(r).RoutePatternor include mount prefix in structured fields. - Middleware on wrong router - Calling
UseafterMountdoes not retroactively wrap mounted subtrees registered earlier. Fix: register sharedUseon the parent beforeMount, or add middleware on each sub-router. - Param always string -
chi.URLParamreturnsstring; forgettingstrconvproduces silent0IDs. Fix: parse and validate before domain calls. - Default Server timeouts -
http.ListenAndServeuses zero timeouts; chi'sTimeoutmiddleware helps but does not replaceReadHeaderTimeoutonhttp.Server. Fix: configurehttp.Servertimeouts explicitly. - Trailing slash redirects - chi can redirect
/usersto/users/depending on registration; inconsistent clients may double-hit. Fix: pick one style and register both or disable redirects consciously. - Global state in handlers - Examples use maps for brevity; production code needs injected stores. Fix: close over dependencies via handler constructors returning
http.HandlerFunc.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
stdlib ServeMux (1.22+) | Minimal deps, simple patterns | You need route groups and rich middleware stacks |
| gorilla/mux | Regex/host/header matchers | You want active feature velocity and radix performance |
| Gin/Echo | Heavy JSON binding and framework middleware | You require pure http.Handler signatures everywhere |
httprouter directly | Maximum raw speed, tiny API | You want bundled middleware and route groups |
FAQs
Is chi production-ready?
Yes - many production APIs use chi as a thin layer over net/http with explicit server configuration and observability middleware.
How do I test a chi handler without ListenAndServe?
Build a router, call rr := httptest.NewRecorder(), req := httptest.NewRequest("GET", "/api/v1/users/1", nil), then r.ServeHTTP(rr, req).
Can chi serve static files?
Use http.FileServer wrapped with StripPrefix, or middleware.Compress plus FileServer mounted on a sub-router.
How do I return JSON errors consistently?
Write a small helper that sets Content-Type, status, and encodes a struct; avoid sprinkling http.Error with plain text in JSON APIs.
Does chi support CORS?
Use github.com/go-chi/cors middleware or your own Access-Control-* handler early in the chain.
What about OpenAPI generation?
chi does not generate specs automatically; maintain OpenAPI separately or use codegen tools that accept route tables you document.
How do route groups interact with middleware?
Route and Group create sub-routers that inherit parent Use middleware registered before the group block.
Can I mix chi and grpc-gateway on one port?
Mount gateway http.Handler with r.Mount and keep gRPC on a separate listener or use cmux with clear ownership.
Why choose chi over gorilla/mux?
chi stays actively maintained, uses a fast radix tree, and idiomatically matches modern Go HTTP patterns.
How do I propagate request IDs to logs?
middleware.RequestID sets the header; read it from context or response writer wrappers in downstream middleware.
Does chi work with `http.Server` TLS and HTTP/2?
Yes - pass the router as Handler; configure TLS on http.Server as usual.
How do I handle 405 Method Not Allowed?
Set r.MethodNotAllowed to a custom handler; default behavior returns 405 when path matches but verb does not.
Related
- Web Frameworks Basics - chi hello routes and groups
- Go HTTP Frameworks: Routers vs Full Frameworks - router positioning
- gorilla/mux: Regex Routes & Host Matching - when mux patterns win
- Middleware & Decorator Patterns - middleware composition
- Middleware Chains & Request Context - context in middleware
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).