Oct 27, 20267 min read

Go Chi Tutorial: Routing, Middleware and a REST API

Build a small JSON API with the chi router: routes, URL parameters, middleware, sub-routers and handlers you can test with curl.

chi is a small, composable router for Go that builds on the standard net/http package. Handlers are ordinary http.HandlerFunc values, so anything written for net/http works with chi, and chi adds the parts the standard library leaves out: URL parameters with patterns, middleware stacks per router, route groups and sub-routers.

This tutorial builds a small todo API with chi v5. You will set up routing, read URL parameters, add middleware, write JSON handlers and test everything locally with go run and curl. At the end, the same service is built with Encore, which adds a Postgres database and Pub/Sub and deploys it to your own AWS or GCP account. For a broader comparison of the two, see Chi vs Encore.go.

Install chi

Create a module and add chi:

mkdir todos && cd todos go mod init example.com/todos go get github.com/go-chi/chi/v5

The examples below use chi v5.5.0 and Go 1.27.

Your first chi router

A chi router is an http.Handler, so you pass it straight to http.ListenAndServe:

package main import ( "net/http" "github.com/go-chi/chi/v5" ) func main() { r := chi.NewRouter() r.Get("/health", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) }) http.ListenAndServe(":3000", r) }

There is a method for each HTTP verb: r.Get, r.Post, r.Put, r.Patch and r.Delete. Use r.Method("OPTIONS", ...) or r.Handle for anything else. A request to a path with no route returns 404, and a known path with the wrong method returns 405.

URL parameters

Wrap a path segment in braces to capture it, and read it with chi.URLParam:

r.Get("/todos/{id}", func(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") w.Write([]byte("todo " + id)) })

Parameters are strings. Add a regular expression after a colon to restrict what matches, so /todos/abc returns 404 before your handler runs:

r.Get("/todos/{id:[0-9]+}", getTodo)

Middleware

chi middleware has the standard signature func(http.Handler) http.Handler. r.Use adds middleware to every route on the router, in order. The middleware package ships common ones:

import "github.com/go-chi/chi/v5/middleware" r.Use(middleware.RequestID) // sets a request ID on the context r.Use(middleware.Logger) // logs method, path, status and duration r.Use(middleware.Recoverer) // turns panics into a 500 response r.Use(middleware.Timeout(10 * time.Second)) // cancels the request context after 10s

Writing your own is a function that wraps the next handler:

func requireAPIKey(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("X-API-Key") != "secret" { http.Error(w, "unauthorized", http.StatusUnauthorized) return } next.ServeHTTP(w, r) }) }

To apply middleware to only some routes, use r.With for a single route or r.Group for several:

r.With(requireAPIKey).Delete("/todos/{id}", deleteTodo) r.Group(func(r chi.Router) { r.Use(requireAPIKey) r.Post("/todos", createTodo) r.Delete("/todos/{id}", deleteTodo) })

Route groups and sub-routers

r.Route creates a sub-router under a path prefix, which keeps related routes together:

r.Route("/todos", func(r chi.Router) { r.Get("/", listTodos) r.Post("/", createTodo) r.Route("/{id}", func(r chi.Router) { r.Get("/", getTodo) r.Delete("/", deleteTodo) }) })

r.Mount attaches any http.Handler, including a router built in another package, at a prefix:

func adminRouter() http.Handler { r := chi.NewRouter() r.Use(requireAPIKey) r.Get("/stats", stats) return r } r.Mount("/admin", adminRouter())

A complete chi REST API

Here is the full todo API in one file. It stores todos in memory, returns JSON, validates input and uses the middleware and sub-routers from above. Save it as main.go:

