Building a REST API with Go and Chi
A hands-on tutorial for a production-ready REST API with Go and Chi: routing, middleware, JSON handling, error responses and graceful shutdown.

Go is a strong choice for HTTP services. The standard library's net/http package handles the fundamentals, but adding a lightweight router like Chi gives you path parameters, middleware chaining, and route grouping without pulling in a heavy framework. Chi stays close to the standard library — handlers are still http.Handler compatible, and middleware follows the standard pattern.
This tutorial builds a complete REST API for a task management service with proper error handling, middleware, and graceful shutdown.
Project Structure
Keep it flat until the project needs more structure. A premature package hierarchy adds more complexity than it solves.
task-api/
├── main.go # Entry point, server setup
├── handler.go # HTTP handlers
├── middleware.go # Custom middleware
├── model.go # Data types
├── store.go # Data access layer
├── go.mod
└── go.sum
Setting Up the Router
Chi's router composes middleware and routes in a readable chain.
// main.go
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
func main() {
store := NewMemoryStore()
handler := NewTaskHandler(store)
r := chi.NewRouter()
// Global middleware stack
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(30 * time.Second))
// Routes
r.Route("/api/v1/tasks", func(r chi.Router) {
r.Get("/", handler.ListTasks)
r.Post("/", handler.CreateTask)
r.Route("/{taskID}", func(r chi.Router) {
r.Get("/", handler.GetTask)
r.Put("/", handler.UpdateTask)
r.Delete("/", handler.DeleteTask)
})
})
// Health check outside the versioned API
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
})
srv := &http.Server{
Addr: ":8080",
Handler: r,
ReadTimeout: 10 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
// Graceful shutdown
go func() {
log.Printf("Server starting on %s", srv.Addr)
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatalf("Server error: %v", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatalf("Forced shutdown: %v", err)
}
log.Println("Server stopped")
}The graceful shutdown block listens for SIGINT/SIGTERM, then gives in-flight requests 10 seconds to complete before forcing a stop. Without this, deploys kill active connections.
Data Model and Store
Define the data types and a simple in-memory store. In production, replace this with a database-backed implementation behind the same interface.
// model.go
package main
import "time"
type Task struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
Status string `json:"status"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type CreateTaskRequest struct {
Title string `json:"title"`
Description string `json:"description,omitempty"`
}
type UpdateTaskRequest struct {
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
Status *string `json:"status,omitempty"`
}// store.go
package main
import (
"crypto/rand"
"encoding/hex"
"fmt"
"sync"
"time"
)
type TaskStore interface {
List() ([]Task, error)
Get(id string) (Task, error)
Create(req CreateTaskRequest) (Task, error)
Update(id string, req UpdateTaskRequest) (Task, error)
Delete(id string) error
}
type MemoryStore struct {
mu sync.RWMutex
tasks map[string]Task
}
func NewMemoryStore() *MemoryStore {
return &MemoryStore{tasks: make(map[string]Task)}
}
func (s *MemoryStore) List() ([]Task, error) {
s.mu.RLock()
defer s.mu.RUnlock()
tasks := make([]Task, 0, len(s.tasks))
for _, t := range s.tasks {
tasks = append(tasks, t)
}
return tasks, nil
}
func (s *MemoryStore) Get(id string) (Task, error) {
s.mu.RLock()
defer s.mu.RUnlock()
task, ok := s.tasks[id]
if !ok {
return Task{}, fmt.Errorf("task not found: %s", id)
}
return task, nil
}
func (s *MemoryStore) Create(req CreateTaskRequest) (Task, error) {
s.mu.Lock()
defer s.mu.Unlock()
id := generateID()
now := time.Now().UTC()
task := Task{
ID: id,
Title: req.Title,
Description: req.Description,
Status: "pending",
CreatedAt: now,
UpdatedAt: now,
}
s.tasks[id] = task
return task, nil
}
func (s *MemoryStore) Update(id string, req UpdateTaskRequest) (Task, error) {
s.mu.Lock()
defer s.mu.Unlock()
task, ok := s.tasks[id]
if !ok {
return Task{}, fmt.Errorf("task not found: %s", id)
}
if req.Title != nil {
task.Title = *req.Title
}
if req.Description != nil {
task.Description = *req.Description
}
if req.Status != nil {
task.Status = *req.Status
}
task.UpdatedAt = time.Now().UTC()
s.tasks[id] = task
return task, nil
}
func (s *MemoryStore) Delete(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.tasks[id]; !ok {
return fmt.Errorf("task not found: %s", id)
}
delete(s.tasks, id)
return nil
}
func generateID() string {
b := make([]byte, 8)
rand.Read(b)
return hex.EncodeToString(b)
}The sync.RWMutex allows concurrent reads with exclusive writes — correct for an in-memory store accessed by multiple goroutines.
HTTP Handlers
Handlers parse requests, call the store, and format responses. Keep them thin — business logic belongs in the store or a service layer.
// handler.go
package main
import (
"encoding/json"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
)
type TaskHandler struct {
store TaskStore
}
func NewTaskHandler(store TaskStore) *TaskHandler {
return &TaskHandler{store: store}
}
func (h *TaskHandler) ListTasks(w http.ResponseWriter, r *http.Request) {
tasks, err := h.store.List()
if err != nil {
writeError(w, http.StatusInternalServerError, "Failed to list tasks")
return
}
writeJSON(w, http.StatusOK, tasks)
}
func (h *TaskHandler) GetTask(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "taskID")
task, err := h.store.Get(id)
if err != nil {
if strings.Contains(err.Error(), "not found") {
writeError(w, http.StatusNotFound, "Task not found")
return
}
writeError(w, http.StatusInternalServerError, "Failed to get task")
return
}
writeJSON(w, http.StatusOK, task)
}
func (h *TaskHandler) CreateTask(w http.ResponseWriter, r *http.Request) {
var req CreateTaskRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON body")
return
}
if req.Title == "" {
writeError(w, http.StatusBadRequest, "Title is required")
return
}
if len(req.Title) > 200 {
writeError(w, http.StatusBadRequest, "Title must be 200 characters or less")
return
}
task, err := h.store.Create(req)
if err != nil {
writeError(w, http.StatusInternalServerError, "Failed to create task")
return
}
writeJSON(w, http.StatusCreated, task)
}
func (h *TaskHandler) UpdateTask(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "taskID")
var req UpdateTaskRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON body")
return
}
if req.Status != nil {
valid := map[string]bool{"pending": true, "in-progress": true, "done": true}
if !valid[*req.Status] {
writeError(w, http.StatusBadRequest, "Status must be pending, in-progress, or done")
return
}
}
task, err := h.store.Update(id, req)
if err != nil {
if strings.Contains(err.Error(), "not found") {
writeError(w, http.StatusNotFound, "Task not found")
return
}
writeError(w, http.StatusInternalServerError, "Failed to update task")
return
}
writeJSON(w, http.StatusOK, task)
}
func (h *TaskHandler) DeleteTask(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "taskID")
if err := h.store.Delete(id); err != nil {
if strings.Contains(err.Error(), "not found") {
writeError(w, http.StatusNotFound, "Task not found")
return
}
writeError(w, http.StatusInternalServerError, "Failed to delete task")
return
}
w.WriteHeader(http.StatusNoContent)
}Response Helpers
Consistent JSON responses make the API predictable for clients.
// ❌ Inconsistent error responses
w.WriteHeader(500)
w.Write([]byte("something went wrong"))
// Client gets plain text sometimes, JSON other times
// ✅ Consistent JSON error envelope
type ErrorResponse struct {
Error string `json:"error"`
Status int `json:"status"`
}
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, ErrorResponse{
Error: message,
Status: status,
})
}Every error response has the same shape. Clients can reliably parse errors without checking Content-Type.
Custom Middleware
Chi middleware follows the standard func(http.Handler) http.Handler pattern.
// middleware.go
package main
import (
"net/http"
"strings"
)
// ContentTypeJSON ensures POST/PUT requests send JSON
func ContentTypeJSON(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost || r.Method == http.MethodPut {
ct := r.Header.Get("Content-Type")
if !strings.HasPrefix(ct, "application/json") {
writeError(w, http.StatusUnsupportedMediaType,
"Content-Type must be application/json")
return
}
}
next.ServeHTTP(w, r)
})
}
// CORS middleware for development
func CORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}Apply middleware at different scopes — global middleware on the root router, route-specific middleware on sub-routers. The ContentTypeJSON middleware prevents cryptic parsing errors by rejecting non-JSON requests early.
Key Takeaways
- Chi stays close to
net/http— handlers are standardhttp.Handler, middleware isfunc(http.Handler) http.Handler - Set server timeouts explicitly —
ReadTimeout,WriteTimeout, andIdleTimeoutprevent resource exhaustion - Implement graceful shutdown — catch SIGINT/SIGTERM and give in-flight requests time to complete
- Use
sync.RWMutexfor concurrent access — read locks for queries, write locks for mutations - Validate input at the handler level — check required fields, enforce length limits, validate enum values
- Keep error responses consistent — a standard JSON error envelope makes clients predictable


