# chi From Zero

> Learn chi, the lightweight idiomatic Go router: why it stays compatible with net/http, routing and URL params, sub-routers, the stdlib-style middleware stack, reading and writing requests with the standard library, building a REST API, and structuring and testing it. The framework that's barely a framework - and the clearest bridge to plain net/http.


---

# chi From Zero

chi is the framework for people who like `net/http` and only want the one thing the standard library
doesn't give you: a real router. Its whole philosophy is **stay compatible with the standard library**.
A chi handler is a plain `http.HandlerFunc`. chi middleware is a plain `func(http.Handler) http.Handler`.
A chi router *is* an `http.Handler`. That means everything you learn in chi transfers straight to the
broader Go ecosystem, and any stdlib-compatible middleware works with it unchanged. If Gin and Echo are
"frameworks with their own context," chi is "the standard library, plus routing."

The mental model is one router built from standard pieces. The **router** (`chi.NewRouter()`) maps method
+ path to ordinary `http.HandlerFunc`s, supports **URL parameters** (`/tasks/{id}`) the stdlib mux long
lacked, and lets you compose **sub-routers** and a **middleware stack** out of plain `http.Handler`
wrappers. There's no special context value to learn - you use `r *http.Request` and `w http.ResponseWriter`
like always, and pull URL params with `chi.URLParam(r, "id")`.

> 📝 This teaches the **framework** - it assumes you know **Go** ([Go From Zero](/guides/go-from-zero))
> and the shape of `net/http` (a quick read of the [net/http roots guide](/guides/web-services-with-only-net-http)
> makes chi feel obvious). Compare it with [Gin](/guides/gin-from-zero) and [Echo](/guides/echo-from-zero)
> to see the "own-context" vs "stdlib-native" split. chi runs as a Go program, so examples are shown with
> the commands to run them.

## How to read this

Read in order - it grows one service (a small **articles API**) using only `net/http` types plus chi's
router. Phases carry difficulty badges.

## The phases

1. **[What chi Is](01-what-chi-is.md)** 🟢 - the "just a router" philosophy and why staying net/http-compatible matters.
2. **[Routing, URL Params & Sub-routers](02-routing-and-subrouters.md)** 🟢 - methods, `{id}` params, `Route`/`Mount`, and nested routers.
3. **[Middleware the Standard Way](03-middleware.md)** 🟡 - `func(http.Handler) http.Handler`, chi's built-in middlewares, and per-route stacks.
4. **[Requests & Responses with the Standard Library](04-requests-and-responses.md)** 🟡 - decoding JSON, writing JSON, status codes, and small helpers.
5. **[Building a REST API](05-building-a-rest-api.md)** 🟡 - full CRUD for the articles resource with chi + the stdlib.
6. **[Structuring & Testing](06-structure-and-testing.md)** 🔴 - handlers/services layout, `context` values, and `httptest`.
7. **[Where to Go Next](07-where-to-go-next.md)** 🟢 - chi vs Gin/Echo, the improved stdlib mux, and what to build.

> The throughline: chi adds a **router** to the standard library and gets out of the way. Learn chi and
> you've mostly learned idiomatic `net/http` - which is exactly the point.


---

# What chi Is

You know [Go](/guides/go-from-zero), and you already like `net/http`. Maybe you've read the
[net/http roots guide](/guides/web-services-with-only-net-http) and thought: this is genuinely fine - 
clean types, no magic, the standard library does the work. There's only one thing that gets old fast:
telling `/articles/42` apart from `/articles` means hand-rolling a router, and the stdlib mux historically
made that more painful than it should be. That's the one gap.

chi fills exactly that gap and nothing more. It's a lightweight, idiomatic router whose entire philosophy
is **stay 100% compatible with `net/http`**. Where [Gin](/guides/gin-from-zero) and Echo hand you their
own context object to learn - `*gin.Context`, `echo.Context`, with their own JSON helpers and their own way
of doing everything - chi hands you back the standard library. A chi handler is a plain `http.HandlerFunc`.
chi middleware is a plain `func(http.Handler) http.Handler`. A chi router *is* an `http.Handler`. There's no
new request object to memorize, because there isn't one.

## The mental model: chi adds a router and gets out of the way

Hold this one idea and the whole framework follows from it.

📝 **chi adds a router to the standard library and then steps aside.** It doesn't wrap your handlers in
anything, doesn't give you a special context. It matches an incoming method + path to one of *your* plain
`net/http` handlers, fills in URL parameters the stdlib mux long lacked, and calls your function with the
same `w http.ResponseWriter, r *http.Request` you'd write anyway.

```mermaid
flowchart LR
  R["chi.Router<br/>matches method + path"] --> H["handler<br/>func(w, r) - a plain http.HandlerFunc"]
  H --> RW["http.ResponseWriter<br/>you write the response"]
```

*One idea:* the router is the only chi-specific thing in the picture. On either side of it - the handler you
write, the response you send - it's the standard library all the way down. Compare that mermaid box to Gin's
(which has a `gin.Context` doing the response work) and the difference is the whole pitch.

## Your first server

First, pull chi into your module. From inside your Go project:

```bash
go get github.com/go-chi/chi/v5
```

*What just happened:* `go get` downloaded chi and recorded it in your `go.mod`/`go.sum`. Note the `/v5` - 
chi uses Go's versioned module paths, so the import path carries the major version. That one command is the
whole install.

Now the smallest server that does something real. Create a file called `main.go`:

```go
package main

import (
    "net/http"

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

func main() {
    r := chi.NewRouter()
    r.Get("/ping", func(w http.ResponseWriter, req *http.Request) {
        w.Write([]byte("pong"))
    })
    http.ListenAndServe(":3000", r)
}
```

*What just happened:* line by line - 
- `chi.NewRouter()` creates the **router** and returns a `chi.Router`. We name it `r`.
- `r.Get("/ping", ...)` registers a **route**: when a `GET` arrives for `/ping`, run the function we pass.
  That function is the **handler**, and its signature - `func(w http.ResponseWriter, req *http.Request)` - 
  is the *exact* shape of a plain `http.HandlerFunc`. No chi types in it at all.
- `w.Write([]byte("pong"))` writes the response body using the standard `http.ResponseWriter` - same call
  you'd make with no framework.
- `http.ListenAndServe(":3000", r)` starts the server on port 3000 - and here's the key move: the second
  argument to the stdlib's `ListenAndServe` is the handler, and we pass `r`. **The router *is* the handler.**
  Because a chi router satisfies the `http.Handler` interface, you hand it straight to the standard library.
  There's no `r.Run()` wrapper to learn.

Run it like any Go program:

```bash
go run main.go
```

Leave it running, and in another terminal hit the route:

```bash
curl localhost:3000/ping
```

```console
$ curl localhost:3000/ping
pong
```

*What just happened:* `go run` compiled and started your program; `http.ListenAndServe` brought up the
server and blocked, waiting for requests. `curl` sent a `GET /ping`, chi's router matched it to your route,
called your plain handler, and the handler wrote `pong` back. A working server, and the only chi-specific
line is `chi.NewRouter()`.

## Why "compatible with net/http" is the whole point

It's tempting to read "compatible with the standard library" as a modest, boring feature. It isn't - it's
the reason to pick chi.

💡 Because everything in that server is a stdlib type, **what you learn here transfers straight to plain
net/http** ([the net/http roots guide](/guides/web-services-with-only-net-http)) and back again. Learn to
write a chi handler and you've learned to write an `http.HandlerFunc`. Learn chi middleware and you've
learned `func(http.Handler) http.Handler`, the standard middleware shape.

💡 And it runs in the other direction too: **any stdlib-compatible middleware works with chi unchanged.**
The whole ecosystem of `func(http.Handler) http.Handler` wrappers - CORS handlers, request loggers,
auth middleware written for raw `net/http` - drops into a chi router with no adapter, because chi never
asked them to speak a special dialect.

⚠️ The flip side, worth naming on day one: chi gives you *less* out of the box than Gin or Echo. There's no
`c.JSON(200, ...)` one-liner waiting for you - you'll reach for `encoding/json` and write the response the
standard way. That's a deliberate trade: a little more typing for zero magic and total portability. If
you'd rather the framework do more of the boring parts, [Gin](/guides/gin-from-zero) is the guide to read.

## The running example: an articles API

We won't keep writing throwaway `/ping` routes. Across this guide we'll grow one real service: a small
**articles API**. The core is a single type - an article with an id, a title, and a body:

```go
type Article struct {
    ID    int    `json:"id"`
    Title string `json:"title"`
    Body  string `json:"body"`
}
```

*What just happened:* we declared the `Article` struct the whole guide builds on. Those `json:"..."`
**struct tags** tell `encoding/json` what to call each field on the wire - so `Title` becomes `"title"` in
the JSON, not `"Title"`. They do real work in both directions. Here's the type returning itself through a
plain chi handler:

```go
r.Get("/articles/sample", func(w http.ResponseWriter, req *http.Request) {
    a := Article{ID: 1, Title: "What chi Is", Body: "chi is just a router."}
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(a)
})
```

*What just happened:* the handler built an `Article`, set the `Content-Type` header by hand, and used the
standard `json.NewEncoder(w).Encode(a)` to serialize it straight to the response writer. No framework
helper doing this - it's stdlib JSON, exactly as you'd write it without chi. Hit it and you get clean JSON
back:

```console
$ curl localhost:3000/articles/sample
{"id":1,"title":"What chi Is","body":"chi is just a router."}
```

