Software Config

Building REST APIs with Go and Chi Router

Tech Setup1 min read
TS

Tech Setup

Reviewed July 29, 2026

Why Go for APIs?

Go compiles to a single binary, handles thousands of concurrent connections with goroutines, and has a batteries-included standard library. It's ideal for microservices and REST APIs.

Project Setup

mkdir go-api && cd go-api
go mod init github.com/yourname/go-api
go get github.com/go-chi/chi/v5

Basic Server

package main

import (
    "encoding/json"
    "log"
    "net/http"
    "os"
    "time"

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

type Response struct {
    Message string `json:"message"`
    OK      bool   `json:"ok"`
}

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

    // Middleware
    r.Use(middleware.Logger)
    r.Use(middleware.Recoverer)
    r.Use(middleware.Timeout(30 * time.Second))

    // Routes
    r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
        json.NewEncoder(w).Encode(Response{
            Message: "healthy",
            OK:      true,
        })
    })

    // Start server
    port := os.Getenv("PORT")
    if port == "" {
        port = "8080"
    }
    log.Printf("Server starting on :%s", port)
    log.Fatal(http.ListenAndServe(":"+port, r))
}

CRUD Routes

type Todo struct {
    ID        string `json:"id"`
    Title     string `json:"title"`
    Completed bool   `json:"completed"`
}

var todos = map[string]Todo{
    "1": {ID: "1", Title: "Learn Go", Completed: false},
}

func main() {
    r := chi.NewRouter()
    r.Use(middleware.Logger)
    r.Use(middleware.Recoverer)

    r.Route("/api/todos", func(r chi.Router) {
        r.Get("/", listTodos)
        r.Post("/", createTodo)
        r.Get("/{id}", getTodo)
        r.Put("/{id}", updateTodo)
        r.Delete("/{id}", deleteTodo)
    })

    log.Fatal(http.ListenAndServe(":8080", r))
}

func listTodos(w http.ResponseWriter, r *http.Request) {
    list := make([]Todo, 0, len(todos))
    for _, t := range todos {
        list = append(list, t)
    }
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(list)
}

func createTodo(w http.ResponseWriter, r *http.Request) {
    var t Todo
    if err := json.NewDecoder(r.Body).Decode(&t); err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    t.ID = fmt.Sprintf("%d", len(todos)+1)
    todos[t.ID] = t
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(t)
}

func getTodo(w http.ResponseWriter, r *http.Request) {
    id := chi.URLParam(r, "id")
    t, ok := todos[id]
    if !ok {
        http.Error(w, "not found", http.StatusNotFound)
        return
    }
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(t)
}

func updateTodo(w http.ResponseWriter, r *http.Request) {
    id := chi.URLParam(r, "id")
    if _, ok := todos[id]; !ok {
        http.Error(w, "not found", http.StatusNotFound)
        return
    }
    var t Todo
    if err := json.NewDecoder(r.Body).Decode(&t); err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    t.ID = id
    todos[id] = t
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(t)
}

func deleteTodo(w http.ResponseWriter, r *http.Request) {
    id := chi.URLParam(r, "id")
    delete(todos, id)
    w.WriteHeader(http.StatusNoContent)
}

Middleware Pattern

Chi middleware is just func(http.Handler) http.Handler:

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
        }
        // validate token...
        next.ServeHTTP(w, r)
    })
}

// Use it
r.Use(AuthMiddleware)

Graceful Shutdown

import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
)

func main() {
    srv := &http.Server{Addr: ":8080", Handler: r}

    go func() {
        if err := srv.ListenAndServe(); err != http.ErrServerClosed {
            log.Fatalf("listen: %s\n", err)
        }
    }()

    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit

    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    srv.Shutdown(ctx)
    log.Println("Server stopped")
}

Testing

func TestHealth(t *testing.T) {
    req := httptest.NewRequest("GET", "/health", nil)
    w := httptest.NewRecorder()

    r := chi.NewRouter()
    r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
        json.NewEncoder(w).Encode(map[string]bool{"ok": true})
    })
    r.ServeHTTP(w, req)

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

Build and Run

go build -o api.exe .
./api.exe

# Or run directly
go run .

The binary is self-contained — no runtime dependencies needed on the target machine.