Since Go 1.22, net/http.ServeMux handles methods and path parameters, so a typical JSON API needs no router library. Frameworks like gin or chi still work, but are no longer required.

Routing with ServeMux

mux := http.NewServeMux()

mux.HandleFunc("GET /healthz", health)
mux.HandleFunc("POST /api/join", join)
mux.HandleFunc("GET /api/quizzes/{id}", getQuiz)
mux.HandleFunc("PUT /api/quizzes/{id}", updateQuiz)
mux.HandleFunc("GET /files/{path...}", serveFile) // wildcard rest-of-path
mux.Handle("/", spaHandler)                       // catch-all

func getQuiz(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    ...
}
panic: pattern "GET /" conflicts with pattern "/api/":
  GET / matches fewer methods than /api/, but has a more general path pattern

Middleware is a function

No framework concept needed: a middleware takes a handler and returns a handler.

func securityHeaders(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("X-Content-Type-Options", "nosniff")
        next.ServeHTTP(w, r)
    })
}

mux.Handle("GET /api/admin/me", requireAdmin(adminMe))
handler := recoverer(securityHeaders(mux))

Pass request-scoped data (the authenticated user) through the context with an unexported key type:

type ctxKey int
const ctxUser ctxKey = iota

ctx := context.WithValue(r.Context(), ctxUser, user)
next.ServeHTTP(w, r.WithContext(ctx))

// later
u, ok := r.Context().Value(ctxUser).(User)

JSON in and out

func writeJSON(w http.ResponseWriter, status int, v any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(v)
}

var req CreateQuizRequest
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // cap the body size
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil { ... }

Struct tags control the mapping: `json:"timeLimitSeconds,omitempty"`.


Configure http.Server, don’t use the default

http.ListenAndServe(addr, h) has no timeouts. Set at least ReadHeaderTimeout:

srv := &http.Server{
    Addr:              addr,
    Handler:           h,
    ReadHeaderTimeout: 10 * time.Second,
}

See Concurrency for graceful shutdown.


Structured logging with log/slog

log := slog.New(slog.NewTextHandler(os.Stderr, nil)) // or NewJSONHandler
slog.SetDefault(log)

log.Info("listening", "addr", addr)
log.Warn("forgot idle sessions", "count", n, "ttl", ttl)

Key/value pairs instead of format strings. In tests, discard output with slog.New(slog.NewTextHandler(io.Discard, nil)).


Ship the frontend inside the binary: go:embed

package web

import "embed"

//go:embed all:dist
var Dist embed.FS
sub, _ := fs.Sub(web.Dist, "dist")
mux.Handle("/", http.FileServerFS(sub))

The SPA build output is compiled into the binary: one file to deploy, no separate static host. all: includes dotfiles and _-prefixed files.

Tip: expose runtime settings via an endpoint (e.g. GET /api/config) instead of baking them into the frontend build, so one binary works in every environment.


Configuration from the environment

No library needed for a handful of settings. Parse once in main, fail fast with a clear message:

func envDuration(key string, def time.Duration) (time.Duration, error) {
    v := os.Getenv(key)
    if v == "" {
        return def, nil
    }
    d, err := time.ParseDuration(v)
    if err != nil || d <= 0 {
        return 0, fmt.Errorf("%s=%q: want a positive duration such as 24h", key, v)
    }
    return d, nil
}

For larger configs, github.com/caarlos0/env maps env vars onto struct tags.


Profiling a live process: net/http/pprof

mux := http.NewServeMux()
mux.HandleFunc("/debug/pprof/", pprof.Index)
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
go http.ListenAndServe("127.0.0.1:6060", mux)
go tool pprof -http=: http://127.0.0.1:6060/debug/pprof/heap
go tool pprof -http=: 'http://127.0.0.1:6060/debug/pprof/profile?seconds=30'

pprof exposes heap contents, stacks and the command line. Bind it to localhost or put it behind real authentication; never expose it publicly.


Small libraries that fill the gaps

Need Library
WebSockets github.com/coder/websocket
Postgres github.com/jackc/pgx/v5
OIDC token verification github.com/coreos/go-oidc/v3
Type-safe SQL from queries sqlc
Migrations github.com/golang-migrate/migrate
OpenAPI codegen oapi-codegen (see OpenAPI Code Generator)
Metrics github.com/prometheus/client_golang
Terminal UIs github.com/charmbracelet/bubbletea + lipgloss