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.
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.
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" resourcer.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:
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}
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 mainimport ( "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.