The router gap in Go
You have a Go service that works. It calculates things, talks to a database, and returns JSON. Now you need to expose it over HTTP. The standard library gives you http.HandleFunc, which works for a single endpoint. Add a second endpoint, then a third, then authentication, then logging, then request ID tracking. The main function turns into a tangle of closures and repeated logic. You need a router that keeps routes organized and lets you attach behavior to groups of endpoints without rewriting the handler every time. Chi fills that gap.
Chi is a lightweight router built on top of net/http. It doesn't replace the standard library. It wraps it. The standard library router handles paths and methods. Chi adds middleware chains, route groups, and context-based parameters. It keeps the API surface small and idiomatic. You get structure without the weight of a full framework.
How Chi routes requests
A router maps HTTP methods and URL paths to Go functions. Chi does this by implementing the http.Handler interface. When a request arrives, Chi looks at the method and path, finds the matching route, and calls the associated handler.
The big addition is middleware. Middleware is a function that runs before or after your handler. You can use it for logging, authentication, or setting timeouts. Chi chains these together. A request enters the chain, passes through each middleware, reaches the handler, and the response bubbles back out.
Chi also stores route parameters in the request context. This keeps your handler signatures clean. You don't need to pass extra arguments. The context travels with the request. You extract parameters using chi.URLParam. This approach is safer than parsing the URL manually and integrates with Go's standard cancellation and deadline mechanisms.
Minimal Chi server
Here's the smallest Chi server. It creates a router, mounts one route, and starts listening. The code is nearly identical to standard library usage, but Chi gives you a router instance with more capabilities.
package main
import (
"net/http"
"github.com/go-chi/chi/v5"
)
func main() {
// chi.NewRouter returns a router that implements http.Handler
r := chi.NewRouter()
// Mount a GET handler to the root path
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
// Write the response body directly to the writer
w.Write([]byte("Hello from Chi"))
})
// Start the HTTP server on port 3000
// The router is passed as the handler
http.ListenAndServe(":3000", r)
}
Run this with go run main.go. The server starts and blocks. Hit localhost:3000 with a browser or curl. You get the response. Chi handles the routing. The handler writes to http.ResponseWriter. The server sends the response back.
The middleware chain
Middleware is where Chi shines. It lets you attach cross-cutting concerns to routes. Logging, authentication, compression, and request ID generation all fit here. Chi middleware has a specific signature: it takes an http.Handler and returns an http.Handler.
// Logger is a middleware that prints method and path
func Logger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Print request details before passing to next handler
println(r.Method, r.URL.Path)
// Call the next handler in the chain
// If you forget this, the request stops here
next.ServeHTTP(w, r)
})
}
The middleware returns a new handler. This new handler wraps the original one. When Chi dispatches a request, it calls the outermost middleware. That middleware does its work, then calls next.ServeHTTP. This passes control to the next middleware or the final handler.
If any middleware forgets to call next, the request stops. The client waits until the connection times out. The compiler won't catch this. It's a runtime logic error. Always call next. The chain must remain unbroken.
Chi provides a helper type chi.Middleware which is just an alias for the middleware signature. This makes it easier to type middleware variables. You apply middleware to the router with r.Use. This attaches the middleware to all routes registered after the call.
Realistic API setup
Real APIs need more than one route. You usually want logging on every request, authentication on protected routes, and a way to extract IDs from the URL. Chi handles this with middleware chains and route groups. Here's a realistic setup with a logging middleware, a route group, and a handler that reads a parameter and returns JSON.
package main
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
// Logger prints method and path for every request
func Logger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
println(r.Method, r.URL.Path)
next.ServeHTTP(w, r)
})
}
func main() {
r := chi.NewRouter()
// Apply Logger to all routes registered after this line
r.Use(Logger)
// Group routes under /users
// Scoped middleware can be added here with chi.WithMiddleware
r.Group(func(r chi.Router) {
// Extract user ID from the URL path
r.Get("/users/{id}", func(w http.ResponseWriter, r *http.Request) {
// chi.URLParam retrieves the param from context
id := chi.URLParam(r, "id")
// Set content type header for JSON response
w.Header().Set("Content-Type", "application/json")
// Marshal the response struct to JSON
resp, err := json.Marshal(map[string]string{"id": id})
if err != nil {
// Handle marshal error gracefully
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Write(resp)
})
})
http.ListenAndServe(":3000", r)
}
Chi stores the id parameter in the request context. chi.URLParam reads it. This keeps the handler signature standard. You don't need to pass id as an argument. The context carries it.
Route groups let you scope middleware. r.Group takes a function that receives a router. You can register routes inside the group. You can also attach middleware to the group using chi.WithMiddleware. This applies the middleware only to routes in the group. It keeps authentication or rate limiting scoped to specific endpoints.
Context and parameters
Chi uses the request context to store routing data. When you define a route like /users/{id}, Chi parses the URL and stores id in the context. Your handler retrieves it with chi.URLParam. This approach is safer than parsing the URL manually. It also integrates with Go's standard context package.
Context is the standard way to pass request-scoped values in Go. It carries deadlines, cancellation signals, and key-value pairs. Chi puts params in context. If you spawn a goroutine, pass r.Context(). The goroutine inherits deadlines and cancellation. This prevents leaks.
// Handler spawns a goroutine and passes context
func asyncHandler(w http.ResponseWriter, r *http.Request) {
// Pass context to goroutine to inherit cancellation
go func(ctx context.Context) {
// Do work here
// Check ctx.Done() to stop if request cancels
}(r.Context())
w.Write([]byte("started"))
}
The convention is to pass context.Context as the first parameter to functions that need it. Chi handlers don't take context as a parameter. They get it from the request. Use r.Context() to access it. This keeps handlers compatible with http.HandlerFunc.
Pitfalls and errors
Middleware order determines execution. If you put authentication before logging, failed auth requests won't be logged. Put logging first. If you forget to call next.ServeHTTP inside a middleware, the handler never runs. The client waits until the connection times out. The compiler won't catch this. It's a runtime logic error.
Another trap is handler signatures. Chi expects func(http.ResponseWriter, *http.Request). If you add a third parameter, the compiler rejects it with cannot use func literal as type func(http.ResponseWriter, *http.Request) in argument. You can't change the signature. Use the context for extra data.
Chi doesn't set request timeouts automatically. If a handler blocks forever, the goroutine leaks. Wrap long-running calls in a context with a deadline. Use context.WithTimeout or context.WithCancel. Chi provides middleware.Timeout to enforce deadlines.
// Middleware enforces a 5-second timeout
r.Use(middleware.Timeout(5 * time.Second))
This middleware cancels the context if the handler takes too long. The handler should check ctx.Done() and return early. This prevents goroutine leaks.
The compiler also catches import errors. If you forget to import chi, you get undefined: chi. If you import a package but don't use it, you get imported and not used. Go is strict about unused imports. Remove them or use them.
Decision matrix
Use Chi when you want a lightweight router with middleware support and clean route grouping. Use the standard library net/http when you have a simple service with few routes and no need for middleware chains. Use a heavier framework like Gin or Echo when you need built-in validation, templating, or automatic JSON binding. Use Chi with context.Context when you need to pass request-scoped data like user IDs or trace IDs to background goroutines.
Chi keeps the API surface small. You get structure without the weight of a full framework. The middleware chain is explicit. You see exactly what runs and when. The context integration is standard. You use Go's built-in tools for cancellation and deadlines.
Middleware is a chain. Break the chain and the request dies. Context is the backpack. Pack it early, unpack it late. Trust the standard library conventions. Chi extends them, it doesn't replace them.