Middleware Is Just a Wrapper
Here's the secret that takes the word "middleware" from intimidating to boring: in net/http,
middleware is a handler that wraps another handler. That's the whole thing. There is no special
middleware type, no registration system, no framework magic. It's a function that takes an
http.Handler, holds onto it in a closure, and hands you back a new http.Handler that does
something extra before or after calling the one you gave it.
The mental model, in one line: middleware is func(next http.Handler) http.Handler — a function
that takes the "next" handler and returns a wrapped version of it. The wrapper runs your code, then
calls next.ServeHTTP(w, r) to let the real work happen, then optionally runs more code on the way
back out. It's an onion: each layer wraps the one inside it, the request travels inward through every
layer, and the response travels back outward through them in reverse.
📝 You already know everything you need for this. A
Handleris anything withServeHTTP(w, r)(Phase 1), and a closure is a function that remembers a variable from where it was created (Go From Zero). Middleware is just those two ideas shaken together. If "closure overnext" feels fuzzy, that's the only new thing here — everything else you've seen.
The shape of every middleware
Let's build the canonical example: logging. We want to print the method, path, and how long each request took. Watch the shape carefully, because every middleware you ever write looks like this.
.Handler
What just happened: Logging takes one handler (next) and returns a brand-new one built with
http.HandlerFunc. Inside that new handler, we record the start time, call next.ServeHTTP(w, r) to
run whatever was wrapped (the mux, another middleware, your actual route handler — we don't care
which), and then, after it returns, we log how long it took. The returned function is a closure: it
"remembers" next and start even though Logging has long since returned. Code before
next.ServeHTTP runs on the way in; code after it runs on the way out. That before/after split is
the entire vocabulary of middleware — auth checks go before, timing and cleanup go after.
Applying it: wrap the mux
A handler that wraps a handler is useless until you actually wrap something. Your router (the
ServeMux from Phase 2) is an http.Handler — so you wrap it, and hand the wrapped result to the
server instead of the bare mux.
http
mux
h = // now h is "logging, then the mux"
http
What just happened: We declared h as an http.Handler and started it as the mux. Then
h = Logging(h) replaced it with the logging wrapper, which still has the mux tucked inside it as
next. When a request arrives, the server calls h.ServeHTTP — that's the logging layer, which logs
and then calls the mux, which routes to listMessages. We pass h, not mux, to ListenAndServe.
The mux didn't change at all; we just put a coat on it.
Chaining: wrappers around wrappers
One middleware is wrapping; several is nesting. Because each middleware takes a handler and returns a handler, the output of one is valid input to the next. You stack them by nesting the calls:
h = // Auth wraps the mux; Logging wraps Auth
http
What just happened: Read it inside-out. Auth(mux) produces a handler that does auth and then calls
the mux. Logging(...) wraps that, producing a handler that logs and then calls the auth layer. So a
request flows: Logging → Auth → mux → your handler, and the response unwinds back the same way in
reverse. The outermost wrapper runs first on the way in. That ordering matters: putting Logging
outermost means it times the whole request including auth; swapping them would exclude auth from the
timing. Nesting reads backwards, which is exactly why people reach for a helper.
That nesting gets ugly fast with four or five middlewares. A tiny helper flattens it into a readable list:
.Handler) http.Handler
// usage:
// same as Logging(Auth(mux))
What just happened: Chain takes the base handler plus a variadic list of middlewares. It applies
them back to front (the loop counts down from the last index) so that the first one you list ends
up as the outermost wrapper — matching how you'd read it: "Logging, then Auth, then the mux." Now
adding a middleware is appending a name to the list, not re-nesting parentheses. This is the same
helper, give or take, that every Go middleware library ships under names like Use or With.
Auth middleware: when not to call next
Logging always calls next — it never blocks a request, it just observes. Auth is the interesting
case, because its whole job is to sometimes refuse. The rule is simple: if the request fails the
check, write an error response and return without calling next. That short-circuits the onion —
the inner layers never run.
.Handler
What just happened: We read the Authorization header. If it's empty, we write a 401 Unauthorized
and return immediately — the wrapped handler never runs, so the protected route is never reached.
That bare return is load-bearing: forget it, and after writing the 401 you'd also call next,
running the real handler and writing a second response on top of the error. Only when the header is
present do we fall through to next.ServeHTTP(w, r).
⚠️ Once you've written to
w(status or body), you can't un-write it. Afterhttp.Errorthe response is committed, so thereturnisn't optional politeness — it prevents a corrupt double-response. The pattern "write the error, thenreturn" is one you'll repeat constantly.
Real auth doesn't just check that a token exists — it validates it and figures out who the user is.
You'll want to pass that identity down to the handlers inside. You can't add a field to *http.Request,
but you can attach values to its context. Here's the shape (Phase 6 goes deep on context):
context
next
What just happened: context.WithValue produces a new context carrying user under a key, and
r.WithContext(ctx) makes a copy of the request using that context. We pass the copy down, so any
inner handler can call r.Context().Value(userKey) to retrieve the user the middleware authenticated.
This is how middleware talks to the handlers it wraps — not by mutating the request, but by enriching
its context on the way in. We'll do this properly, with a typed key and a getter, in
Phase 6.
💡 Look back at that
func(http.Handler) http.Handlersignature. It is exactly what chi uses — chi middleware is plain net/http middleware, no translation needed, which is why chi feels like "net/http with a nicer router." Gin and Echo wrap the same before/after idea around their own context type (c.Next()is their version ofnext.ServeHTTP), but it's the identical onion. Learn it once here and every framework's middleware chapter is review.
Recap
- Middleware is
func(next http.Handler) http.Handler— a function that takes a handler and returns a new handler wrapping it. No special type, just a closure overnext. - Code before
next.ServeHTTPruns on the way in; code after it runs on the way out. The request travels inward through the layers and the response unwinds back outward. - Apply middleware by wrapping your mux (
h = Logging(mux)) and passing the wrapper, not the bare mux, to the server. Chain by nesting (Logging(Auth(mux))) or with a smallChainhelper; the outermost wrapper runs first. - An auth middleware that rejects a request must write its error and
returnwithout callingnext— otherwise the protected handler runs anyway and you write two responses. - Pass data (like the authenticated user) to inner handlers via
context.WithValue+r.WithContext, not by mutating the request — expanded in Phase 6.
Quick check
Three quick ones to make sure the wrapper model stuck.
[
{
"q": "What is the type signature of a net/http middleware?",
"choices": [
"func(w http.ResponseWriter, r *http.Request)",
"func(next http.Handler) http.Handler",
"type Middleware interface { Use() }",
"func(mux *http.ServeMux) error"
],
"answer": 1,
"explain": "Middleware takes the next http.Handler and returns a new http.Handler that wraps it. It's a plain function over a closure — no special type involved."
},
{
"q": "In an auth middleware, what must you do when the request is unauthorized?",
"choices": [
"Call next.ServeHTTP anyway so the handler can decide",
"Write the error response and return WITHOUT calling next",
"Panic so the server recovers and sends a 500",
"Delete the Authorization header and retry"
],
"answer": 1,
"explain": "Write the 401 and return immediately. If you don't return, you'll call next after writing the error, running the protected handler and writing a second, corrupt response."
},
{
"q": "Given Logging(Auth(mux)), which layer sees the request first?",
"choices": [
"mux, because it's innermost",
"Auth, because authentication always goes first",
"Logging, because the outermost wrapper runs first on the way in",
"They run in parallel"
],
"answer": 2,
"explain": "Read it inside-out: Logging wraps Auth wraps mux. The outermost wrapper (Logging) runs first on the way in, then Auth, then the mux — and the response unwinds in reverse."
}
]
← Phase 3: Reading Requests, Writing JSON · Guide overview · Phase 5: A JSON REST API With No Framework →
Check your understanding
1. What is the type signature of a net/http middleware?
2. In an auth middleware, what must you do when the request is unauthorized?
3. Given Logging(Auth(mux)), which layer sees the request first?