Chi Router & Middleware Composition (Go)

Web & API Engineering intermediate Building with AI ~18 min read

Overview

go-chi/chi is a lightweight, composable HTTP router for Go that builds on the standard net/http interfaces rather than replacing them. Unlike frameworks that introduce their own handler signatures and context types, chi uses http.Handler and http.HandlerFunc directly, meaning any middleware written for the standard library works with chi out of the box. This composability is chi’s defining feature: middleware chains are explicit, route groups inherit middleware from their parent, and the entire router is itself an http.Handler that can be mounted inside other routers or passed to http.ListenAndServe.

The search platform uses chi to build HTTP APIs with composable middleware chains for authentication, usage tracking, rate limiting, CORS, and request ID propagation. Each concern is implemented as a standalone middleware function that wraps the next handler in the chain. Middleware is attached at the router level (applies to all routes), the group level (applies to a subset of routes), or the individual route level, giving fine-grained control over which requests pass through which processing steps.

Chi’s approach to URL parameter extraction uses context values rather than custom handler signatures. When a route pattern includes a parameter like /users/{userID}, chi extracts the value from the URL and stores it in the request context, retrievable via chi.URLParam(r, "userID"). This keeps the handler signature identical to http.HandlerFunc, so handlers are portable between chi and any other standard-library-compatible router.

Core Concepts

Middleware as Function Composition

A chi middleware is a function with the signature func(next http.Handler) http.Handler. It receives the next handler in the chain, returns a new handler that wraps it, and can execute logic before and/or after calling next.ServeHTTP. This is function composition applied to HTTP handling: each middleware adds a layer of behavior without modifying the handlers it wraps. The chain is built by calling r.Use(middleware1, middleware2, ...), which wraps handlers in order so that middleware1 runs first.

Route Groups and Subrouters

Chi’s r.Group() creates a new inline router that inherits the parent’s middleware but can add its own. r.Route(pattern, fn) creates a subrouter mounted at the given pattern, useful for versioned API paths like /api/v1. Both mechanisms let you apply middleware to subsets of routes without repeating configuration, and they compose naturally: a group inside a route inside a group each adds its own middleware layer to the inherited chain.

Context-Based Parameter Extraction

Chi stores URL parameters in the request context using Go’s context.Context. The function chi.URLParam(r, "name") retrieves a named parameter from the context. This avoids custom handler signatures (like Gin’s *gin.Context) and keeps handlers compatible with the standard library. However, it means parameter extraction is string-based at the call site—there’s no compile-time guarantee that you’re referencing a parameter that exists in the route pattern.

Request ID Propagation

A request ID middleware generates or extracts a unique identifier for each incoming request (typically from an X-Request-ID header, falling back to a UUID) and stores it in the request context. Subsequent middleware and handlers can retrieve it to include in log entries, error responses, and outgoing service calls. This creates a correlation ID that traces a request across services, logs, and error reports without requiring a distributed tracing system.

CORS Middleware

Cross-Origin Resource Sharing middleware handles preflight OPTIONS requests and sets the appropriate Access-Control-* headers on responses. Chi includes a configurable CORS middleware that accepts allowed origins, methods, headers, and max age as parameters. It intercepts preflight requests before they reach the router, returns the CORS headers, and passes non-preflight requests through to the next handler with the response headers injected.

How It Works

Basic Router Setup with Middleware Chain

The router is initialized, global middleware is registered with r.Use(), and routes are defined. The middleware chain executes in registration order for every request that matches a route.

package main

import (
    "net/http"
    "time"

    "github.com/go-chi/chi/v5"
    "github.com/go-chi/chi/v5/middleware"
    "github.com/go-chi/cors"
)

func main() {
    r := chi.NewRouter()

    // Global middleware — applied to every request
    r.Use(middleware.RequestID)
    r.Use(middleware.RealIP)
    r.Use(middleware.Logger)
    r.Use(middleware.Recoverer)
    r.Use(middleware.Timeout(30 * time.Second))

    r.Use(cors.Handler(cors.Options{
        AllowedOrigins:   []string{"https://app.example.com"},
        AllowedMethods:   []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
        AllowedHeaders:   []string{"Accept", "Authorization", "Content-Type", "X-Request-ID"},
        ExposedHeaders:   []string{"X-Request-ID"},
        AllowCredentials: true,
        MaxAge:           300,
    }))

    // Public routes — no auth required
    r.Get("/health", healthHandler)
    r.Get("/ready", readyHandler)

    // Authenticated API routes
    r.Route("/api/v1", func(r chi.Router) {
        r.Use(AuthMiddleware)
        r.Use(UsageTrackingMiddleware)
        r.Use(RateLimitMiddleware(100, time.Minute))

        r.Get("/search", searchHandler)
        r.Post("/documents", createDocumentHandler)
        r.Route("/documents/{docID}", func(r chi.Router) {
            r.Get("/", getDocumentHandler)
            r.Put("/", updateDocumentHandler)
            r.Delete("/", deleteDocumentHandler)
        })
    })

    http.ListenAndServe(":8080", r)
}

Custom Auth Middleware

