REST API with Chi router

Create a REST API in Go using the Chi router by initializing a mux, defining routes, and starting the HTTP server.

REST API with Chi router

You're building a service. You start with http.HandleFunc. It works for one endpoint. Then you add five more. Suddenly your main.go is a wall of string matching and manual parsing. You need structure. You need a router that handles paths like /users/{id} without you writing regex. Chi fills that gap. It's a lightweight router built on top of the standard library, adding structure without the bloat of a full framework. You get middleware, route groups, and clean parameter extraction while keeping the familiar http.Handler interface. The goal is to organize your code without introducing a dependency that dictates your entire architecture.

How Chi fits the Go way

Think of the standard net/http mux as a single receptionist who knows every desk in the building by heart. It works, but as the building grows, the receptionist gets overwhelmed. Chi is like a smart switchboard. You plug in handlers to specific extensions. The switchboard routes calls based on patterns. It also lets you group related extensions together, so the billing department gets its own sub-switchboard. Chi gives you that grouping and pattern matching while keeping the underlying machinery simple. It doesn't replace net/http; it sits on top of it. The router implements http.Handler, so you can drop it into http.ListenAndServe or mount it inside another router. This composability is a core Go design principle. You can build a router for the public API and another for the admin panel, then mount both on the main router.

Chi adds structure without hiding the standard library.

Minimal example

Here's the simplest Chi router: create the mux, register a route, and start listening.

package main

import (
	"net/http"
	"github.com/go-chi/chi/v5"
)

func main() {
	// chi.NewMux creates a router that implements http.Handler
	r := chi.NewMux()

	// Register a GET route with a path parameter
	r.Get("/hello/{name}", func(w http.ResponseWriter, r *http.Request) {
		// chi.URLParam extracts the value from the URL
		name := chi.URLParam(r, "name")
		w.Write([]byte("Hello " + name))
	})

	// Start the server on port 3000
	http.ListenAndServe(":3000", r)
}

Routes are functions. Keep them small.

What happens at runtime

When you run this, Chi sets up a tree structure to match URLs. The {name} part becomes a variable. When a request hits /hello/alice, Chi walks the tree, finds the match, captures alice, and calls your function. The chi.URLParam function looks up that captured value from the request context. Chi stores parameters in the request's context, which is a standard Go pattern for passing request-scoped data. The server then sends the response back. If the URL doesn't match, Chi returns a 404.

Path matching is O(log N) due to the tree structure. The standard mux is O(N) because it iterates patterns. Chi is faster for large route sets. This performance comes from the trie-based routing algorithm Chi implements internally.

Realistic setup

Real APIs return JSON and use middleware for logging or authentication. Here's a setup with route groups and JSON responses.

package main

import (
	"encoding/json"
	"net/http"
	"github.com/go-chi/chi/v5"
	"github.com/go-chi/chi/v5/middleware"
)

func main() {
	r := chi.NewRouter()
	// Logger prints request details to stdout
	r.Use(middleware.Logger)
	// Group routes to apply middleware or prefixes
	r.Group(func(r chi.Router) {
		r.Get("/users", func(w http.ResponseWriter, r *http.Request) {
			w.Header().Set("Content-Type", "application/json")
			json.NewEncoder(w).Encode([]string{"Alice", "Bob"})
		})
		r.Get("/users/{id}", func(w http.ResponseWriter, r *http.Request) {
			id := chi.URLParam(r, "id")
			w.Header().Set("Content-Type", "application/json")
			json.NewEncoder(w).Encode(map[string]string{"id": id})
		})
	})
	http.ListenAndServe(":8080", r)
}

Middleware is the glue. Order determines behavior.

Writing middleware

Middleware is where Chi shines. You can write custom middleware to handle authentication, rate limiting, or request modification. Here's a simple auth check.

// AuthMiddleware checks for a valid token in the header
func AuthMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		token := r.Header.Get("Authorization")
		if token == "" {
			http.Error(w, "unauthorized", http.StatusUnauthorized)
			return
		}
		// Call the next handler in the chain
		next.ServeHTTP(w, r)
	})
}

