The request pipeline problem
You are building an HTTP service. Every endpoint needs request logging. Every API route needs authentication. The admin dashboard needs role verification. Static assets need zero overhead. Writing that logic inside every handler turns your code into a repetitive mess. You end up copying the same token validation across twenty functions. When the auth strategy changes, you hunt down every copy.
Go solves this with middleware. Middleware wraps your handlers in reusable layers. Chi extends that idea with router groups, which let you attach those layers to entire branches of your URL tree. You define the behavior once. The router applies it automatically.
How middleware actually works
Think of an HTTP request as a package moving through a sorting facility. Each station inspects the package, stamps it, or rejects it. If the package passes, it moves to the next station. If it fails, the facility returns it immediately.
In Go, every middleware is just a function that takes an http.Handler and returns an http.Handler. The returned handler runs your custom logic, then calls next.ServeHTTP to hand control to the next layer. If you skip that call, the chain stops. The request never reaches your actual business logic.
Chi groups act like department doors. You define a group, attach middleware to it, and every route registered inside inherits those layers. The router builds a tree. Requests walk down the tree, collecting middleware as they go. The http.Handler interface is intentionally simple: it only requires a ServeHTTP(http.ResponseWriter, *http.Request) method. Chi leverages that simplicity to compose complex pipelines without reflection or magic.
A minimal router with scoped groups
Here is the simplest way to split global behavior from scoped behavior. The global layer runs on every request. The group layer only runs when the path matches.
package main
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
// main sets up a router with global and scoped middleware.
func main() {
r := chi.NewRouter()
// Global layer: runs on every single request
r.Use(middleware.RequestID)
r.Use(middleware.Logger)
// Scoped group: only applies to /api/* paths
api := r.Group(func(r chi.Router) {
r.Use(AuthMiddleware) // Runs before every /api/* handler
r.Get("/users", GetUsers)
})
api.Route("/api", func(r chi.Router) {
r.Get("/users", GetUsers)
})
http.ListenAndServe(":8080", r)
}
The closure pattern is the key. Chi passes a fresh router instance into your function. You register routes and middleware on that instance. Chi then attaches the group to the parent router at the specified path. The middleware stack builds from the outside in. Go convention dictates that receiver names should be one or two letters matching the type, like (r *Router), but since Chi uses method receivers internally, you just follow the pattern it exposes. Trust the tool. Let gofmt handle indentation and spacing. Focus your mental energy on the routing logic, not whitespace debates.
Walking through the execution order
When a request hits /api/users, Chi matches the path and walks the middleware stack. The global RequestID runs first. It generates a unique identifier and stores it in the request context. Then Logger prints the method and path. Next, AuthMiddleware checks the authorization header. If the token is valid, it calls next.ServeHTTP. Finally, GetUsers runs.
If AuthMiddleware finds a missing token, it writes a 401 response and returns. next.ServeHTTP never executes. The request short-circuits. This is why middleware order matters. Authentication must run before rate limiting if you want to reject unauthorized users before they consume quota. Logging usually runs first so you capture every attempt, even failed ones.
Context propagation is the hidden mechanism here. Each middleware can attach values to r.Context(). The next layer reads them. Go convention dictates that context.Context always travels as the first parameter to any function that might need cancellation or request-scoped data. Functions that accept a context should respect deadlines and cancellation signals. If you spawn a goroutine inside a handler, pass the request context so the background work stops when the client disconnects. The context is your cancellation bus. Thread it through every boundary.
Real-world routing with auth and static files
Production services need more layers. You want panic recovery, IP forwarding, static file serving, and nested admin routes. Here is how the structure scales without becoming tangled.
package main
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
// setupRouter builds the complete routing tree.
func setupRouter() http.Handler {
r := chi.NewRouter()
// Global safety and observability layers
r.Use(middleware.Recoverer)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
// Static assets bypass all API middleware
r.Get("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir("./static"))))
// Public routes need no authentication
r.Get("/health", HealthCheck)
// API group requires valid tokens
api := r.Group(func(r chi.Router) {
r.Use(AuthMiddleware)
r.Use(RateLimiter)
r.Get("/users", GetUsers)
r.Post("/users", CreateUser)
})
api.Route("/api", func(r chi.Router) {
r.Get("/users", GetUsers)
r.Post("/users", CreateUser)
})
return r
}
Notice the separation of concerns. Static files live outside the API group. They never hit the auth or rate limit layers. The health check stays public so load balancers can probe it. The admin routes nest inside the API group, inheriting authentication while adding role checks.
Go convention favors explicit error handling. Your handlers should check every error immediately. The if err != nil { return err } pattern looks verbose, but it keeps failure paths visible. Do not swallow errors or defer them. Write the response, return early, and let the middleware chain handle logging or metrics. When you define handler functions, keep signatures clean. Accept interfaces, return structs. If a handler needs database access, pass an interface that defines the required methods rather than a concrete struct. This keeps your routing layer decoupled from storage details.
Common traps and compiler complaints
The most frequent mistake is forgetting to call next.ServeHTTP. The compiler will not catch it. Your route will simply hang or return a 404 depending on how Chi falls back. Always verify that your middleware passes control downstream.
Another trap is capturing loop variables when generating middleware dynamically. If you iterate over a slice of permissions and create a closure for each, the closure captures the loop variable by reference. In older Go versions, every closure saw the final value. Go 1.22+ changed loop variable semantics, but the compiler still warns with loop variable i captured by func literal if you rely on the old behavior. Declare a fresh variable inside the loop or pass it as a parameter to avoid subtle bugs.
Type mismatches show up quickly. If you pass a function with the wrong signature to r.Use, the compiler rejects it with cannot use handler (type func(http.ResponseWriter, *http.Request)) as http.Handler value in argument. Middleware must match the func(http.Handler) http.Handler signature exactly. You can wrap plain handlers using http.HandlerFunc(handler) to satisfy the interface.
Goroutine leaks happen when middleware spawns background work and forgets to cancel it. Always derive a new context with context.WithCancel or pass the request context directly. The worst goroutine bug is the one that never logs. If a background task blocks forever, your server will eventually run out of memory. Trust context cancellation. Run it through every long-lived call site.
When to group, when to flatten
Use global middleware when the behavior applies to every request, like panic recovery, request ID generation, or security headers. Use a router group when multiple routes share the same authentication, rate limiting, or logging requirements. Use r.Route with a path prefix when you want to attach middleware and mount the group under a specific URL segment simultaneously. Use r.Group with a closure when you need scoped middleware without changing the URL structure. Use route-level middleware when a single endpoint needs a unique wrapper, like r.Get("/export", middleware.NoCache, ExportHandler). Use plain net/http handlers when your service only has a few routes and the overhead of a third-party router adds unnecessary complexity.
Keep your routing tree shallow. Deeply nested groups make debugging harder and obscure which middleware actually runs. Name your middleware functions clearly. Document what they modify and what they expect from the context. The router is your service's nervous system. Treat it like one.