That two-line JSON dance is what Gin folds into `c.JSON`. Phase 4 wraps it in a small helper of our own so
you write it once, not in every handler - but it stays stdlib underneath. By the end of the guide, this
grows into full create/read/update/delete over a real collection of articles. For now you've met the cast:
a **router**, a **route**, a plain **handler**, and the **`Article`** we'll spend the next phases turning
into a proper REST API. Next up: routing - methods, `{id}` URL params, and sub-routers.

## Recap

- **chi is a lightweight, idiomatic Go router** whose whole philosophy is staying 100% compatible with
  `net/http`. Install it with `go get github.com/go-chi/chi/v5` (note the `/v5`).
- **The mental model:** chi adds a router to the standard library and gets out of the way. A chi handler is
  a plain `http.HandlerFunc`, chi middleware is a plain `func(http.Handler) http.Handler`, and a chi router
  *is* an `http.Handler` - there's no special context object to learn.
- **A first server is tiny:** `chi.NewRouter()` makes the router, `r.Get(path, handler)` registers a route,
  and you serve it with the standard library - `http.ListenAndServe(":3000", r)`, because the router is the
  handler. Run with `go run main.go`, test with `curl`.
- **net/http-compatibility is the point, not a footnote:** your skills transfer straight to plain `net/http`,
  and any stdlib-compatible middleware works with chi unchanged - no framework-specific adapters.
- **The trade-off:** chi gives you less out of the box (no `c.JSON` one-liner - you use `encoding/json`
  directly). That's deliberate: less magic, total portability.
- **The running example** is an **articles API** built on the `Article{ID, Title, Body}` struct, which the
  rest of the guide turns into full CRUD.

## Quick check

Three questions on the ideas that have to stick - what chi is, the "just a router" philosophy, and how a
first server fits together:

```quiz
[
  {
    "q": "What is a chi route handler, in terms of types?",
    "choices": [
      "A plain http.HandlerFunc - func(w http.ResponseWriter, r *http.Request), the same signature as raw net/http",
      "A function that takes a special *chi.Context argument",
      "A method on a struct that chi generates for you",
      "A function returning (string, error) that chi serializes automatically"
    ],
    "answer": 0,
    "explain": "chi's whole philosophy is staying compatible with net/http. A chi handler is just a plain http.HandlerFunc - there is no special context object like Gin's *gin.Context or Echo's echo.Context. You write w http.ResponseWriter, r *http.Request exactly as you would without a framework."
  },
  {
    "q": "In `http.ListenAndServe(\":3000\", r)`, why can you pass the chi router `r` as the second argument?",
    "choices": [
      "Because a chi router IS an http.Handler, so the standard library serves it directly",
      "Because chi monkey-patches ListenAndServe to accept its own type",
      "Because r is secretly converted to a string route table",
      "Because ListenAndServe ignores its second argument when it is a chi router"
    ],
    "answer": 0,
    "explain": "A chi router satisfies the http.Handler interface, so it plugs straight into the standard library's http.ListenAndServe. The router IS the handler - that is why there is no special r.Run() wrapper to learn; you use the stdlib function you already know."
  },
  {
    "q": "Which is a real consequence of chi staying compatible with net/http?",
    "choices": [
      "Any stdlib-compatible middleware (func(http.Handler) http.Handler) works with chi unchanged",
      "chi handlers must be rewritten to run under plain net/http",
      "chi ships a c.JSON one-liner so you never touch encoding/json",
      "chi requires its own special middleware format that other libraries must adopt"
    ],
    "answer": 0,
    "explain": "Because chi uses standard types, the whole ecosystem of func(http.Handler) http.Handler middleware - loggers, CORS, auth - drops in with no adapter. The trade-off is the opposite of a c.JSON helper: chi gives you less out of the box, so you use encoding/json directly."
  }
]
```


---

# Routing, URL Params & Sub-routers

Here's the whole mental model, and once it clicks the rest of chi is detail: a chi router is a
lookup table. Each entry is a **method + a URL pattern** on the left, and a plain
`http.HandlerFunc` on the right. When a request arrives, chi reads its method (`GET`, `POST`, …)
and its path (`/articles/42`), finds the matching entry, and calls that function. No magic context
object, no special handler signature - the right-hand side is the exact same
`func(w http.ResponseWriter, r *http.Request)` you'd write for the standard library.

The one thing chi adds on top of a flat lookup table is **placeholders**. A pattern like
`/articles/{id}` matches `/articles/42` and `/articles/hello` alike, and stashes whatever was in
the `{id}` slot so your handler can read it back. And because real apps have dozens of routes,
chi lets you **group** related ones under a shared prefix instead of repeating yourself - that's
what sub-routers are for.

> 📝 We're growing one example through the whole guide: a small **articles API**. An article is
> just `Article{id, title, body}`. By the end of this phase you'll have all the URLs that API
> needs - listing, creating, fetching one, updating, deleting - wired up cleanly.

## Methods: one function per verb

chi gives you a method on the router for each HTTP verb. The pattern is always the same: path
first, handler second.

```go
r := chi.NewRouter()

r.Get("/articles", listArticles)
r.Post("/articles", createArticle)
r.Get("/articles/{id}", getArticle)
r.Put("/articles/{id}", updateArticle)
r.Delete("/articles/{id}", deleteArticle)
```

*What just happened:* we registered five routes. Notice that `/articles` and `/articles/{id}`
are different patterns, and that the *same* path (`/articles/{id}`) can carry different handlers
depending on the verb - `GET` reads an article, `PUT` replaces it, `DELETE` removes it. chi
matches on method **and** path together, so there's no collision.

Beyond these named helpers (`Get`, `Post`, `Put`, `Patch`, `Delete`, `Head`, `Options`), there
are two escape hatches for when the verb is dynamic or unusual:

```go
r.Method("GET", "/health", healthHandler)   // takes an http.Handler
r.MethodFunc("GET", "/ping", pingHandler)    // takes an http.HandlerFunc
```

*What just happened:* `r.Method` and `r.MethodFunc` do exactly what `r.Get` does, except you pass
the verb as a string. Reach for these only when you genuinely need a verb as data - for everyday
routes, the named helpers read better.

## URL params: reading `{id}` back out

The handlers above use `/articles/{id}`, but how does `getArticle` find out *which* id? With
`chi.URLParam`:

```go
func getArticle(w http.ResponseWriter, r *http.Request) {
    idStr := chi.URLParam(r, "id")   // always a string, e.g. "42"
    id, err := strconv.Atoi(idStr)
    if err != nil {
        http.Error(w, "id must be a number", http.StatusBadRequest)
        return
    }
    // ... look up article #id and write it back ...
    fmt.Fprintf(w, "you asked for article %d", id)
}
```

*What just happened:* `chi.URLParam(r, "id")` pulls the value that filled the `{id}` slot in the
pattern. The name you pass (`"id"`) must match the name inside the braces. The big gotcha worth
burning into memory: **it always returns a string.** A request to `/articles/42` gives you `"42"`,
not `42`. If you need a number, convert it yourself with `strconv.Atoi` and check the error,
because nothing stops someone from requesting `/articles/banana`.

> ⚠️ A common early bug: comparing `chi.URLParam(r, "id")` directly to an integer, or forgetting
> the conversion can fail. Treat the param as untrusted user input - convert and validate before
> you use it.

If you'd rather reject non-numeric ids at the routing layer instead of inside the handler, chi
lets you constrain a placeholder with a regular expression:

```go
r.Get("/articles/{id:[0-9]+}", getArticle)
```

*What just happened:* the `:[0-9]+` part says "only match if this segment is one or more digits."
Now `/articles/42` reaches `getArticle`, but `/articles/banana` doesn't match this route at all
and falls through to a 404. You still read the value with `chi.URLParam(r, "id")` (the regex part
isn't included in the name). Handy, but don't overdo it - for anything beyond simple shapes,
validating inside the handler is usually clearer.

> 💡 Query strings (`/articles?q=go`) are *not* URL params and chi doesn't wrap them. They come
> from the standard library: `r.URL.Query().Get("q")`. More on that at the end of this phase.

## Sub-routers: grouping routes with `Route`

Writing `/articles` and `/articles/{id}` over and over gets noisy, and it scatters related routes
across your file. `r.Route` fixes both. It carves out a prefix and gives you a fresh router scoped
to it, so every route you register inside is relative to that prefix:

```go
r.Route("/articles", func(r chi.Router) {
    r.Get("/", listArticles)            // GET    /articles
    r.Post("/", createArticle)          // POST   /articles
    r.Route("/{id}", func(r chi.Router) {
        r.Get("/", getArticle)          // GET    /articles/{id}
        r.Put("/", updateArticle)       // PUT    /articles/{id}
        r.Delete("/", deleteArticle)    // DELETE /articles/{id}
    })
})
```

*What just happened:* this registers the exact same five routes as our flat list earlier, but now
they're visually grouped by resource. Inside `r.Route("/articles", ...)`, the path `"/"` means
"the prefix itself" (`/articles`), and the nested `r.Route("/{id}", ...)` stacks another segment
on top, so `"/"` inside *it* means `/articles/{id}`. The `id` param is still read the same way. This
nesting is the idiomatic chi way to organize a resource - all the "things you can do to articles"
live in one block.

> 📝 The `r` inside the callback shadows the outer `r` on purpose. It's a new sub-router whose
> routes are automatically prefixed. Reusing the name keeps the calls looking identical at every
> level - `r.Get`, `r.Post`, and so on - which is exactly the point.

## `Mount`: bolting a whole router onto a path

`Route` is for grouping routes inline. `Mount` is for attaching an *entire pre-built router* - 
with its own routes and its own middleware - at a path. This is how you compose an app out of
self-contained modules:

```go
func adminRouter() chi.Router {
    r := chi.NewRouter()
    // r.Use(requireAdmin)   // its own middleware (next phase)
    r.Get("/articles", listAllArticles)
    r.Delete("/articles/{id}", forceDeleteArticle)
    return r
}

