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.
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.
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.
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)
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)
})
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())
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.
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:
Route and Mount, to split a large API across packages.{id:[0-9]+}.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 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:
//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.errs.NotFound returns 404 and errs.InvalidArgument returns 400, with a JSON body.encore run creates the database, applies the migration and starts a local Pub/Sub, and the local development dashboard shows traces for each request.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:
Run in your terminal to get started locally.
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.
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.
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]+}.
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.