Middleware takes the next handler and returns a new handler. The new handler runs your logic, then calls next.ServeHTTP to continue the chain. If you return early, the request stops. This pattern lets you short-circuit requests. You can stack multiple middleware functions. The order determines execution. Authentication should run before authorization. Logging should run around everything.

Chi provides With to apply middleware to a specific route without affecting siblings. Use applies to the whole group. This gives fine-grained control. You can have a public router and a private router, mount the private one with auth middleware, and keep the public one open. This separation keeps code clean.

Context and cancellation

Handlers run concurrently for each request. The request context carries deadlines and cancellation signals. When a client disconnects, the server cancels the request context. Your handler should listen for cancellation to stop work. Chi doesn't do this automatically. You must check r.Context().Done(). If you start a database query, pass the context to the query. If the client leaves, the query cancels and frees resources. This prevents goroutine leaks and wasted work.

context.Context always goes as the first parameter, conventionally named ctx. Functions that take a context should respect cancellation and deadlines. If you spawn a goroutine in a handler, pass the request context to it so it stops when the client disconnects. The worst goroutine bug is the one that never logs.

Pitfalls and errors

Chi doesn't validate your path parameters. If you define /users/{id} and the client sends /users/, Chi still matches and passes an empty string. You need to check for empty values in your handler. If you forget to handle an error from json.NewEncoder, the compiler won't stop you, but the response might be incomplete. The compiler rejects undefined: chi if you forget the import. If you use chi.URLParam with a key that doesn't exist in the route pattern, it returns an empty string, not an error. This is a common source of bugs. Always verify parameters.

Middleware order matters. If you put a panic recoverer after a handler that panics, the panic escapes. Put recoverers at the top of the chain. Chi also handles method routing. If you register a GET route and a client sends POST, Chi returns 405 Method Not Allowed automatically. You don't need to check r.Method manually.

Chi doesn't have a built-in error handler. You write your own. This is a design choice. Go prefers explicit error handling. You return errors from handlers or write to the response. Chi leaves the error format up to you. You might want JSON errors for APIs and HTML for web apps. Chi doesn't force a format.

Validate inputs. Trust no URL parameter.

Conventions and style

gofmt is mandatory. Don't argue about indentation; let the tool decide. Most editors run it on save. Chi code follows standard Go conventions. The receiver name is usually one or two letters matching the type. Here we use functions, so the w and r names are standard. Public names start with a capital letter. Private start lowercase. No keywords like public or private.

if err != nil { return err } is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Chi doesn't change this. You still check errors explicitly.

_ (underscore) discards a value intentionally. result, _ := ... says "I considered the second return value and chose to drop it". Use it sparingly with errors.

Don't pass a *string. Strings are already cheap to pass by value. Chi handlers receive *http.Request, which is a pointer, but that's because the request is large and shared. Your domain types should usually be passed by value if they fit in a few words.

Testing

Chi routers are easy to test because they are just http.Handler implementations. Here's how to test a route.

func TestGetUser(t *testing.T) {
	r := chi.NewRouter()
	r.Get("/users/{id}", getUser)

	req := httptest.NewRequest(http.MethodGet, "/users/123", nil)
	rec := httptest.NewRecorder()

	r.ServeHTTP(rec, req)

	if rec.Code != http.StatusOK {
		t.Errorf("expected 200, got %d", rec.Code)
	}
}

Use httptest.NewRequest and httptest.NewRecorder to simulate requests and capture responses. Call ServeHTTP on the router. Check the status code and body. This tests the routing and handler logic without starting a server. You can test middleware by wrapping handlers. This approach keeps tests fast and isolated.

When to use Chi

Use Chi when you need a lightweight router with middleware support and clean path parameters. Use the standard net/http mux when your service has fewer than five routes and you want zero dependencies. Use a full framework like Gin or Echo when you need built-in validation, templating, or heavy opinionation. Use a gateway like Envoy when you need TLS termination, rate limiting, or service mesh features before the request hits your code.

Pick the tool that matches the complexity.

Where to go next