func main() {
    r := chi.NewRouter()
    r.Get("/articles", listArticles)
    r.Mount("/admin", adminRouter())   // GET /admin/articles, DELETE /admin/articles/{id}
    http.ListenAndServe(":3000", r)
}
```

*What just happened:* `adminRouter()` builds a complete, independent router. `r.Mount("/admin", …)`
hangs it off the `/admin` prefix, so its `/articles` route becomes `/admin/articles`. The admin
router can declare its own middleware that applies only to its routes, and `main` doesn't need to
know any of its internals. As an app grows, this lets each feature own a file and a router, and
`main` stays a short list of mounts.

> 💡 Rule of thumb: use `Route` to group routes that share a prefix in the same place; use `Mount`
> to plug in a router that was built somewhere else (often in its own package or file).

## Query params come from the standard library

One last thing, because people expect chi to have a helper for it and it doesn't - on purpose.
Query string values aren't part of the route, so chi leaves them to `net/http`:

```go
func listArticles(w http.ResponseWriter, r *http.Request) {
    q := r.URL.Query().Get("q")        // /articles?q=go  ->  "go"
    if q == "" {
        fmt.Fprint(w, "all articles")
        return
    }
    fmt.Fprintf(w, "articles matching %q", q)
}
```

*What just happened:* `r.URL.Query()` parses the query string into a map-like value, and `.Get("q")`
reads one key (returning `""` if it's absent). This is plain standard library - the same code works
without chi at all. It's a perfect little illustration of chi's whole philosophy: it adds the router
and the URL params the stdlib lacked, and for everything else it gets out of your way.

## Recap

- A chi router is a lookup table mapping **method + pattern** to a plain `http.HandlerFunc` - no special handler signature, no magic context.
- Use the named verb helpers (`r.Get`, `r.Post`, `r.Put`, `r.Delete`, …); `r.Method`/`r.MethodFunc` take the verb as a string for dynamic cases.
- `{id}` in a pattern is a placeholder; read it with `chi.URLParam(r, "id")`, which **always returns a string** - convert with `strconv.Atoi` and validate. Constrain with regex like `{id:[0-9]+}` when you want routing to reject bad shapes.
- `r.Route` groups routes under a shared prefix with a scoped sub-router (nest them for `/articles/{id}`); `r.Mount` attaches a whole pre-built router (with its own middleware) at a path.
- Query params aren't routing - read them with the standard library: `r.URL.Query().Get("q")`.

## Quick check

Test the mental model before moving on.

```quiz
[
  {
    "q": "A request hits GET /articles/42 on a route registered as \"/articles/{id}\". What does chi.URLParam(r, \"id\") return?",
    "choices": ["The integer 42", "The string \"42\"", "An error, because id isn't numeric", "nil until you call strconv.Atoi"],
    "answer": 1,
    "explain": "chi.URLParam always returns a string. You convert it yourself (e.g. strconv.Atoi) when you need a number."
  },
  {
    "q": "You built a complete, self-contained adminRouter() in its own file and want to attach it under /admin. Which call do you use?",
    "choices": ["r.Route(\"/admin\", adminRouter)", "r.Mount(\"/admin\", adminRouter())", "r.Get(\"/admin\", adminRouter())", "r.Group(adminRouter())"],
    "answer": 1,
    "explain": "r.Mount attaches an entire pre-built router (with its own routes and middleware) at a path. r.Route is for grouping routes inline."
  },
  {
    "q": "How do you read the value of q in a request to /articles?q=go?",
    "choices": ["chi.URLParam(r, \"q\")", "r.URL.Query().Get(\"q\")", "chi.QueryParam(r, \"q\")", "r.FormParam(\"q\")"],
    "answer": 1,
    "explain": "Query strings aren't route params, so chi doesn't wrap them. You use the standard library: r.URL.Query().Get(\"q\")."
  }
]
```


---

# 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 using 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:

```go
func(next http.Handler) 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](/guides/web-services-with-only-net-http) - 
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. Any middleware written for
stdlib works with chi unchanged.

Picture the request falling through layers and climbing back out:

```mermaid
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.

```go
package main

import (
	"log"
	"net/http"
	"time"
)

func Logger(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		next.ServeHTTP(w, r)
		log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
	})
}
```

*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

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

	r.Use(Logger) // applies to every route registered after this line

	r.Get("/articles", listArticles)
	r.Get("/articles/{id}", getArticle)

	http.ListenAndServe(":3000", r)
}
```

*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. Call `Use` multiple times and 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 - a feature,
not an annoyance, that 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:

```go
r.With(RequireAuth).Post("/articles", createArticle)
```

*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:

```go
r.Route("/admin", func(admin chi.Router) {
	admin.Use(RequireAuth) // only /admin/* routes get this
	admin.Get("/stats", adminStats)
	admin.Delete("/articles/{id}", deleteArticle)
})
```

*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:

```go
import (
	"time"

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

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

	r.Use(middleware.RequestID)              // tags each request with a unique ID
	r.Use(middleware.RealIP)                 // sets r.RemoteAddr from X-Forwarded-For etc.
	r.Use(middleware.Logger)                 // structured request logging
	r.Use(middleware.Recoverer)              // catches panics, returns 500 instead of crashing
	r.Use(middleware.Timeout(60 * time.Second)) // cancels the request context after 60s

	r.Get("/articles", listArticles)

	http.ListenAndServe(":3000", r)
}
```

*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 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(...)`.

```go
type contextKey string

const userKey contextKey = "user"

