What chi Is
You know Go, and you already like net/http. Maybe you've read the
net/http roots guide 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 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.
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:
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:
package main
import (
"net/http"
"github.com/go-chi/chi/v5"
)
func main()
What just happened: line by line -
chi.NewRouter()creates the router and returns achi.Router. We name itr.r.Get("/ping", ...)registers a route: when aGETarrives 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 plainhttp.HandlerFunc. No chi types in it at all.w.Write([]byte("pong"))writes the response body using the standardhttp.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'sListenAndServeis the handler, and we passr. The router is the handler. Because a chi router satisfies thehttp.Handlerinterface, you hand it straight to the standard library. There's nor.Run()wrapper to learn.
Run it like any Go program:
Leave it running, and in another terminal hit the route:
$ 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) 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 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:
type Article struct
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:
r.Get("/articles/sample", func(w http.ResponseWriter, req *http.Request) )
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:
$ 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 withgo 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 plainfunc(http.Handler) http.Handler, and a chi router is anhttp.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 withgo run main.go, test withcurl. - 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.JSONone-liner - you useencoding/jsondirectly). 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:
[
{
"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."
}
]
Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.
Check your understanding 3 questions
1. What is a chi route handler, in terms of types?
2. In `http.ListenAndServe(":3000", r)`, why can you pass the chi router `r` as the second argument?
3. Which is a real consequence of chi staying compatible with net/http?