package main import ( "encoding/json" "errors" "log" "net/http" "strconv" "sync" "time" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" ) type Todo struct { ID int `json:"id"` Title string `json:"title"` Done bool `json:"done"` } type store struct { mu sync.Mutex nextID int todos map[int]Todo } func newStore() *store { return &store{nextID: 1, todos: map[int]Todo{}} } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(v) } func writeError(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, map[string]string{"error": msg}) } func (s *store) list(w http.ResponseWriter, r *http.Request) { s.mu.Lock() defer s.mu.Unlock() todos := make([]Todo, 0, len(s.todos)) for _, t := range s.todos { todos = append(todos, t) } writeJSON(w, http.StatusOK, todos) } func (s *store) create(w http.ResponseWriter, r *http.Request) { var in struct { Title string `json:"title"` } if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.Title == "" { writeError(w, http.StatusBadRequest, "title is required") return } s.mu.Lock() t := Todo{ID: s.nextID, Title: in.Title} s.todos[t.ID] = t s.nextID++ s.mu.Unlock() writeJSON(w, http.StatusCreated, t) } func (s *store) get(w http.ResponseWriter, r *http.Request) { t, err := s.find(r) if err != nil { writeError(w, http.StatusNotFound, err.Error()) return } writeJSON(w, http.StatusOK, t) } func (s *store) remove(w http.ResponseWriter, r *http.Request) { t, err := s.find(r) if err != nil { writeError(w, http.StatusNotFound, err.Error()) return } s.mu.Lock() delete(s.todos, t.ID) s.mu.Unlock() w.WriteHeader(http.StatusNoContent) } func (s *store) find(r *http.Request) (Todo, error) { id, err := strconv.Atoi(chi.URLParam(r, "id")) if err != nil { return Todo{}, errors.New("invalid id") } s.mu.Lock() defer s.mu.Unlock() t, ok := s.todos[id] if !ok { return Todo{}, errors.New("todo not found") } return t, nil } func main() { s := newStore() r := chi.NewRouter() r.Use(middleware.RequestID) r.Use(middleware.Logger) r.Use(middleware.Recoverer) r.Use(middleware.Timeout(10 * time.Second)) r.Get("/health", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) }) r.Route("/todos", func(r chi.Router) { r.Get("/", s.list) r.Post("/", s.create) r.Route("/{id:[0-9]+}", func(r chi.Router) { r.Get("/", s.get) r.Delete("/", s.remove) }) }) log.Println("listening on :3000") log.Fatal(http.ListenAndServe(":3000", r)) }

Start it:

go run .

In a second terminal, test it with curl:

curl -X POST localhost:3000/todos -d '{"title":"Write the chi tutorial"}' # {"id":1,"title":"Write the chi tutorial","done":false} curl localhost:3000/todos/1 # {"id":1,"title":"Write the chi tutorial","done":false} curl localhost:3000/todos/9 # {"error":"todo not found"} curl -i -X DELETE localhost:3000/todos/1 # HTTP/1.1 204 No Content

The server terminal shows a log line per request from middleware.Logger, with the request ID, status and duration.

chi vs the Go 1.22+ standard library router

Since Go 1.22, http.ServeMux matches methods and path wildcards, so a small service can skip a router entirely:

mux := http.NewServeMux() mux.HandleFunc("GET /todos/{id}", func(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") w.Write([]byte("todo " + id)) })

chi is still useful when you want:

  • Middleware per router and per group, instead of wrapping the whole mux or each handler by hand.
  • Sub-routers with Route and Mount, to split a large API across packages.
  • Regular expression parameters such as {id:[0-9]+}.
  • The bundled middleware for request IDs, logging, panics, timeouts, compression and more.

Both use the same handler type, so moving between them is mostly a change to how routes are registered. For other Go routers and frameworks, see Chi alternatives and the best Go backend frameworks.

The same service with Encore

The chi API above keeps todos in memory. A production version needs a database, migrations, probably a queue for events, and somewhere to deploy it. With Encore, you declare those in your Go code: endpoints are functions with an annotation, and the Postgres database and Pub/Sub topic are package-level variables. encore run starts the service locally with a real Postgres database and Pub/Sub, and when you deploy, Encore provisions them in your own AWS or GCP account.

todo/migrations/1_create_todo.up.sql:

CREATE TABLE todo ( id BIGSERIAL PRIMARY KEY, title TEXT NOT NULL, done BOOLEAN NOT NULL DEFAULT FALSE );

todo/todo.go:

package todo import ( "context" "errors" "encore.dev/beta/errs" "encore.dev/pubsub" "encore.dev/storage/sqldb" ) // Postgres database, created from the migrations directory. var db = sqldb.NewDatabase("todos", sqldb.DatabaseConfig{ Migrations: "./migrations", }) type TodoCreated struct { ID int `json:"id"` Title string `json:"title"` } // Pub/Sub topic that other services can subscribe to. var Created = pubsub.NewTopic[*TodoCreated]("todo-created", pubsub.TopicConfig{ DeliveryGuarantee: pubsub.AtLeastOnce, }) type Todo struct { ID int `json:"id"` Title string `json:"title"` Done bool `json:"done"` } type CreateParams struct { Title string `json:"title"` } //encore:api public method=POST path=/todos func Create(ctx context.Context, p *CreateParams) (*Todo, error) { if p.Title == "" { return nil, &errs.Error{Code: errs.InvalidArgument, Message: "title is required"} } t := &Todo{Title: p.Title} err := db.QueryRow(ctx, `INSERT INTO todo (title) VALUES ($1) RETURNING id, done`, p.Title, ).Scan(&t.ID, &t.Done) if err != nil { return nil, err } if _, err := Created.Publish(ctx, &TodoCreated{ID: t.ID, Title: t.Title}); err != nil { return nil, err } return t, nil } //encore:api public method=GET path=/todos/:id func Get(ctx context.Context, id int) (*Todo, error) { t := &Todo{ID: id} err := db.QueryRow(ctx, `SELECT title, done FROM todo WHERE id = $1`, id, ).Scan(&t.Title, &t.Done) if errors.Is(err, sqldb.ErrNoRows) { return nil, &errs.Error{Code: errs.NotFound, Message: "todo not found"} } return t, err }

What changes compared to the chi version:

  • Routing and JSON are handled by the framework. The //encore:api annotation defines the method and path, :id becomes a typed int parameter, and request and response structs are decoded and encoded for you. Invalid input such as /todos/abc is rejected before the function runs.
  • Errors map to HTTP status codes. errs.NotFound returns 404 and errs.InvalidArgument returns 400, with a JSON body.
  • Postgres and Pub/Sub run locally. encore run creates the database, applies the migration and starts a local Pub/Sub, and the local development dashboard shows traces for each request.
  • Deploys go to your own cloud. Encore provisions the database as Amazon RDS or Google Cloud SQL and the topic as SNS and SQS or GCP Pub/Sub in your own account.

Run it locally:

encore run curl -X POST localhost:4000/todos -d '{"title":"Write the chi tutorial"}' curl localhost:4000/todos/1

The Encore REST API tutorial builds a complete service step by step, and Chi vs Encore.go compares routing, middleware, databases and observability side by side. To try it, install the CLI and create an app:

Get started
brew install encoredev/tap/encore &&
encore app create
Copy

Run in your terminal to get started locally.

Frequently asked questions

What is chi in Go?

chi is a lightweight HTTP router for Go. It builds on the standard net/http package, so handlers are plain http.HandlerFunc values and any net/http middleware works with it. It adds URL parameters, route groups, sub-routers and a set of optional middleware.

How do I install chi?

Run go get github.com/go-chi/chi/v5 inside a Go module, then import github.com/go-chi/chi/v5 and, for the bundled middleware, github.com/go-chi/chi/v5/middleware.

How do I read a URL parameter in chi?

Declare the parameter in the route pattern, such as /todos/{id}, and read it in the handler with chi.URLParam(r, "id"). The value is a string, so convert it with strconv.Atoi or similar. You can restrict it with a regular expression, as in /todos/{id:[0-9]+}.

Do I still need chi now that Go 1.22 has method and wildcard routing?

For small services, the standard library ServeMux in Go 1.22 and later covers method matching and path wildcards. chi still adds middleware chaining per router, route groups, mounting sub-routers and regular expression parameters, which keeps larger route trees organized.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders