Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/go-chi/chi/llms.txt

Use this file to discover all available pages before exploring further.

chi’s composable router model makes it straightforward to build well-structured REST APIs in Go. By combining middleware stacks, nested route groups, and context-loaded resources, you can keep your code organized as your service grows — all while staying 100% compatible with the standard net/http package.

Project setup

Start by installing chi v5 as a module dependency:
go get github.com/go-chi/chi/v5

Step 1 — Define your data model

Before wiring up routes, define the data structures and a simple in-memory store that the handlers will operate on.
main.go
package main

import (
	"errors"
	"fmt"
	"math/rand"
)

// Article is the core data model.
type Article struct {
	ID     string `json:"id"`
	UserID int64  `json:"user_id"`
	Title  string `json:"title"`
	Slug   string `json:"slug"`
}

// In-memory fixture data — replace with a real DB in production.
var articles = []*Article{
	{ID: "1", UserID: 100, Title: "Hi", Slug: "hi"},
	{ID: "2", UserID: 200, Title: "sup", Slug: "sup"},
	{ID: "3", UserID: 300, Title: "alo", Slug: "alo"},
}

func dbGetArticle(id string) (*Article, error) {
	for _, a := range articles {
		if a.ID == id {
			return a, nil
		}
	}
	return nil, errors.New("article not found")
}

func dbNewArticle(article *Article) (string, error) {
	article.ID = fmt.Sprintf("%d", rand.Intn(100)+10)
	articles = append(articles, article)
	return article.ID, nil
}

func dbUpdateArticle(id string, article *Article) (*Article, error) {
	for i, a := range articles {
		if a.ID == id {
			articles[i] = article
			return article, nil
		}
	}
	return nil, errors.New("article not found")
}

func dbRemoveArticle(id string) (*Article, error) {
	for i, a := range articles {
		if a.ID == id {
			articles = append(articles[:i], articles[i+1:]...)
			return a, nil
		}
	}
	return nil, errors.New("article not found")
}

Step 2 — Build the middleware stack

A good middleware stack handles cross-cutting concerns — request IDs, logging, panic recovery, and timeouts — before any route handler runs.
main.go
package main

import (
	"net/http"
	"time"

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

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

	// Core middleware applied to every request.
	r.Use(middleware.RequestID)
	r.Use(middleware.Logger)
	r.Use(middleware.Recoverer)

	// Signal ctx.Done() when the request exceeds 60 s.
	r.Use(middleware.Timeout(60 * time.Second))

	// ... routes registered below
	http.ListenAndServe(":3333", r)
}
middleware.Recoverer catches any handler panic, logs the stack trace, and returns a 500 — preventing a single bad request from crashing the server.

Step 3 — Add an ArticleCtx middleware

The ArticleCtx middleware loads an Article from the database using the {articleID} URL parameter and stores it on the request context. Downstream handlers simply read it from the context — they never need to look up the article themselves.
main.go
import "context"

// ArticleCtx loads the Article for the request and sets it on the context.
// If the article is not found, it stops the chain with a 404.
func ArticleCtx(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		articleID := chi.URLParam(r, "articleID")
		article, err := dbGetArticle(articleID)
		if err != nil {
			http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
			return
		}
		ctx := context.WithValue(r.Context(), "article", article)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}
In production, use a typed context key (e.g., a private struct type) instead of a plain string like "article". Bare string keys can collide with keys set by other packages. See the Context guide for details.

Step 4 — Implement the resource handlers

Each handler reads the article from the context (already validated by ArticleCtx) and returns a JSON response.
main.go
import (
	"encoding/json"
	"net/http"
)

func listArticles(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(articles)
}

func createArticle(w http.ResponseWriter, r *http.Request) {
	var article Article
	if err := json.NewDecoder(r.Body).Decode(&article); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	dbNewArticle(&article)
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusCreated)
	json.NewEncoder(w).Encode(article)
}