The auth middleware extracts the bearer token from the Authorization header, validates it, and stores the authenticated user in the request context. If validation fails, it short-circuits the chain by returning a 401 response without calling next.

type contextKey string

const userContextKey contextKey = "user"

type AuthUser struct {
    ID    string
    Email string
    Plan  string
}

func AuthMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        token := extractBearerToken(r)
        if token == "" {
            http.Error(w, `{"error": "missing authorization header"}`, http.StatusUnauthorized)
            return
        }

        user, err := validateToken(token)
        if err != nil {
            http.Error(w, `{"error": "invalid token"}`, http.StatusUnauthorized)
            return
        }

        ctx := context.WithValue(r.Context(), userContextKey, user)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

func UserFromContext(ctx context.Context) *AuthUser {
    user, _ := ctx.Value(userContextKey).(*AuthUser)
    return user
}

func extractBearerToken(r *http.Request) string {
    auth := r.Header.Get("Authorization")
    if len(auth) > 7 && auth[:7] == "Bearer " {
        return auth[7:]
    }
    return ""
}

Rate Limiting Middleware

This middleware uses a token bucket per user to enforce rate limits. It reads the authenticated user from context (set by the auth middleware that runs earlier in the chain), looks up or creates their bucket, and either allows the request or returns 429.

func RateLimitMiddleware(limit int, window time.Duration) func(http.Handler) http.Handler {
    buckets := &sync.Map{}

    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            user := UserFromContext(r.Context())
            if user == nil {
                http.Error(w, `{"error": "unauthenticated"}`, http.StatusUnauthorized)
                return
            }

            val, _ := buckets.LoadOrStore(user.ID, &tokenBucket{
                tokens:    limit,
                max:       limit,
                lastReset: time.Now(),
                window:    window,
            })
            bucket := val.(*tokenBucket)
            bucket.mu.Lock()
            defer bucket.mu.Unlock()

            bucket.refill()

            if bucket.tokens <= 0 {
                w.Header().Set("Retry-After", fmt.Sprintf("%d", int(bucket.nextReset().Seconds())))
                http.Error(w, `{"error": "rate limit exceeded"}`, http.StatusTooManyRequests)
                return
            }

            bucket.tokens--
            w.Header().Set("X-RateLimit-Remaining", fmt.Sprintf("%d", bucket.tokens))
            next.ServeHTTP(w, r)
        })
    }
}

Usage Tracking Middleware

Usage tracking records every API call for billing and analytics. It captures the user, endpoint, response status, and latency, then sends the record to a background channel for async persistence.

func UsageTrackingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        user := UserFromContext(r.Context())
        reqID := middleware.GetReqID(r.Context())

        ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)

        next.ServeHTTP(ww, r)

        go trackUsage(UsageRecord{
            UserID:    user.ID,
            RequestID: reqID,
            Method:    r.Method,
            Path:      r.URL.Path,
            Status:    ww.Status(),
            Latency:   time.Since(start),
            BytesSent: ww.BytesWritten(),
            Timestamp: start,
        })
    })
}

Practical Applications

The search platform uses chi’s middleware composition to build a layered security and observability stack without tangling concerns. The request flows through request ID generation, IP extraction, logging, recovery, CORS, authentication, usage tracking, and rate limiting—in that order. Each middleware is a standalone unit tested in isolation, and the composition is explicit in the router setup code rather than hidden in configuration files.

Route groups separate public endpoints (health checks, readiness probes) from authenticated API routes. The Kubernetes liveness and readiness probes hit /health and /ready without triggering auth validation or rate limiting, while every /api/v1/* request must present a valid token and is subject to per-user rate limits. This grouping is visible in a single function rather than scattered across middleware configuration.

The usage tracking middleware demonstrates the “wrap and observe” pattern. By wrapping the response writer with chi’s WrapResponseWriter, the middleware can observe the response status code and bytes written after the handler completes, without modifying the handler’s behavior. This is the same pattern used by metrics middleware in production systems to expose Prometheus counters for request rate, error rate, and latency distribution.

Implementation Patterns

Middleware Ordering Matters

Middleware executes in registration order. The request ID middleware must run before the logger so that log lines include the request ID. The auth middleware must run before rate limiting so that rate limits can be keyed by user. Recovery must run early so that panics in any subsequent middleware or handler are caught. Document the intended order in a comment at the registration site, because reordering middleware can introduce subtle bugs.

Response Writer Wrapping for Post-Processing

Chi’s middleware.NewWrapResponseWriter wraps the standard http.ResponseWriter to capture the status code and bytes written. This is essential for any middleware that needs to observe the response (logging, metrics, usage tracking) without buffering the entire response body. The wrapper intercepts WriteHeader and Write calls, records their arguments, and delegates to the underlying writer.

Subrouter Mounting for API Versioning

r.Route("/api/v1", fn) creates a subrouter that strips the prefix before matching child routes. This makes it natural to maintain multiple API versions: /api/v1 and /api/v2 are separate subrouters with potentially different middleware chains and handlers, mounted on the same parent router. Each version is self-contained and can be developed, tested, and deprecated independently.

Common Pitfalls

Further Reading