Middleware the Standard Way
Here's the thing most tutorials bury: chi doesn't have a middleware system. It has the
net/http middleware pattern, and a couple of helpers for plugging it in. That's the whole
story. If you've ever seen middleware in a Go codebase that uses no framework at all, you've
already seen chi middleware — same signature, same idea.
So before we touch r.Use, let's get the mental model rock-solid, because once it clicks the
rest is mechanical.
The mental model: a function that wraps a handler
📝 Middleware is a function that takes one http.Handler and returns a new http.Handler.
The new handler does some work, then calls the original. That's it. The type, written out, is:
func http.Handler
Read that slowly. You receive next — the handler that would have run. You hand back a
different handler. Inside that new handler, you decide when (or whether) to call
next.ServeHTTP(w, r). Everything before that call happens on the way in; everything after
happens on the way out. You're wrapping a present in a slightly bigger box.
This is the exact pattern from the net/http roots guide —
chi invented none of it. A chi router is an http.Handler, a chi handler is an
http.HandlerFunc, and chi middleware is a plain net/http wrapper. The payoff at the end of
this phase: any middleware written for stdlib works with chi unchanged.
Picture the request falling through layers and climbing back out:
flowchart LR
A[Request] --> B[Logger: start timer]
B --> C[Auth: check token]
C --> D[Your handler]
D --> E[Auth: returns]
E --> F[Logger: log duration]
F --> G[Response]
What just happened: the request travels down through each middleware to your handler, then the call stack unwinds back up through them. The Logger that started a timer on the way in is the one that prints the duration on the way out, because it's the outermost wrapper.
Writing one
Let's write the classic: a request logger. We want to time how long each request takes and print the method, path, and duration.
package main
.Handler
What just happened: Logger takes next and returns a brand-new http.HandlerFunc. Inside,
we record start, then call next.ServeHTTP(w, r) to run the rest of the chain (other
middleware, then the actual route handler). Only after next returns do we log — so
time.Since(start) captures the full request duration. The line before next.ServeHTTP
runs on the way in; the line after runs on the way out.
⚠️ If you forget to call next.ServeHTTP(w, r), the request stops dead at your middleware — the
handler never runs and the client gets an empty response. Sometimes that's intentional (an auth
middleware rejecting a request writes a 401 and deliberately doesn't call next). But an
accidental missing call is one of the most common middleware bugs. If a route mysteriously
returns nothing, check that every middleware in the chain actually calls next.
Registering: Use, With, and sub-routers
Writing the function is half the job. Now you tell chi where to apply it. Three ways:
r.Use — for everything below it
What just happened: r.Use(Logger) adds Logger to this router's stack. Every route
registered after the Use call — both /articles routes here — runs through Logger
first. You can call Use multiple times; they stack in order, the first Use being the
outermost layer.
⚠️ Order matters, and Use must come before the routes it should wrap. chi panics at
startup if you call Use after you've already registered routes on the same router. This is a
feature, not an annoyance: it stops you from silently shipping middleware that doesn't run.
Group your Use calls at the top of each router.
r.With — for one route (or a few)
Sometimes you want middleware on a single endpoint, not the whole router. With returns a
temporary router carrying that middleware, and you chain a route off it:
r
What just happened: RequireAuth wraps only the POST /articles route. The GET routes
above are untouched. With is inline and doesn't mutate the parent router — perfect for
"this one mutating endpoint needs auth, the reads don't."
Sub-routers — middleware scoped to a group
From the previous phase you know Route and Mount create sub-routers. Middleware applied
inside a sub-router only affects that group:
r
What just happened: RequireAuth is registered on the admin sub-router, so it guards
/admin/stats and /admin/articles/{id} but nothing else. This is how you carve out a
protected section without sprinkling With on every line. The public article routes outside
this block stay open.
chi's built-in middleware
You don't have to write the common ones — chi ships a battle-tested set in
github.com/go-chi/chi/v5/middleware. The greatest hits:
What just happened: five lines buy you request IDs, real client IPs behind a proxy, request
logging, panic recovery, and a timeout. middleware.Recoverer is the one you'll be most
grateful for in production — without it, a single nil-pointer panic in a handler takes down the
whole server; with it, that request gets a clean 500 and the server keeps serving everyone else.
💡 Order is deliberate here too: put RequestID and RealIP near the top so the ID and IP are
available to everything below (including Logger). Recoverer should sit high enough to catch
panics from your handlers, but it's commonly placed right after the logging setup. There's also
middleware.AllowContentType(...), middleware.StripSlashes, and many more — skim the package
when you have a real need.
Passing data down with context
Middleware often computes something — the authenticated user, a request-scoped DB transaction —
that the handler needs. You don't reach for a global or a framework-specific bag. You use the
standard library's context, carried on the request itself.
The pattern: in the middleware, attach a value with context.WithValue, then call next with a
copy of the request that carries the new context via r.WithContext. The handler reads it back
with r.Context().Value(...).
const userKey contextKey = "user"
.Handler
What just happened: RequireAuth checks the Authorization header. No token? It writes a 401
and returns — deliberately skipping next, so the handler never runs. With a token, it stashes
the user on the request context and passes a request carrying that context to next. Downstream,
createArticle pulls the user back out. The two functions never call each other directly; the
context is the courier.
📝 A small but real detail: userKey is a custom contextKey type, not a bare string. Context
keys should be an unexported custom type so two packages can't accidentally collide on the same
string key. We'll go deeper on context values and the cleaner "typed getter" pattern in Phase 6 —
this is the minimum to make middleware-to-handler data flow work.
💡 Because all of this is plain net/http — the wrapper signature, the context, r.WithContext —
any middleware written for the standard library drops into chi with zero changes. Need CORS? Grab
github.com/go-chi/cors or any stdlib-compatible CORS package and r.Use it like anything else.
That compatibility is chi's entire pitch, and middleware is where you feel it most.
Recap
- Middleware is a plain net/http
func(next http.Handler) http.Handlerthat wrapsnextand decides when to callnext.ServeHTTP(w, r). chi adds no special type. - Work before
next.ServeHTTPruns on the way in; work after runs on the way out. Skippingnext(e.g. an auth rejection) stops the chain. r.Useapplies middleware to all routes registered after it;r.With(mw)scopes it to one route; sub-routerUsescopes it to that group. ⚠️Usemust come before routes or chi panics.- chi's built-ins (
Logger,Recoverer,RequestID,RealIP,Timeout) cover the essentials —Recovererespecially keeps a panicking handler from taking down the server. - Pass request-scoped data with
context.WithValue+r.WithContext, read it viar.Context().Value(...). Any stdlib-compatible middleware works with chi unchanged.
Quick check
[
{
"q": "What is the type signature of chi middleware?",
"choices": ["func(w http.ResponseWriter, r *http.Request)", "func(next http.Handler) http.Handler", "chi.Middleware interface", "func(c *chi.Context)"],
"answer": 1,
"explain": "chi uses the standard net/http pattern: a function that takes the next handler and returns a new wrapping handler. No chi-specific type is involved."
},
{
"q": "What happens if you call r.Use after registering routes on the same router?",
"choices": ["The middleware silently doesn't run", "chi panics at startup", "It applies to all routes anyway", "It only applies to the next route added"],
"answer": 1,
"explain": "chi panics if Use is called after routes on the same router. This protects you from shipping middleware that wouldn't wrap those routes. Declare Use before the routes it should cover."
},
{
"q": "How does a middleware pass a computed value (like the authenticated user) down to the handler?",
"choices": ["A global variable", "chi.SetValue(r, key, val)", "context.WithValue plus next.ServeHTTP(w, r.WithContext(ctx))", "Adding a field to the http.ResponseWriter"],
"answer": 2,
"explain": "You attach the value to the request context with context.WithValue, then pass a request carrying that context via r.WithContext. The handler reads it with r.Context().Value(key)."
}
]
← Phase 2: Routing, URL Params & Sub-routers · Guide overview · Phase 4: Requests & Responses with the Standard Library →
Check your understanding
1. What is the type signature of chi middleware?
2. What happens if you call r.Use after registering routes on the same router?
3. How does a middleware pass a computed value (like the authenticated user) down to the handler?