chi is a router that never left net/http. Every handler is a plain http.Handler, every middleware is a plain func(http.Handler) http.Handler, and that decision is why the library survived Go 1.22 putting pattern routing in the standard library.
Go 1.22 taught http.ServeMux about methods and path wildcards, and a reasonable question followed: does anyone still need chi? The honest answer is that the routing half of chi got much less interesting and the composition half did not.
Table of contents
- What chi actually is
- What Go 1.22 took over
- The part chi still owns: composition
- Middleware without lock-in
- Choosing between them
- How this fits the rest of the stack
- FAQ
What chi actually is
chi is a routing tree built on a Patricia radix trie, wrapped in an interface that is compatible with net/http all the way down. The core package is under a thousand lines and has no external dependencies.
package main
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
func main() {
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})
r.Route("/users", func(r chi.Router) {
r.Get("/", listUsers)
r.Post("/", createUser)
r.Get("/{id}", getUser)
r.Delete("/{id}", deleteUser)
})
http.ListenAndServe(":3000", r)
}
The critical detail is the last line. r is an http.Handler. You can pass it to http.ListenAndServe, mount it inside another mux, wrap it in a test server, or hand it to any library that expects the standard interface. Nothing about chi is a framework you have to live inside.
Path parameters come out via chi.URLParam(r, "id"), which reads from the request context. There is no custom context type and no custom handler signature to learn.
What Go 1.22 took over
Before 1.22, http.ServeMux matched on path prefixes only. No methods, no wildcards. Routing a REST API meant either a third-party router or a pile of switch statements inside handlers. That is the gap chi filled, and 1.22 closed it.
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", getUser)
mux.HandleFunc("POST /users", createUser)
mux.HandleFunc("DELETE /users/{id}", deleteUser)
// Read the wildcard
id := r.PathValue("id")
For a service with a couple of dozen routes and no nesting, that is genuinely enough. No dependency, no version to track, and r.PathValue is arguably nicer than a package-level helper function.
What the standard mux does not have is a middleware chain. ServeMux gives you one handler per pattern; wrapping groups of routes in shared behaviour is left to you, which usually means either wrapping the whole mux or hand-wrapping every handler.
The part chi still owns: composition
chi’s Route, Group, Mount, and With are the reason to keep it. They let middleware apply to a subtree of routes rather than to everything or to one handler.
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
// Public
r.Get("/health", health)
r.Post("/login", login)
// Authenticated subtree - Group shares the parent path
r.Group(func(r chi.Router) {
r.Use(requireAuth)
r.Get("/me", currentUser)
r.Route("/projects", func(r chi.Router) {
r.Get("/", listProjects)
r.With(requireAdmin).Delete("/{id}", deleteProject)
})
})
Read that from the outside in and the security model is legible on one screen. The health and login routes are public, everything inside the group needs a session, and deleting a project additionally needs admin. Getting the same shape from ServeMux means either three separate muxes stitched together or a middleware that inspects the path and decides, which is exactly the switch statement routers were supposed to remove.
Mount is the other one worth knowing. It attaches a whole sub-router, including its own middleware stack, under a prefix. That is how you keep a v1 and a v2 API in the same binary without either one knowing about the other.
Middleware without lock-in
chi middleware is func(http.Handler) http.Handler. That is not a chi type. It is the shape everything in the Go HTTP ecosystem already uses, which means middleware written for chi works outside chi and vice versa.
func requireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
user, err := verify(token)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), userKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
The bundled middleware package covers the usual production set: RequestID, RealIP, Logger, Recoverer, Timeout, Compress, Throttle, and CleanPath. Recoverer and Timeout are the two that earn their place immediately, because a panic in one handler taking down the whole process is a bad day and an unbounded handler holding a connection is a slow one.
One ordering rule matters: Recoverer should sit above your logging middleware so panics are logged with the request context attached, and Timeout should sit above anything that touches a database.
Choosing between them
Use the standard ServeMux when the service is small, the route list is flat, and every route wants the same middleware. You will not miss chi, and one fewer dependency in a long-lived service is worth something.
- Fewer than about twenty routes with no nesting: standard library.
- Distinct public and authenticated subtrees: chi.
- Multiple API versions in one binary: chi, for Mount.
- A library you will hand to other teams: standard library, so consumers are not forced into your router choice.
- Existing chi codebase on 1.22 or later: leave it. Migrating routing to gain nothing is not a refactor, it is churn.
The migration cost in either direction is low precisely because both sides speak http.Handler. That is the real takeaway: pick either, and you have not painted yourself into a corner.
How this fits the rest of the stack
Whichever router you land on, the deploy story is the same: a Go binary listening on a port, reading its configuration from the environment. Push the repo, get a build log and a live route, and roll back to the previous deploy when a release goes wrong. The RunxBuild hosting calculator shows the service, the managed Postgres behind it, and the bandwidth as separate line items, so the cost of the API is a number rather than a shrug.
Useful related references:
- time.Sleep in Go: When It Is Right and What to Use Instead
- Golang While Loop: There Is Only for, and That Is the Point
- Gin in Go: A Real Router Setup, Not the README Example
- Services on RunxBuild
FAQ
Is chi still relevant after Go 1.22?
For routing alone, much less so - the standard ServeMux now handles methods and wildcards. chi stays relevant for its composition primitives: Group, Route, Mount, and With apply middleware to a subtree of routes, which the standard mux has no equivalent for. If your service has distinct public and authenticated areas, that is the deciding feature.
Is chi a framework?
No. Every chi handler is a plain http.Handler and every middleware is func(http.Handler) http.Handler. The router itself implements http.Handler, so you can mount it anywhere the standard interface is accepted. There is no custom context type, no custom handler signature, and no dependency injection container.
How do I get URL parameters in chi?
Use chi.URLParam(r, "name") where the route pattern declared a matching wildcard. It reads from the request context, so it works inside any handler that received the request unchanged. For typed parsing you still convert yourself - the value comes back as a string.
What is the difference between Group and Route in chi?
Route creates a sub-router mounted at a path prefix, so everything inside it sits under that prefix. Group creates an inline sub-router at the same path, used purely to scope middleware. Reach for Group when you want shared middleware without a shared prefix.
Does chi have external dependencies?
The core chi package depends only on the standard library. Some companion packages under the same organisation, such as render or jwtauth, do pull in dependencies, so check before adding them if your dependency budget is tight.