func getArticle(w http.ResponseWriter, r *http.Request) {
	article, ok := r.Context().Value("article").(*Article)
	if !ok {
		http.Error(w, http.StatusText(http.StatusUnprocessableEntity), http.StatusUnprocessableEntity)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(article)
}

func updateArticle(w http.ResponseWriter, r *http.Request) {
	article, ok := r.Context().Value("article").(*Article)
	if !ok {
		http.Error(w, http.StatusText(http.StatusUnprocessableEntity), http.StatusUnprocessableEntity)
		return
	}
	if err := json.NewDecoder(r.Body).Decode(article); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	dbUpdateArticle(article.ID, article)
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(article)
}

func deleteArticle(w http.ResponseWriter, r *http.Request) {
	article, ok := r.Context().Value("article").(*Article)
	if !ok {
		http.Error(w, http.StatusText(http.StatusUnprocessableEntity), http.StatusUnprocessableEntity)
		return
	}
	removed, err := dbRemoveArticle(article.ID)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(removed)
}

Step 5 — Register RESTful routes with nesting

Use r.Route to create a sub-router scoped to /articles. Within it, nest a second r.Route for /{articleID} and attach ArticleCtx as middleware — it runs only for routes that include an article ID.
main.go
// RESTy routes for the "articles" resource
r.Route("/articles", func(r chi.Router) {
	r.Get("/", listArticles)     // GET  /articles
	r.Post("/", createArticle)   // POST /articles

	// Subrouter: loads article from DB before calling the handler
	r.Route("/{articleID}", func(r chi.Router) {
		r.Use(ArticleCtx)
		r.Get("/", getArticle)       // GET    /articles/{articleID}
		r.Put("/", updateArticle)    // PUT    /articles/{articleID}
		r.Delete("/", deleteArticle) // DELETE /articles/{articleID}
	})
})
The full route table after this registration looks like:
MethodPatternHandler
GET/articleslistArticles
POST/articlescreateArticle
GET/articles/{articleID}getArticle
PUT/articles/{articleID}updateArticle
DELETE/articles/{articleID}deleteArticle

Step 6 — Mount a separate admin router

Admin routes live in their own chi.Router returned by adminRouter(). The AdminOnly middleware gatekeeps every route inside it, keeping the admin concern entirely separate from the public API.
main.go
// AdminOnly rejects requests that do not carry an admin flag in the context.
// In practice, a preceding auth middleware would set this value.
func AdminOnly(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		isAdmin, ok := r.Context().Value("acl.admin").(bool)
		if !ok || !isAdmin {
			http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
			return
		}
		next.ServeHTTP(w, r)
	})
}

// adminRouter returns a self-contained router for /admin/* paths.
func adminRouter() http.Handler {
	r := chi.NewRouter()
	r.Use(AdminOnly)
	r.Get("/", func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("admin: index"))
	})
	r.Get("/accounts", func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("admin: list accounts"))
	})
	r.Get("/users/{userId}", func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("admin: view user id " + chi.URLParam(r, "userId")))
	})
	return r
}
Mount it on the main router:
main.go
r.Mount("/admin", adminRouter())

Putting it all together

Here is the complete main.go that assembles every piece above:
main.go
package main

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"math/rand"
	"net/http"
	"time"

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

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

	r.Use(middleware.RequestID)
	r.Use(middleware.Logger)
	r.Use(middleware.Recoverer)
	r.Use(middleware.Timeout(60 * time.Second))

	r.Get("/", func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("root."))
	})

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

		r.Route("/{articleID}", func(r chi.Router) {
			r.Use(ArticleCtx)
			r.Get("/", getArticle)
			r.Put("/", updateArticle)
			r.Delete("/", deleteArticle)
		})
	})

	r.Mount("/admin", adminRouter())

	http.ListenAndServe(":3333", r)
}
Run it and exercise the API with curl:
curl http://localhost:3333/articles

Using the resource pattern

For larger services, the todosResource pattern from the chi examples groups a router and its handlers onto a struct, making the whole resource portable and testable:
todos.go
package main

import (
	"net/http"

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

type todosResource struct{}

// Routes returns a router with all todo endpoints registered.
func (rs todosResource) Routes() chi.Router {
	r := chi.NewRouter()

	r.Get("/", rs.List)    // GET  /todos
	r.Post("/", rs.Create) // POST /todos

	r.Route("/{id}", func(r chi.Router) {
		r.Get("/", rs.Get)       // GET    /todos/{id}
		r.Put("/", rs.Update)    // PUT    /todos/{id}
		r.Delete("/", rs.Delete) // DELETE /todos/{id}
	})

	return r
}

func (rs todosResource) List(w http.ResponseWriter, r *http.Request)   { w.Write([]byte("todos list")) }
func (rs todosResource) Create(w http.ResponseWriter, r *http.Request) { w.Write([]byte("todos create")) }
func (rs todosResource) Get(w http.ResponseWriter, r *http.Request)    { w.Write([]byte("todo get")) }
func (rs todosResource) Update(w http.ResponseWriter, r *http.Request) { w.Write([]byte("todo update")) }
func (rs todosResource) Delete(w http.ResponseWriter, r *http.Request) { w.Write([]byte("todo delete")) }
Mount it alongside other resources:
main.go
r.Mount("/todos", todosResource{}.Routes())
Each mounted sub-router has its own independent middleware stack. Middleware registered on todosResource.Routes() only runs for /todos/* requests — it has no effect on /articles/* or /admin/* routes.

Build docs developers (and LLMs) love