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.
Search across all documentation pages
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.
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.
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:
http.Handler middleware (OTel, auth gateways)http.Handler for consumers to mounthttp.ServeMux while keeping existing handlerspackage 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:
/api/v1 routeschi.NewRouter() allocates a route tree; each Get/Post registers a method + pattern node.Use wraps 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 to sub.http.NotFound unless you set r.NotFound and r.MethodNotAllowed.| 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 |
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.
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.
r.URL.Path confuses operators. Fix: log chi.RouteContext(r).RoutePattern or include mount prefix in structured fields.Use after Mount does not retroactively wrap mounted subtrees registered earlier. Fix: register shared Use on the parent before Mount, or add middleware on each sub-router.chi.URLParam returns string; forgetting strconv produces silent 0 IDs. Fix: parse and validate before domain calls.http.ListenAndServe uses zero timeouts; chi's Timeout middleware helps but does not replace ReadHeaderTimeout on http.Server. Fix: configure http.Server timeouts explicitly./users to /users/ depending on registration; inconsistent clients may double-hit. Fix: pick one style and register both or disable redirects consciously.http.HandlerFunc.| 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 |
Yes - many production APIs use chi as a thin layer over net/http with explicit server configuration and observability middleware.
Build a router, call rr := httptest.NewRecorder(), req := httptest.NewRequest("GET", "/api/v1/users/1", nil), then r.ServeHTTP(rr, req).
Use http.FileServer wrapped with StripPrefix, or middleware.Compress plus FileServer mounted on a sub-router.
Write a small helper that sets Content-Type, status, and encodes a struct; avoid sprinkling http.Error with plain text in JSON APIs.
Use github.com/go-chi/cors middleware or your own Access-Control-* handler early in the chain.
chi does not generate specs automatically; maintain OpenAPI separately or use codegen tools that accept route tables you document.
Route and Group create sub-routers that inherit parent Use middleware registered before the group block.
Mount gateway http.Handler with r.Mount and keep gRPC on a separate listener or use cmux with clear ownership.
chi stays actively maintained, uses a fast radix tree, and idiomatically matches modern Go HTTP patterns.
middleware.RequestID sets the header; read it from context or response writer wrappers in downstream middleware.
Yes - pass the router as Handler; configure TLS on http.Server as usual.
Set r.MethodNotAllowed to a custom handler; default behavior returns 405 when path matches but verb does not.
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).
Reviewed by Chris St. John·Last updated Jul 16, 2026