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")
...
}
- Method in the pattern: wrong method → automatic
405with anAllowheader. - Most specific pattern wins, regardless of registration order.
- Ambiguous patterns panic at registration, so conflicts surface at startup, not in production:
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 |