Updated Jun 23, 2026

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 fine, honestly — 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. It 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.

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:

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. You'll import it as the chi package. 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() {
    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; you use the function you already know.

Run it like any Go program:

go run main.go

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

curl localhost:3000/ping
$ 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, which is 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. You're not locked into a framework's plugin universe; you're plugged into the standard library's.

⚠️ 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 in exchange for zero magic and total portability. If you'd rather the framework do more of the boring parts for you, Gin is the guide to read. If you want the standard library with a real router bolted on, you're in the right place.

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 {
    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: when we write articles out as JSON and when we read them back in. Here's the type returning itself through a plain chi handler:

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. (You'd add "encoding/json" to your imports.) Notice there's 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 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:

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

Guide overview · Phase 2: Routing, URL Params & Sub-routers →

Check your understanding

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?

Was this page helpful?