func RequireAuth(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		token := r.Header.Get("Authorization")
		if token == "" {
			http.Error(w, "unauthorized", http.StatusUnauthorized)
			return // note: we do NOT call next - the request stops here
		}

		user := lookupUser(token) // pretend this validates the token
		ctx := context.WithValue(r.Context(), userKey, user)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

func createArticle(w http.ResponseWriter, r *http.Request) {
	user := r.Context().Value(userKey).(string)
	log.Printf("article created by %s", user)
	// ... do the work ...
}
```

*What just happened:* `RequireAuth` checks the `Authorization` header. No token? It writes a 401
and `return`s - 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.

💡 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.Handler` that wraps `next` and
  decides when to call `next.ServeHTTP(w, r)`. chi adds no special type.
- Work **before** `next.ServeHTTP` runs on the way in; work **after** runs on the way out.
  Skipping `next` (e.g. an auth rejection) stops the chain.
- `r.Use` applies middleware to all routes registered after it; `r.With(mw)` scopes it to one
  route; sub-router `Use` scopes it to that group. ⚠️ `Use` must come before routes or chi panics.
- chi's built-ins (`Logger`, `Recoverer`, `RequestID`, `RealIP`, `Timeout`) cover the essentials - 
  `Recoverer` especially keeps a panicking handler from taking down the server.
- Pass request-scoped data with `context.WithValue` + `r.WithContext`, read it via
  `r.Context().Value(...)`. Any stdlib-compatible middleware works with chi unchanged.

## Quick check

```quiz
[
  {
    "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)."
  }
]
```


---

# Requests & Responses with the Standard Library

Here's the deal with chi, stated plainly so it never surprises you: chi is a
router and **nothing else**. It matches a method and path to a handler, and then
hands you the same `w http.ResponseWriter` and `r *http.Request` you'd get from
the bare standard library. There's no `c.JSON(...)`, no `c.Bind(...)`, no magic
context object with convenience methods bolted on.

So all the request/response work - reading a JSON body, writing a JSON body,
choosing a status code - is done with the standard library, mostly the
`encoding/json` package plus `net/http`. That sounds like more work than Gin or
Echo, and in raw line count it is a little. But it's a small amount of code, you
write it once, and the payoff is that you're learning *Go's* HTTP model, not
chi's.

> 📝 The mental model for this phase: **the request and response are streams of
> bytes, and `encoding/json` is your translator at both ends.** Reading = decode
> the request body's bytes into a struct. Writing = encode a struct into the
> response body's bytes. chi is not involved in either direction.

We'll keep growing the **articles API**. The data type is the same one from
earlier phases:

```go
type Article struct {
    ID    int    `json:"id"`
    Title string `json:"title"`
    Body  string `json:"body"`
}
```

*What just happened:* `Article` is a plain struct with JSON struct tags. Those
backtick tags tell `encoding/json` what each field is called in JSON
(`"title"`, not `"Title"`). The tags work in **both** directions - decoding and
encoding - which is why we define them once and never think about them again.

## Reading a JSON body

A client sends `POST /articles` with a JSON body. You want to turn those bytes
into a Go value. The tool is `json.NewDecoder`, which reads directly from
`r.Body` (an `io.Reader` - a stream), so you never have to load the whole body
into a string yourself.

A common pattern is to decode into a small **input struct** rather than straight
into `Article`. The input is "what the client is allowed to send" - usually not
the same as your full model (the client doesn't get to pick the `id`, for
instance).

```go
func createArticle(w http.ResponseWriter, r *http.Request) {
    var in struct {
        Title string `json:"title"`
        Body  string `json:"body"`
    }

    if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
        http.Error(w, "invalid JSON body", http.StatusBadRequest)
        return
    }

    // in.Title and in.Body now hold the decoded values.
    // ... create the article, assign an ID, store it ...
}
```

*What just happened:* `json.NewDecoder(r.Body)` wraps the request body stream.
`.Decode(&in)` reads the JSON and fills in the struct's fields by matching JSON
keys to struct tags - note the `&`, since Decode needs a pointer to write into
your variable. The critical part is the error check: **malformed JSON is the
client's fault, so it's a 400, not a 500.** `http.Error` writes a plain text
message and sets the status in one call. The bare `return` after it is
essential - without it, the handler would keep running on garbage data.

⚠️ A decode error covers a body that isn't valid JSON at all. It does **not**
catch JSON that's valid but wrong - `{"title": 5}` (number where a string is
expected) errors, but `{"titlee": "oops"}` (typo'd field) silently decodes to an
empty `Title`. By default, unknown fields are just ignored. To reject them - 
useful for catching client typos early - turn it on explicitly:

```go
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&in); err != nil {
    http.Error(w, "invalid JSON body", http.StatusBadRequest)
    return
}
```

*What just happened:* `DisallowUnknownFields()` flips the decoder into strict
mode, so a body with a key your struct doesn't have produces an error instead of
being quietly dropped - a small line that turns a whole class of silent client
bugs into loud 400s. Use it when you control the clients and want tight
contracts; skip it for public APIs where forward-compatibility is a feature.

## Writing JSON - and the one ordering rule that bites everyone

Now the other direction: you have an `Article` (or any value) and want to send it
back as JSON. There's no built-in `WriteJSON`, so everyone writes a tiny helper.
Here's the canonical one:

```go
func writeJSON(w http.ResponseWriter, status int, v any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(v)
}
```

*What just happened:* three steps, in this exact order. First, set the
`Content-Type` header so the client knows it's getting JSON. Second, write the
status line with `w.WriteHeader(status)`. Third, encode the value straight onto
`w` (the `ResponseWriter` is an `io.Writer`, so the encoder streams JSON bytes
into the response body). `any` is Go's alias for `interface{}`, so this helper
takes any value you can marshal.

⚠️ This is the **number-one stdlib HTTP gotcha**, so read it twice. The response
must be built in this order: **headers first, then status, then body.** The
reason is how HTTP works on the wire - the headers and status line are sent
*before* the body, and once any of those go out, they're locked.
`w.Header().Set(...)` must come **before** `w.WriteHeader(...)` - after
`WriteHeader`, the header block is already on its way, and setting one later is
silently ignored. `w.WriteHeader(...)` must come **before** you write the
body - the **first** call to `w.Write(...)` (which `Encode` does internally)
flushes the status line, and if you never called `WriteHeader`, that first
write implicitly sends **`200 OK`**. Encode the body first and try to set a 201
afterward, and your 201 is ignored - the client already got a 200.

Get the order wrong and there's no crash, no error - just a response with the
wrong status or a missing header, discovered later in a confused debugging
session. Bake the order into the helper (as above) and you never think about it
again.

Using the helper makes handlers read cleanly:

```go
func createArticle(w http.ResponseWriter, r *http.Request) {
    var in struct {
        Title string `json:"title"`
        Body  string `json:"body"`
    }
    if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
        http.Error(w, "invalid JSON body", http.StatusBadRequest)
        return
    }

    a := Article{ID: nextID(), Title: in.Title, Body: in.Body}
    writeJSON(w, http.StatusCreated, a)
}
```

*What just happened:* the handler decodes the input, builds a real `Article`
(assigning the server-controlled `ID` itself), and replies with `201 Created`
plus the new article as JSON. Read it top to bottom and the shape is obvious - 
exactly because the ordering complexity is hidden in `writeJSON`.

## Status codes and the empty-body case

Status codes are just integer constants in `net/http`, and using the named ones
keeps your handlers readable. The ones you'll reach for constantly:

- `http.StatusOK` (200) - a normal successful GET.
- `http.StatusCreated` (201) - you just created a resource (POST).
- `http.StatusNoContent` (204) - success, but there's **nothing to send back**.
- `http.StatusBadRequest` (400) - the client's request was malformed.
- `http.StatusNotFound` (404) - the thing they asked for doesn't exist.

The 204 case is special because the rule is: **204 means no body, so don't write
one.** A `DELETE` that succeeds is the classic example:

```go
func deleteArticle(w http.ResponseWriter, r *http.Request) {
    id := chi.URLParam(r, "id")
    // ... remove the article with that id ...
    w.WriteHeader(http.StatusNoContent)
}
```

*What just happened:* we set the status to 204 and then **stop** - no
`writeJSON`, no `Encode`, nothing. There's no body to send, so we don't reach for
the helper at all. (Write a body after a 204 and you contradict the status
code - some clients will complain.)

That snippet also quietly reuses two request-reading tools from earlier phases,
worth a one-line refresher since you'll use them in the same handlers as your
JSON work:

```go
id := chi.URLParam(r, "id")          // path param from a route like /articles/{id}
sort := r.URL.Query().Get("sort")    // query string param from ?sort=title
```

*What just happened:* `chi.URLParam(r, "id")` pulls a value out of the **path**
for routes declared with `{id}` (this is the one helper chi itself provides).
`r.URL.Query().Get("sort")` is pure stdlib and reads a **query-string** value,
returning `""` if it's absent. Both give you strings - converting `id` to an int
with `strconv.Atoi` (and handling the error as a 400) is on you.

## Validation: there's no net here

This is the real tradeoff of the stdlib approach. After you decode `in`,
**nothing has checked that the data makes sense.** An empty title, a 50,000-word
body, a missing field - `encoding/json` doesn't care. Validation is your job, by
hand:

```go
if in.Title == "" {
    http.Error(w, "title is required", http.StatusBadRequest)
    return
}
```

*What just happened:* a plain `if`. That's the whole validation story in raw
stdlib - check the fields you care about and return a 400 when something's off.
For two or three fields this is fine and arguably clearer than anything
fancier. For a large API with many rules, hand-written checks pile up fast, and
that's where a library earns its keep.

> 💡 Two ways to get more help without abandoning the stdlib model. (1) The
> `github.com/go-playground/validator` package lets you declare rules as struct
> tags - `validate:"required,min=1"` - and validate with one call. (2) chi ships
> a companion package, `github.com/go-chi/render`, with JSON helpers
> (`render.JSON`, `render.Bind`, `render.Status`) that wrap the
> decode/encode/order dance for you. Both are optional - the plain stdlib shown
> above is genuinely enough for most APIs.

And that's the philosophical fork in the road. Gin and Echo come with
batteries - `c.ShouldBindJSON` decodes *and* validates in one call, `c.JSON`
handles the header/status/body order for you. chi deliberately ships none of
that, betting that a `writeJSON` helper and a few `if` statements are a fair
price for staying 100% standard-library-native.

## Recap

- chi gives you a router and **nothing else** for I/O - you read and write with
  `encoding/json` and `net/http`, the same as bare stdlib.
- **Read** a body with `json.NewDecoder(r.Body).Decode(&in)`; a decode error is a
  client mistake, so return **400** and `return` immediately. Use
  `DisallowUnknownFields()` for strict contracts.
- **Write** JSON with a small helper, and respect the order: **header → status →
  body.** The first body write locks the status (and defaults to 200 if you
  never set one), so headers and `WriteHeader` must come first.
- Use named `net/http` status constants. **204 means no body** - set the status
  and write nothing.
- There's **no built-in validation** - check fields by hand, or opt into
  `go-playground/validator` / `go-chi/render`. That's the deliberate tradeoff
  versus Gin/Echo's batteries.

## Quick check

```quiz
[
  {
    "q": "In the writeJSON helper, what's the correct order of operations?",
    "choices": ["WriteHeader, then Set the Content-Type header, then Encode the body", "Set the Content-Type header, then WriteHeader, then Encode the body", "Encode the body, then WriteHeader, then Set the header", "Set the header and WriteHeader in any order, then Encode"],
    "answer": 1,
    "explain": "Headers must be set before WriteHeader, and WriteHeader before the body - once the body is written the status and headers are locked."
  },
  {
    "q": "A client sends a body that is not valid JSON. What should the handler do?",
    "choices": ["Return 500 Internal Server Error", "Ignore it and continue with zero values", "Return 400 Bad Request and stop processing", "Return 204 No Content"],
    "answer": 2,
    "explain": "A malformed body is the client's fault, so it's a 400, and you must return immediately so the handler doesn't run on garbage data."
  },
  {
    "q": "You want a successful DELETE to return 204 No Content. What do you write?",
    "choices": ["writeJSON(w, http.StatusNoContent, article)", "w.WriteHeader(http.StatusNoContent) and write no body", "json.NewEncoder(w).Encode(nil)", "http.Error(w, \"\", 204)"],
    "answer": 1,
    "explain": "204 means there is no body - set the status and write nothing at all."
  }
]
```


---

# Building a REST API

This is the payoff phase. Everything so far has been a separate piece on the
workbench - the router from Phase 2, the middleware stack from Phase 3, the
JSON read/write helpers from Phase 4. Now we bolt them together into a real,
working REST API for the **articles** resource. By the end you'll have full CRUD
(create, read, update, delete) that you can hit with `curl`.

Here's the thing to hold in your head before any code:

> 📝 **A REST resource is just five plain `http.HandlerFunc`s over one
> collection, mounted on a sub-router.** List, get-one, create, update, delete - 
> five functions with the identical signature `func(w http.ResponseWriter, r
> *http.Request)`. There's no framework "context" object, no special base
> class, no magic. It's the same conceptual shape you'd draw for Gin or Echo,
> but here every handler is pure standard library plus chi's router doing the
> method-and-path matching.

Let's build it from the inside out: first the place the data lives, then the five
handlers, then the routing that wires them up.

## The store: where the articles live

Before we can serve articles, we need somewhere to keep them. In a real app this
is a database. To keep this phase about *the API* and not about SQL, we'll use an
in-memory store: a `map` from ID to `Article`, plus a counter for the next ID.

But there's a trap here that catches people, so let's name it loudly.

⚠️ **`net/http` serves every request on its own goroutine.** That means two
requests can hit your store *at the same time* - one creating an article while
another lists them. A plain Go `map` is **not** safe for concurrent
read/write; do it unguarded and you'll get a runtime panic ("concurrent map
writes") under load, the kind of bug that never shows up in local testing and
takes your server down in production. The fix is a `sync.RWMutex` guarding
every access.

```go
type Article struct {
    ID    int    `json:"id"`
    Title string `json:"title"`
    Body  string `json:"body"`
}

type store struct {
    mu       sync.RWMutex
    articles map[int]Article
    nextID   int
}

func newStore() *store {
    return &store{
        articles: map[int]Article{},
        nextID:   1,
    }
}
```

*What just happened:* `Article` is the same struct from Phase 4 - plain fields
with JSON tags. `store` wraps the map together with the mutex that protects it
and the `nextID` counter. Keeping the mutex *next to* the data it guards (rather
than as a loose global) is the idiomatic Go move - it's obvious what the lock
protects. `newStore` hands back a ready-to-use store with an empty map and IDs
starting at 1.

Now the store's methods. The rule of thumb: take a **read** lock (`RLock`) when
you're only looking, take a **write** lock (`Lock`) when you're changing
anything. Read locks can be held by many goroutines at once; a write lock is
exclusive.

```go
func (s *store) list() []Article {
    s.mu.RLock()
    defer s.mu.RUnlock()

    out := make([]Article, 0, len(s.articles))
    for _, a := range s.articles {
        out = append(out, a)
    }
    return out
}

func (s *store) get(id int) (Article, bool) {
    s.mu.RLock()
    defer s.mu.RUnlock()

    a, ok := s.articles[id]
    return a, ok
}

func (s *store) create(title, body string) Article {
    s.mu.Lock()
    defer s.mu.Unlock()

    a := Article{ID: s.nextID, Title: title, Body: body}
    s.articles[a.ID] = a
    s.nextID++
    return a
}

func (s *store) update(id int, title, body string) (Article, bool) {
    s.mu.Lock()
    defer s.mu.Unlock()

    if _, ok := s.articles[id]; !ok {
        return Article{}, false
    }
    a := Article{ID: id, Title: title, Body: body}
    s.articles[id] = a
    return a, true
}

func (s *store) delete(id int) bool {
    s.mu.Lock()
    defer s.mu.Unlock()

    if _, ok := s.articles[id]; !ok {
        return false
    }
    delete(s.articles, id)
    return true
}
```

*What just happened:* five small methods, each locking before it touches the
map and `defer`-ing the unlock so it always releases even if something returns
early. `list` and `get` use `RLock` (read-only); `create`, `update`, and
`delete` use `Lock` (they mutate). Notice the **`(value, bool)` pattern** on
`get`, `update`, and `delete`: the `bool` says "did it exist?" - how the
handlers know whether to return a 404. `create` builds the `Article` with the
server-assigned ID, never trusting the client to pick one.

> 💡 The methods returning `bool` instead of an `error` is deliberate: "not
> found" isn't really an error here, it's a normal outcome the handler maps to a
> 404.

## The five handlers

Now the heart of it. Each handler is a closure over the store so it can reach the
data, and each one is a plain `func(w http.ResponseWriter, r *http.Request)`. We
reuse the `writeJSON` helper from Phase 4 verbatim - here it is again so this
phase stands alone:

```go
func writeJSON(w http.ResponseWriter, status int, v any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(v)
}
```

*What just happened:* exactly the Phase 4 helper - set the header, write the
status, encode the body, in that locked order. All five handlers respond
*through* it and never repeat that dance.

We'll also need one tiny shared step: pulling the `id` out of the URL and turning
it into an int. Three of the five handlers do this, so look at it once here and
recognize it when it reappears:

```go
idStr := chi.URLParam(r, "id")
id, err := strconv.Atoi(idStr)
if err != nil {
    http.Error(w, "id must be a number", http.StatusBadRequest)
    return
}
```

*What just happened:* `chi.URLParam(r, "id")` reads the `{id}` segment from the
path (chi's one I/O helper). It always hands back a **string**, so
`strconv.Atoi` converts it to an int. If the URL had `/articles/abc`, `Atoi`
fails and we return **400** - a non-numeric id is the client's mistake, not the
server's.

Now the handlers themselves.

### list - GET the whole collection (200)

```go
func listArticles(s *store) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        writeJSON(w, http.StatusOK, s.list())
    }
}
```

*What just happened:* the simplest one. `listArticles` is a function that
*returns* a handler (a closure capturing `s`). The handler asks the store for
every article and writes them as a JSON array with **200 OK**. Even when the list
is empty, `list()` returns a non-nil empty slice, so the client gets `[]`, not
`null` - a small kindness against special-casing the empty case.

### get - GET one by id (200 or 404)

```go
func getArticle(s *store) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        id, err := strconv.Atoi(chi.URLParam(r, "id"))
        if err != nil {
            http.Error(w, "id must be a number", http.StatusBadRequest)
            return
        }

        a, ok := s.get(id)
        if !ok {
            http.Error(w, "article not found", http.StatusNotFound)
            return
        }

        writeJSON(w, http.StatusOK, a)
    }
}
```

*What just happened:* parse the id (400 if it's not a number), then ask the
store. The store's `bool` does the work: if `ok` is false, the article doesn't
exist and we return **404** and stop; otherwise we write the single article with
**200**. The canonical get-one shape - two ways to fail, one way to succeed.

### create - POST a new one (201, with decode + manual validation)

```go
func createArticle(s *store) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        var in struct {
            Title string `json:"title"`
            Body  string `json:"body"`
        }
        if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
            http.Error(w, "invalid JSON body", http.StatusBadRequest)
            return
        }

        if in.Title == "" {
            http.Error(w, "title is required", http.StatusBadRequest)
            return
        }

        a := s.create(in.Title, in.Body)
        writeJSON(w, http.StatusCreated, a)
    }
}
```

*What just happened:* the busiest handler, and it earns it. First, decode the
request body into an **input struct** (`in`) - note it has no `ID` field, because
the client doesn't get to choose the id. A decode failure is malformed JSON, so
**400**. Next, the part the stdlib won't do for you: **validation by hand.** We
check `in.Title == ""` and reject empty titles with a 400 (add more checks here
as your rules grow). Finally, `s.create` stores it with a fresh server-assigned
ID and we reply **201 Created** with the full new article, so the client learns
the id it was given.

### update - PUT to replace one (200 or 404)

```go
func updateArticle(s *store) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        id, err := strconv.Atoi(chi.URLParam(r, "id"))
        if err != nil {
            http.Error(w, "id must be a number", http.StatusBadRequest)
            return
        }

        var in struct {
            Title string `json:"title"`
            Body  string `json:"body"`
        }
        if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
            http.Error(w, "invalid JSON body", http.StatusBadRequest)
            return
        }
        if in.Title == "" {
            http.Error(w, "title is required", http.StatusBadRequest)
            return
        }

        a, ok := s.update(id, in.Title, in.Body)
        if !ok {
            http.Error(w, "article not found", http.StatusNotFound)
            return
        }

        writeJSON(w, http.StatusOK, a)
    }
}
```

*What just happened:* update is "get-one and create had a baby" - it parses the
id *and* decodes a body, validating both. The store's `update` returns the same
`(value, bool)`: if the id doesn't exist, **404**; otherwise the article is
replaced and we return the updated version with **200**. We use `PUT` here,
meaning "replace the whole article with this." (A partial update would be
`PATCH`, which is fiddlier because you must distinguish "field omitted" from
"field set to empty"; PUT sidesteps that.)

### delete - DELETE one (204 or 404)

```go
func deleteArticle(s *store) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        id, err := strconv.Atoi(chi.URLParam(r, "id"))
        if err != nil {
            http.Error(w, "id must be a number", http.StatusBadRequest)
            return
        }

        if !s.delete(id) {
            http.Error(w, "article not found", http.StatusNotFound)
            return
        }

        w.WriteHeader(http.StatusNoContent)
    }
}
```

*What just happened:* parse the id, ask the store to delete. If it wasn't there,
**404**. If it was, we set **204 No Content** and write **nothing** - no
`writeJSON`, no body at all, because 204 means "success, and there's nothing to
send back" (the empty-body rule from Phase 4; deletes are its textbook use).

## Wiring it up with a sub-router

Five handlers, and now the routing that connects HTTP methods and paths to them.
This is where Phase 2's `r.Route` shines: we mount the whole resource under one
path prefix and nest the per-id routes inside it.

```go
func main() {
    s := newStore()

    r := chi.NewRouter()
    r.Use(middleware.Logger)

    r.Route("/api/v1/articles", func(r chi.Router) {
        r.Get("/", listArticles(s))
        r.Post("/", createArticle(s))

        r.Route("/{id}", func(r chi.Router) {
            r.Get("/", getArticle(s))
            r.Put("/", updateArticle(s))
            r.Delete("/", deleteArticle(s))
        })
    })

    http.ListenAndServe(":3000", r)
}
```

*What just happened:* one store, one router, and the resource laid out as a tree.
The outer `r.Route("/api/v1/articles", ...)` groups everything under that prefix.
Inside it, `Get("/")` and `Post("/")` handle the **collection** (`/api/v1/articles`
itself) - list and create. The nested `r.Route("/{id}", ...)` handles a **single
item** (`/api/v1/articles/42`), with `Get`/`Put`/`Delete` mapping to get/update/
delete. Read the registration top to bottom and it *is* the REST table - methods
on the left, handlers on the right, paths from the nesting. `middleware.Logger`
wraps the whole thing so every request gets logged. Each handler is called with
`s` to produce the actual `http.HandlerFunc`, threading the shared store into
all five.

> 💡 The version prefix `/api/v1/` is a cheap insurance policy. When you
> eventually ship a breaking change, you add `/api/v2/` alongside it and old
> clients keep working. Costs you nothing today; saves you a migration headache
> later.

## Driving it with curl

Start the server and exercise the whole lifecycle. Here's the tour, request and
response side by side:

```bash
# Create an article -> 201
$ curl -s -X POST localhost:3000/api/v1/articles \
    -H 'Content-Type: application/json' \
    -d '{"title":"Hello chi","body":"My first article."}'
{"id":1,"title":"Hello chi","body":"My first article."}

# List them all -> 200
$ curl -s localhost:3000/api/v1/articles
[{"id":1,"title":"Hello chi","body":"My first article."}]

# Get one by id -> 200
$ curl -s localhost:3000/api/v1/articles/1
{"id":1,"title":"Hello chi","body":"My first article."}

# Update it -> 200
$ curl -s -X PUT localhost:3000/api/v1/articles/1 \
    -H 'Content-Type: application/json' \
    -d '{"title":"Hello chi (edited)","body":"Now with edits."}'
{"id":1,"title":"Hello chi (edited)","body":"Now with edits."}

# Delete it -> 204 (no body). Show the status code to prove it:
$ curl -s -o /dev/null -w '%{http_code}\n' -X DELETE localhost:3000/api/v1/articles/1
204

# Ask for it again -> 404
$ curl -s localhost:3000/api/v1/articles/1
article not found
```

*What just happened:* the full CRUD cycle, every status code from our handlers
showing up exactly where designed. Create gave a 201 and echoed back the id the
server assigned. List returned a JSON array. The DELETE returns no body, so we
used `-w '%{http_code}'` to print the bare status (204) and confirm it. The
final GET after the delete returns the 404 plain-text message from
`http.Error` - proof the article is really gone. POST a body with no title and
you get a 400 ("title is required"); GET `/api/v1/articles/abc` and you get a
400 ("id must be a number").

## The store is a stand-in

One last point, and it's the important one for where you're headed.

> 💡 **That in-memory store is a database stand-in.** We used a map + mutex so
> this phase could be about the *API shape* without dragging in SQL. But look at
> the five handlers: not one of them knows or cares that the data lives in a map.
> They call `s.list()`, `s.get(id)`, `s.create(...)`, `s.update(...)`,
> `s.delete(id)` - five methods. Swap the store's *insides* for real persistence
> with [GORM](/guides/gorm-from-zero) and those five methods become database
> queries, while the handlers, the routing, and the validation **barely change.**
> The mutex disappears (the database handles concurrency), but the seams you've
> drawn here are exactly the seams a real app uses.

The next phase makes that separation official: how to lay out handlers and
services in real files, how to pass dependencies cleanly with `context`, and how
to test all of this with `httptest` so you never have to `curl` by hand again.

## Recap

- A REST resource is **five plain `http.HandlerFunc`s over one collection**,
  mounted on a sub-router - same shape as Gin/Echo, but pure stdlib + chi
  routing, no framework context.
- The in-memory store is a `map[int]Article` guarded by a `sync.RWMutex`.
  ⚠️ `net/http` serves requests concurrently, so an unguarded map will panic - 
  `RLock` to read, `Lock` to write.
- Read the id with `chi.URLParam(r, "id")` then `strconv.Atoi` (400 if it's not
  a number); decode bodies with `json.NewDecoder(r.Body).Decode`; reply through
  the Phase-4 `writeJSON` helper.
- Status codes map cleanly: list/get/update **200**, create **201**, delete
  **204** (no body), missing item **404**, bad input **400**. Validation is by
  hand - the stdlib won't do it for you.
- The `(value, bool)` pattern from the store methods is what drives the 404
  decision in the handlers.
- The store is a **database stand-in** - swap in [GORM](/guides/gorm-from-zero)
  later and the handlers barely change, because the store/handler seam is the
  real one.

## Quick check

```quiz
[
  {
    "q": "Why must the in-memory map be guarded by a sync.RWMutex?",
    "choices": ["Maps are slow without a lock", "net/http serves each request on its own goroutine, so concurrent map writes would panic", "chi requires a mutex on every handler", "It makes JSON encoding thread-safe"],
    "answer": 1,
    "explain": "net/http handles requests concurrently on separate goroutines. A plain Go map is not safe for concurrent read/write and will panic, so every access is guarded - RLock to read, Lock to write."
  },
  {
    "q": "A successful DELETE handler should return which status, and with what body?",
    "choices": ["200 OK with the deleted article as JSON", "404 Not Found with no body", "204 No Content with no body at all", "201 Created with an empty object"],
    "answer": 2,
    "explain": "A successful delete returns 204 No Content and writes nothing - 204 means success with no body, so you set the status and stop (no writeJSON)."
  },
  {
    "q": "In createArticle, why decode into a small input struct with only Title and Body instead of straight into Article?",
    "choices": ["Article has too many fields to decode", "encoding/json can't decode into a struct with an int field", "The client doesn't get to pick the id - the server assigns it, so ID isn't accepted from the body", "It makes the response faster"],
    "answer": 2,
    "explain": "The input struct is 'what the client may send.' Leaving ID out means the client can't set it; the server assigns the id in s.create, keeping it authoritative."
  }
]
```


---

# Structuring & Testing

You've built the whole articles API in one file. That's the right way to start - one file you can read top to bottom beats a maze of folders you have to keep jumping between. But around the time you add a second resource, or your fourth handler reaches for the same store, the single-file version starts to creak. This phase is about the move that happens next, done in the way that won't bite you later.

The mental model to hold onto: **handlers stay thin, and their dependencies are explicit.** A handler's job is to read the request, call something that does the real work, and write a response. The "something" - your store, a logger, a config - should be handed to the handler on purpose, not reached for through a package-level global. The idiom Go reaches for here is a **struct that holds the dependencies, with methods that *are* your handlers**. Wire it once in `main`, and every handler gets what it needs through the receiver - no globals, no magic.

## Handlers as methods on a struct

Here's the shape. A `Handler` struct holds whatever the handlers need - for the articles API, that's the store. The handlers become methods on it.

```go
// handlers/handlers.go
package handlers

import (
    "encoding/json"
    "net/http"

    "github.com/go-chi/chi/v5"
    "yourmodule/store"
)

type Handler struct {
    Store *store.ArticleStore
}

func New(s *store.ArticleStore) *Handler {
    return &Handler{Store: s}
}

func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) {
    id := chi.URLParam(r, "id")
    a, ok := h.Store.Get(id)
    if !ok {
        http.Error(w, "not found", http.StatusNotFound)
        return
    }
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(a)
}
```

*What just happened:* `GetArticle` is a method on `*Handler`, but its signature is still exactly `func(http.ResponseWriter, *http.Request)` - a plain `http.HandlerFunc`. The receiver `h` is how the handler reaches the store. There's no package-level `var store` anywhere; the dependency arrived through `h.Store`, set when we built the `Handler`. Dependency injection, minus the ceremony.

💡 Why a method instead of a free function that takes the store as an argument? Because `http.HandlerFunc` is fixed at `(w, r)` - you can't add a `store` parameter and still satisfy the interface. Hanging the handler off a struct gives it access to dependencies *without* changing its signature.

Now wire it in `main`:

```go
// main.go
package main

import (
    "log"
    "net/http"

    "github.com/go-chi/chi/v5"
    "github.com/go-chi/chi/v5/middleware"
    "yourmodule/handlers"
    "yourmodule/store"
)

func newRouter(s *store.ArticleStore) http.Handler {
    h := handlers.New(s)

    r := chi.NewRouter()
    r.Use(middleware.Logger)

    r.Route("/api/v1/articles", func(r chi.Router) {
        r.Get("/", h.ListArticles)
        r.Post("/", h.CreateArticle)
        r.Get("/{id}", h.GetArticle)
        r.Put("/{id}", h.UpdateArticle)
        r.Delete("/{id}", h.DeleteArticle)
    })
    return r
}

func main() {
    s := store.New()
    log.Println("listening on :8080")
    log.Fatal(http.ListenAndServe(":8080", newRouter(s)))
}
```

*What just happened:* `main` does the wiring and nothing else - create the store, build the router, start the server. We pulled the router construction into its own `newRouter(s)` function that returns an `http.Handler`. That looks small, but it's the single most important move in this phase: because `newRouter` builds the *entire* application and hands you back an `http.Handler`, your tests can call it too and exercise the real thing.

📝 Notice `newRouter` returns `http.Handler`, not `*chi.Mux`. Callers (including `main` and your tests) only need the `http.Handler` behavior, so that's all you promise them. A chi router *is* an `http.Handler`, so this costs nothing.

## A layout that scales

You don't need a folder for everything on day one. But once you split, a conventional Go web layout looks like this:

```
articles-api/
  go.mod
  main.go            ← build the router, wire dependencies, start the server
  handlers/
    handlers.go      ← the Handler struct + its method handlers
  store/
    store.go         ← ArticleStore: Get/List/Create/Update/Delete
  models/
    article.go       ← the Article struct (shared shape)
```

The dependency arrows all point one way: `main` imports `handlers` and `store`; `handlers` imports `store` and `models`; `store` imports `models`; `models` imports nothing. Keep it acyclic and Go stays happy (it refuses to compile import cycles anyway, which is a feature).

⚠️ Don't over-split early. A `services/`, `repository/`, `dto/`, `interfaces/` tower of folders for a CRUD app with one resource is cosplay, not architecture. Start with the four above, and only add a layer when a real second use forces it - the layout should follow the code, not lead it.

## Request-scoped values through context, done right

Some data isn't part of the URL or the body - it belongs to *this request* and needs to travel from a middleware down to a handler. The authenticated user. A request ID for tracing. The standard library's answer is `context.Context`, which rides along on every `*http.Request`.

A middleware computes the value and stashes it; the handler reads it back out. Here's a request-ID example:

```go
// middleware/requestid.go
package mw

import (
    "context"
    "net/http"

    "github.com/google/uuid"
)

type ctxKey int

const requestIDKey ctxKey = 0

func RequestID(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        id := uuid.NewString()
        ctx := context.WithValue(r.Context(), requestIDKey, id)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

// RequestIDFrom pulls the request ID back out, in a handler.
func RequestIDFrom(ctx context.Context) (string, bool) {
    id, ok := ctx.Value(requestIDKey).(string)
    return id, ok
}
```

*What just happened:* `context.WithValue` returns a *new* context carrying the value, and `r.WithContext(ctx)` returns a *new* request wrapping it - contexts and requests are immutable, so you always build a new one and pass it forward via `next.ServeHTTP`. Downstream handlers call `r.Context().Value(requestIDKey)` (wrapped here in the tidy `RequestIDFrom` helper) to read it back.

⚠️ The trap that bites everyone: **never use a bare string as the context key.** If you write `context.WithValue(ctx, "user", u)` and some library you import also writes `context.WithValue(ctx, "user", somethingElse)`, the keys collide and silently clobber each other - no compile error, just a baffling runtime bug. The fix is an **unexported custom key type**: `type ctxKey int` lives only in your package, so no other package can ever produce a value equal to your `requestIDKey`. That makes collisions impossible by construction.

Here's the handler side reading the value:

```go
func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) {
    if id, ok := mw.RequestIDFrom(r.Context()); ok {
        w.Header().Set("X-Request-ID", id)
    }
    // ... rest of the handler
}
```

*What just happened:* the handler never knew or cared *how* the request ID got there - it just asks the context. That's the payoff of the pattern: middleware and handler are decoupled, talking only through a typed, collision-proof key.

💡 Context carries more than your values. `r.Context()` also propagates **cancellation and deadlines**. If the client hangs up, or you wrap a route in `middleware.Timeout(2 * time.Second)`, the context's `Done()` channel fires - and any database driver or `http.Client` call that respects `context` will abort instead of running forever. Passing `r.Context()` down into your store and outbound calls is what makes that work: free request-scoped cancellation, as long as you thread the context through.

## Testing the whole router with httptest

Now collect on the promise from `newRouter`. Because it returns an `http.Handler`, and because the standard library ships `net/http/httptest`, you can test the *entire* stack - routing, middleware, and handler - without ever opening a real socket.

```go
// handlers/handlers_test.go
package handlers_test

import (
    "net/http"
    "net/http/httptest"
    "testing"
)

func TestGetArticle(t *testing.T) {
    r := newRouter(seedStore())   // your real router builder, seeded store
    req := httptest.NewRequest(http.MethodGet, "/api/v1/articles/1", nil)
    rec := httptest.NewRecorder()

    r.ServeHTTP(rec, req)

    if rec.Code != http.StatusOK {
        t.Fatalf("got %d", rec.Code)
    }
}
```

*What just happened:* `httptest.NewRequest` builds a `*http.Request` in memory, `httptest.NewRecorder` is a fake `http.ResponseWriter` that captures what the handler writes, and `r.ServeHTTP(rec, req)` runs the request through the real router exactly as a live server would. Afterward, `rec.Code`, `rec.Body`, and `rec.Header()` hold everything the handler produced. No port, no network, no flakiness.

⚠️ Here's the part people get wrong, and it's specific to routed frameworks like chi: **route through `r.ServeHTTP`, not by calling the handler directly.** It's tempting to write `h.GetArticle(rec, req)` and skip the router. Don't - `chi.URLParam(r, "id")` reads the `id` from chi's *route context*, which only gets attached when the request passes through the router that matched the pattern `/{id}`. Call the handler directly and that context is missing: `chi.URLParam` returns an empty string and your test exercises a code path that never happens in production. Test the router, not the bare function.

Testing a POST is the same shape, with a body and a header:

```go
import "strings"

func TestCreateArticle(t *testing.T) {
    r := newRouter(seedStore())
    body := `{"title":"Hello","body":"World"}`
    req := httptest.NewRequest(http.MethodPost, "/api/v1/articles", strings.NewReader(body))
    req.Header.Set("Content-Type", "application/json")
    rec := httptest.NewRecorder()

    r.ServeHTTP(rec, req)

    if rec.Code != http.StatusCreated {
        t.Fatalf("got %d, body: %s", rec.Code, rec.Body.String())
    }
}
```

*What just happened:* `strings.NewReader(body)` gives the request a JSON body to read, and `req.Header.Set("Content-Type", "application/json")` mirrors what a real client sends - if your handler checks the content type, the test now satisfies it. The router runs the full POST path: middleware, JSON decode, store write, `201 Created`. Including `rec.Body.String()` in the failure message means a broken test tells you *why* instead of just *what*.

💡 Step back and notice what you *didn't* have to learn. There's no chi-specific test harness, no special `TestClient`, no framework mock. Because your handlers are plain `net/http` handlers and your router is a plain `http.Handler`, your tests are plain `net/http` too - `httptest` is the standard library testing the standard library. That's the dividend chi pays for staying compatible: the skills transfer in both directions.

## Recap

- **Thin handlers, explicit dependencies:** a `Handler` struct holds the store; its methods *are* your `http.HandlerFunc`s, getting dependencies through the receiver instead of globals.
- **Wire once in `main`**, and pull router construction into a `newRouter(s) http.Handler` function so tests can build the real application too.
- **A small package layout** - `main.go`, `handlers/`, `store/`, `models/` - scales fine; don't add layers until a real second use demands them.
- **Context carries request-scoped values** set in middleware and read in handlers - always with an **unexported custom key type** (`type ctxKey int`), never a bare string, to make collisions impossible.
- **`r.Context()` also carries cancellation and deadlines**, so threading it down into stores and outbound calls gives you free timeout propagation.
- **Test through the real router** with `httptest`: `r.ServeHTTP(rec, req)` exercises routing + middleware + handler, and is the only way `chi.URLParam` resolves - calling the handler directly leaves chi's route context empty.

## Quick check

```quiz
[
  {
    "q": "Why hang handlers off a Handler struct as methods instead of using package-level globals for the store?",
    "choices": ["chi requires handlers to be methods", "It injects dependencies through the receiver without changing the http.HandlerFunc signature", "Methods run faster than functions in Go", "Globals are not allowed in Go programs"],
    "answer": 1,
    "explain": "http.HandlerFunc is fixed at (w, r), so you can't add a store parameter. A method gets the dependency via its receiver while keeping the required signature."
  },
  {
    "q": "What's the danger of using a bare string as a context.WithValue key?",
    "choices": ["Strings are too slow as map keys", "Another package using the same string key silently collides and clobbers your value", "context.WithValue rejects string keys at compile time", "Strings can't be read back with Value()"],
    "answer": 1,
    "explain": "Two packages using the same string key collide with no compile error. An unexported custom key type (type ctxKey int) makes collisions impossible by construction."
  },
  {
    "q": "Why test through r.ServeHTTP rather than calling h.GetArticle(rec, req) directly?",
    "choices": ["Calling the handler directly panics", "Only r.ServeHTTP can use httptest.NewRecorder", "Routing through the real router attaches chi's route context, so chi.URLParam resolves the {id}", "Direct calls skip JSON encoding"],
    "answer": 2,
    "explain": "chi.URLParam reads from the route context that only gets attached when the request passes through the matching router. Call the handler directly and URLParam returns an empty string."
  }
]
```


---

# Where to Go Next

Stop and look at the pile of things you can do now. Route requests and pull `{id}` params, compose sub-routers with `Route` and `Mount`, stack middleware as plain `func(http.Handler) http.Handler` wrappers, decode and encode JSON with nothing but the standard library, and build, structure, and test a full REST API for the articles resource. That's a real service, not a toy.

And here's the quieter win, the one that outlasts this guide. Because chi is *barely a framework* - a router and a middleware helper, both built from standard pieces - you didn't only learn chi. You learned idiomatic `net/http`. Your handlers are `http.HandlerFunc`. Your middleware is the standard wrapper shape. Your router *is* an `http.Handler`. Strip chi out and most of what you wrote still makes sense, because it was standard-library code the whole time.

So this last phase isn't more handlers. It's the map: where chi sits among the other Go web frameworks, a clear word about a recent change to the standard library that affects the whole pitch, the layer you'll almost certainly add next, and one concrete thing to go build.

## chi vs the field

The good news in Go: these frameworks are far more alike than the JavaScript world's are. They nearly all sit on `net/http`, they all do routing, params, and middleware. The differences are about *feel*, not different universes.

```mermaid
flowchart TD
  Start[Need a Go web service?] --> Pure{Want stdlib purity?}
  Pure -- "Yes, and richer routing/middleware" --> Chi[chi]
  Pure -- "Yes, and the case is simple" --> Std[net/http alone]
  Pure -- "No, want batteries" --> Style{Handler style?}
  Style -- "Context helpers" --> Gin[Gin]
  Style -- "Return an error" --> Echo[Echo]
```

A line on each:

- **chi** - minimal and proudly so, a router that stays *pure* `net/http`. Handlers are plain `http.HandlerFunc`, middleware is the standard `func(http.Handler) http.Handler`, and there's no special context to learn. Nothing to unlearn, nothing locked in.
- **Gin** - the most popular, the biggest ecosystem, the most Stack Overflow answers. Handlers take a `*gin.Context` and write to it. Reach for it when you want batteries and the largest community. See [Gin From Zero](/guides/gin-from-zero).
- **Echo** - close to Gin in spirit, with one stylistic twist: its handlers *return* an `error` (`func(c echo.Context) error`) instead of writing failures into a context, and it ships a bit more built-in middleware. See [Echo From Zero](/guides/echo-from-zero).
- **The standard library alone** - for many services, plain `net/http` is genuinely enough now (more below). See [Web Services With Only net/http](/guides/web-services-with-only-net-http).

> 💡 How to pick: reach for **chi** when you want stdlib purity *plus* its router and middleware ergonomics - sub-router composition, richer path patterns, a clean middleware stack. Reach for **Gin** or **Echo** when you want batteries (binding, validation, more helpers) baked in. Reach for **plain net/http** when the service is simple and you'd rather not add a dependency at all.

📝 None of these is "the best." The senior instinct isn't memorizing a winner - it's asking "best for *this* job?" and being able to answer straight. You have the pieces for that now.

## The plain part: Go 1.22 changed the math

Here's the thing a guide that respects you has to say out loud. chi's headline advantage, for years, was routing - the standard `http.ServeMux` couldn't match a method, couldn't capture path params, so you reached for a router. **Go 1.22 closed a lot of that gap.**

The standard mux now understands method-and-path patterns and captures path values:

```go
mux := http.NewServeMux()
mux.HandleFunc("GET /articles/{id}", func(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id") // the captured {id}, no library needed
    fmt.Fprintf(w, "article %s", id)
})
```

That's the exact problem chi was invented to solve, now in the standard library. So the real question is: *do you even need chi?*

For a simple service - a handful of routes, basic params - the answer today is often **no**. Plain `net/http` will carry it, and the [net/http roots guide](/guides/web-services-with-only-net-http) shows how far that goes.

⚠️ But "the gap narrowed" isn't "the gap closed." chi still earns its keep where the stdlib stays thin:

- **Middleware as a first-class stack.** `r.Use(...)`, per-route stacks, and a batteries-included set (request ID, real-IP, recoverer, structured logging) - the standard mux gives you none of that; you wire it by hand.
- **Sub-router composition.** `Route` and `Mount` let you build and nest whole routers, mount one under a path prefix, and give a subtree its own middleware. Hand-rolling that on `ServeMux` gets old fast.
- **Richer routing features** beyond the basics, plus a clean place for shared logic.

> 💡 The practical rule of thumb: **simple service → plain net/http is probably enough now. Real middleware needs or composed sub-routers → chi still wins** - and because chi *is* net/http underneath, you can start on the standard mux and adopt chi later without rewriting your handlers. That's the whole point of staying compatible.

## The layer you'll add next: a real database

Every API in this guide stored articles in memory. Perfect for learning, useless in production - restart the server and the data's gone. The next thing almost every real service grows is a **database**.

Here's the reassuring part: your handlers barely change. Remember how Phase 6 kept the HTTP logic separate from where data lived, behind a store? That pays off right here. The handler still decodes JSON, validates, calls the store, and writes a response - all that swaps underneath is the store, from a map to a database-backed one.

[GORM From Zero](/guides/gorm-from-zero) is the natural next read. GORM is Go's most popular ORM: define your `Article` struct, point it at SQLite (Postgres later), and your create/read/update/delete calls become real persistence. The Phase 5 handlers stay exactly the same - you're replacing the bottom layer, not rewriting the top.

## What to build

Reading more won't make this stick. Building one real thing will. So here's the assignment, and it's deliberately concrete.

Take the **articles API** you grew across this guide and carry it all the way home:

- **Swap the in-memory store for GORM + SQLite** so articles survive a restart. The handlers stay; the store changes. ([GORM From Zero](/guides/gorm-from-zero) walks the persistence part.)
- **Add auth middleware** so each request proves who it is - exactly the `func(http.Handler) http.Handler` pattern from Phase 3, applied to a real job.
- **Add request logging** (chi ships a logger and a recoverer; mount them and watch your service narrate itself).
- **Deploy it** somewhere you can hit from your phone.

If the articles API feels too familiar, build something small and new end to end instead - a **URL shortener** or a **notes API**. Same muscles: routes, sub-routers, a store, middleware, tests, deploy. Finishing one project completely teaches more than three more tutorials would.

## The clear-eyed close

chi was never magic - it was barely even a framework. A router and a middleware helper, both made of standard parts. That smallness was the lesson, not a limit. Because the framework hid almost nothing, what you actually learned was the **standard library**: `http.HandlerFunc`, `http.Handler`, the middleware wrapper, `context` values, `httptest`.

That's knowledge that outlives any framework. Go 1.22 can absorb half of chi's old job and you shrug, because you understand the layer underneath. Switch to Gin or Echo tomorrow and you'll read them faster, because you know what a router and a context and a middleware chain really are.

So go finish the articles API. Give it a database, lock it behind auth, watch the logs, deploy it, show someone. You're ready.

## Recap

1. **You can ship a real chi API** - routed, sub-routed, middleware-wrapped, JSON in and out with the stdlib, structured and tested. And since chi is barely a framework, you mostly learned idiomatic `net/http` along the way.
2. **Choose on purpose** - chi for stdlib purity *plus* router/middleware ergonomics; Gin or Echo for batteries (Echo if you like error-returning handlers); plain `net/http` when the case is simple.
3. **Go 1.22 narrowed chi's edge** - the standard `http.ServeMux` now matches `GET /articles/{id}` patterns and exposes `r.PathValue("id")`, so simple services often need no router at all.
4. **chi still wins on middleware and composition** - a first-class middleware stack plus `Route`/`Mount` sub-routers are where the standard mux stays thin; and because chi *is* net/http, you can adopt it later without rewriting handlers.
5. **A database is the next layer** - most services add one, and with the Phase 6 store separation in place your handlers barely change; swap the map for GORM + SQLite.
6. **Build and finish one thing** - carry the articles API to GORM, auth middleware, request logging, and a deploy. Finishing beats more tutorials.

## Quick check

Three decisions to take with you as you leave this guide:

```quiz
[
  {
    "q": "Since Go 1.22, why might a simple service not need chi at all?",
    "choices": [
      "Go 1.22 deleted net/http, so chi is the only option",
      "The standard http.ServeMux now matches method+path patterns like \"GET /articles/{id}\" and exposes params via r.PathValue, covering basic routing",
      "chi stopped being maintained in Go 1.22",
      "Go 1.22 added a built-in ORM that replaces routers"
    ],
    "answer": 1,
    "explain": "Go 1.22 improved the standard mux to match method+path patterns and read path values with r.PathValue, which was chi's main historical advantage. For simple routing, plain net/http is now often enough."
  },
  {
    "q": "Given the Go 1.22 update, where does chi still clearly earn its place?",
    "choices": [
      "Nowhere - chi is now obsolete",
      "Only for serving static files",
      "Its first-class middleware stack and sub-router composition with Route/Mount, which the standard mux doesn't give you",
      "Because it replaces net/http with a faster engine"
    ],
    "answer": 2,
    "explain": "The standard mux still leaves middleware and router composition to you. chi's first-class middleware stack and Route/Mount sub-routers are where it stays ahead - and since chi is net/http underneath, you can adopt it later without rewriting handlers."
  },
  {
    "q": "You're adding a real database to your articles API from Phase 6. What mostly changes?",
    "choices": [
      "Every handler must be rewritten from scratch",
      "Only the store layer swaps from an in-memory map to a GORM-backed one; the Phase 5 handlers stay the same",
      "You must abandon chi and switch to Gin",
      "Nothing - chi stores data in a database automatically"
    ],
    "answer": 1,
    "explain": "Because Phase 6 kept HTTP logic separate from where data lives, the handlers still decode, validate, call a store, and respond. You swap the store from a map to GORM + SQLite - the bottom layer changes, the handlers stay."
  }
]
```
