Building RESTful APIs with Go and Chi
A practical guide to building production RESTful APIs with Go and Chi router — routing, middleware, request validation, error handling, and clean architecture.
// table of contents (11 sections)
I have built REST APIs with various Go frameworks — Gin, Echo, Fiber, and even the standard library. After several production services, Chi has become my go-to choice. It is lightweight, idiomatic, and composable — exactly what you want when building APIs that scale.
This post covers the patterns I use for production REST APIs with Chi: project structure, routing, middleware composition, request validation, error handling, and testing.
Why Chi?
Chi is a lightweight HTTP router built on context.Context. It gives you:
- Zero allocations for common routes — fast enough for high-throughput services
- Composable middleware — easy to build reusable request handling layers
- Standard library compatible — works with
http.Handlerandhttp.HandlerFunc - URL parameters and wildcards — clean RESTful route definitions
- No magic — just Go, no complex framework abstractions
go get github.com/go-chi/chi/v5
Project Structure
A clean structure keeps your API maintainable as it grows:
cmd/api/main.go # Application entry point
internal/
├── api/
│ ├── handler.go # HTTP handlers
│ ├── middleware.go # Custom middleware
│ └── response.go # Response helpers
├── service/ # Business logic
├── repository/ # Data access
├── model/ # Domain models
└── validator/ # Request validation
pkg/
├── router/ # Router setup
└── config/ # Configuration
go.mod
go.sum
The internal/ directory cannot be imported by external modules, keeping your business logic private. pkg/ contains reusable packages.
Basic Setup
// cmd/api/main.go
package main
import (
"log"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
func main() {
r := chi.NewRouter()
// Chi middleware stack
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(60 * time.Second))
// Routes
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Welcome"))
})
log.Println("Server starting on :8080")
log.Fatal(http.ListenAndServe(":8080", r))
}
Composable Middleware
Chi middleware is just func(http.Handler) http.Handler. This simple interface makes composition trivial:
// internal/api/middleware.go
package api
import (
"context"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5/middleware"
)
// RequestLogger structured logging middleware
func RequestLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Wrap response writer to capture status code
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
// Call next handler
next.ServeHTTP(ww, r)
// Log request details
log.Printf(
"method=%s path=%s status=%d duration=%s remote_addr=%s",
r.Method,
r.URL.Path,
ww.Status(),
time.Since(start),
r.RemoteAddr,
)
})
}
// Auth middleware validates JWT tokens
func Auth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "Authorization header required", http.StatusUnauthorized)
return
}
token := strings.TrimPrefix(authHeader, "Bearer ")
if token == authHeader {
http.Error(w, "Invalid authorization header format", http.StatusUnauthorized)
return
}
// Validate token and extract user ID
userID, err := validateToken(token)
if err != nil {
http.Error(w, "Invalid token", http.StatusUnauthorized)
return
}
// Store user ID in context
ctx := context.WithValue(r.Context(), "userID", userID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// ContentType enforces JSON content type
func ContentType(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ct := r.Header.Get("Content-Type")
if !strings.Contains(ct, "application/json") && r.Method != "GET" {
http.Error(w, "Content-Type must be application/json", http.StatusUnsupportedMediaType)
return
}
next.ServeHTTP(w, r)
})
}
Apply middleware selectively:
// Public routes
r.Group(func(r chi.Router) {
r.Use(RequestLogger)
r.Post("/auth/login", h.Login)
r.Post("/auth/register", h.Register)
})
// Protected routes
r.Group(func(r chi.Router) {
r.Use(RequestLogger)
r.Use(Auth)
r.Use(ContentType)
r.Get("/users", h.ListUsers)
r.Get("/users/{id}", h.GetUser)
r.Put("/users/{id}", h.UpdateUser)
r.Delete("/users/{id}", h.DeleteUser)
})
RESTful Routing
Chi supports URL parameters, wildcards, and RESTful route patterns:
// pkg/router/router.go
package router
import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/yourproject/internal/api"
)
func New(h *api.Handler) chi.Router {
r := chi.NewRouter()
// Global middleware
r.Use(middleware.RequestID)
r.Use(middleware.Recoverer)
r.Use(h.RequestLogger)
r.Use(middleware.AllowContentType("application/json"))
r.Use(middleware.Timeout(60 * time.Second))
// API v1 routes
r.Route("/api/v1", func(r chi.Router) {
// Auth routes (public)
r.Post("/auth/login", h.Login)
r.Post("/auth/register", h.Register)
r.Post("/auth/refresh", h.RefreshToken)
// Users routes (protected)
r.Route("/users", func(r chi.Router) {
r.Use(h.Auth)
r.Get("/", h.ListUsers) // GET /api/v1/users?page=1&limit=10
r.Post("/", h.CreateUser) // POST /api/v1/users
r.Get("/{id}", h.GetUser) // GET /api/v1/users/123
r.Put("/{id}", h.UpdateUser) // PUT /api/v1/users/123
r.Patch("/{id}", h.PatchUser) // PATCH /api/v1/users/123
r.Delete("/{id}", h.DeleteUser) // DELETE /api/v1/users/123
// Nested routes
r.Get("/{id}/posts", h.ListUserPosts) // GET /api/v1/users/123/posts
r.Post("/{id}/posts", h.CreateUserPost) // POST /api/v1/users/123/posts
})
// Posts routes (protected)
r.Route("/posts", func(r chi.Router) {
r.Use(h.Auth)
r.Get("/", h.ListPosts) // GET /api/v1/posts
r.Post("/", h.CreatePost) // POST /api/v1/posts
r.Get("/{id}", h.GetPost) // GET /api/v1/posts/123
r.Put("/{id}", h.UpdatePost) // PUT /api/v1/posts/123
r.Delete("/{id}", h.DeletePost) // DELETE /api/v1/posts/123
// Comments nested under posts
r.Route("/{postID}/comments", func(r chi.Router) {
r.Get("/", h.ListComments) // GET /api/v1/posts/123/comments
r.Post("/", h.CreateComment) // POST /api/v1/posts/123/comments
})
})
// Health check (no auth required)
r.Get("/health", h.HealthCheck)
})
return r
}
Handlers With Clean Architecture
A good handler should be thin — just HTTP concerns. Business logic belongs in the service layer:
// internal/api/handler.go
package api
import (
"encoding/json"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/yourproject/internal/model"
"github.com/yourproject/internal/service"
"github.com/yourproject/internal/validator"
)
type Handler struct {
userService *service.UserService
postService *service.PostService
validator *validator.Validator
}
func New(userService *service.UserService, postService *service.PostService) *Handler {
return &Handler{
userService: userService,
postService: postService,
validator: validator.New(),
}
}
// ListUsers handles GET /users
func (h *Handler) ListUsers(w http.ResponseWriter, r *http.Request) {
// Parse query parameters
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit < 1 || limit > 100 {
limit = 10
}
// Call service layer
users, total, err := h.userService.ListUsers(r.Context(), page, limit)
if err != nil {
h.Error(w, err)
return
}
// Response
h.JSON(w, http.StatusOK, map[string]any{
"data": users,
"meta": map[string]any{
"page": page,
"limit": limit,
"total": total,
"totalPages": (total + limit - 1) / limit,
},
})
}
// GetUser handles GET /users/{id}
func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
// Extract URL parameter
id := chi.URLParam(r, "id")
if id == "" {
h.Error(w, ErrInvalidRequest("user ID is required"))
return
}
// Call service
user, err := h.userService.GetUser(r.Context(), id)
if err != nil {
h.Error(w, err)
return
}
h.JSON(w, http.StatusOK, user)
}
// CreateUser handles POST /users
func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
var req model.CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.Error(w, ErrInvalidRequest("invalid JSON body"))
return
}
defer r.Body.Close()
// Validate request
if err := h.validator.Validate(&req); err != nil {
h.Error(w, ErrValidation(err))
return
}
// Call service
user, err := h.userService.CreateUser(r.Context(), &req)
if err != nil {
h.Error(w, err)
return
}
h.JSON(w, http.StatusCreated, user)
}
Response Helpers
Consistent response formatting makes your API predictable:
// internal/api/response.go
package api
import (
"encoding/json"
"net/http"
)
type Response struct {
Success bool `json:"success"`
Data any `json:"data,omitempty"`
Error *ErrorResp `json:"error,omitempty"`
Meta any `json:"meta,omitempty"`
}
type ErrorResp struct {
Code string `json:"code"`
Message string `json:"message"`
Details any `json:"details,omitempty"`
}
// JSON writes a JSON response
func (h *Handler) JSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
resp := Response{
Success: status < 400,
Data: data,
}
json.NewEncoder(w).Encode(resp)
}
// Error writes an error response
func (h *Handler) Error(w http.ResponseWriter, err error) {
// Handle domain errors
if apiErr, ok := err.(*APIError); ok {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(apiErr.Status)
json.NewEncoder(w).Encode(Response{
Success: false,
Error: &ErrorResp{
Code: apiErr.Code,
Message: apiErr.Message,
Details: apiErr.Details,
},
})
return
}
// Unknown error — internal server error
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(Response{
Success: false,
Error: &ErrorResp{
Code: "INTERNAL_ERROR",
Message: "An internal error occurred",
},
})
}
Error Types
Define error types for different scenarios:
// internal/api/errors.go
package api
import "net/http"
type APIError struct {
Status int
Code string
Message string
Details any
}
func (e *APIError) Error() string {
return e.Message
}
// Error constructors
func ErrValidation(details any) *APIError {
return &APIError{
Status: http.StatusUnprocessableEntity,
Code: "VALIDATION_ERROR",
Message: "Request validation failed",
Details: details,
}
}
func ErrNotFound(resource string) *APIError {
return &APIError{
Status: http.StatusNotFound,
Code: "NOT_FOUND",
Message: resource + " not found",
}
}
func ErrUnauthorized(msg string) *APIError {
return &APIError{
Status: http.StatusUnauthorized,
Code: "UNAUTHORIZED",
Message: msg,
}
}
func ErrForbidden(msg string) *APIError {
return &APIError{
Status: http.StatusForbidden,
Code: "FORBIDDEN",
Message: msg,
}
}
func ErrInvalidRequest(msg string) *APIError {
return &APIError{
Status: http.StatusBadRequest,
Code: "INVALID_REQUEST",
Message: msg,
}
}
Request Validation
Use a validation library like go-playground/validator:
// internal/validator/validator.go
package validator
import (
"github.com/go-playground/validator/v10"
)
type Validator struct {
validate *validator.Validate
}
func New() *Validator {
v := validator.New()
// Register custom validations
v.RegisterValidation("slug", validateSlug)
v.RegisterValidation("username", validateUsername)
return &Validator{validate: v}
}
func (v *Validator) Validate(s any) error {
if err := v.validate.Struct(s); err != nil {
return formatValidationErrors(err)
}
return nil
}
func formatValidationErrors(err error) map[string]string {
errors := make(map[string]string)
for _, err := range err.(validator.ValidationErrors) {
field := err.Field()
tag := err.Tag()
var msg string
switch tag {
case "required":
msg = field + " is required"
case "email":
msg = field + " must be a valid email"
case "min":
msg = field + " must be at least " + err.Param() + " characters"
case "max":
msg = field + " must be at most " + err.Param() + " characters"
case "slug":
msg = field + " must contain only lowercase letters, numbers, and hyphens"
default:
msg = field + " is invalid"
}
errors[field] = msg
}
return errors
}
Define request models with validation tags:
// internal/model/user.go
package model
type CreateUserRequest struct {
Name string `json:"name" validate:"required,min=2,max=100"`
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=8"`
Username string `json:"username" validate:"required,username,min=3,max=30"`
}
type UpdateUserRequest struct {
Name string `json:"name" validate:"omitempty,min=2,max=100"`
Email string `json:"email" validate:"omitempty,email"`
}
Testing Handlers
Test handlers with httptest:
// internal/api/handler_test.go
package api_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/yourproject/internal/api"
"github.com/yourproject/internal/service"
"github.com/yourproject/internal/model"
)
func TestCreateUser(t *testing.T) {
// Mock service
mockService := &service.MockUserService{
CreateUserFunc: func(ctx context.Context, req *model.CreateUserRequest) (*model.User, error) {
return &model.User{ID: "123", Name: req.Name, Email: req.Email}, nil
},
}
handler := api.New(mockService, nil)
router := chi.NewRouter()
router.Post("/users", handler.CreateUser)
// Request body
body := map[string]any{
"name": "John Doe",
"email": "john@example.com",
"password": "password123",
}
jsonBody, _ := json.Marshal(body)
// Create request
req := httptest.NewRequest("POST", "/users", bytes.NewReader(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
// Execute
router.ServeHTTP(w, req)
// Assert
if w.Code != http.StatusCreated {
t.Errorf("expected status 201, got %d", w.Code)
}
var resp api.Response
json.Unmarshal(w.Body.Bytes(), &resp)
if !resp.Success {
t.Errorf("expected success=true")
}
}
Key Takeaways
- Use Chi for composable routing — middleware and routes compose naturally
- Keep handlers thin — business logic belongs in the service layer
- Consistent response format — wrap all responses in a standard structure
- Typed error handling — define API errors with status codes and error codes
- Validate input early — use validator at the handler boundary
- Context propagation — pass context through to service and repository layers
- Test with httptest — mock services and test HTTP layer in isolation
- Version your API — use
/api/v1/pattern for future evolution
Chi gives you the building blocks for production APIs without the overhead of a full framework. Combined with clean architecture patterns, you get APIs that are fast, maintainable, and testable.
You might also like
Error Handling Patterns in Go
Production-ready error handling patterns in Go — wrapping, sentinel errors, custom error types, error groups, and structured logging for backend services.
Go Concurrency: Goroutines and Channels in Practice
Practical goroutines and channels patterns for production Go services — worker pools, pipelines, fan-in/fan-out, rate limiting, and common concurrency pitfalls.
Go Context: Beyond Cancellation
Understanding context.Context in Go — timeouts, deadlines, request-scoped values, and cancellation propagation patterns for production backend services.
More Posts
API Gateway Patterns: The Front Door to Your Microservices
Web Components 2026: Building Framework-Agnostic UI Libraries
Building Autonomous AI Workflows with LangGraph: A Practical Guide
Building Type-Safe APIs with tRPC in 2026: Full-Stack TypeScript Without Schemas
Database Connection Pooling: Patterns for High-Performance Applications
Prompt Caching: Reduce LLM Costs by 90% with Smart Context Management
Enjoyed This Post?
Want to discuss the topic, have questions, or looking to collaborate on something similar? Drop a comment below or reach out directly.
