Routing

Go 1.22+ stdlib routing with method-based patterns and middleware.

Go 1.22 added enhanced routing to http.NewServeMux() — method-based patterns, path parameters, and wildcard matching. No router library needed.

Basic Routing

mux := http.NewServeMux()

// Method + path pattern
mux.HandleFunc("GET /{$}", handleHome)           // Exact root match
mux.HandleFunc("GET /game/{id}", handleGame)      // Path parameter
mux.HandleFunc("POST /game/new", handleCreateGame)
mux.HandleFunc("GET /api/game/{id}/state", handleGameState)

Path parameters are accessed via r.PathValue():

func handleGame(w http.ResponseWriter, r *http.Request) {
    gameID := r.PathValue("id")
    // ...
}

Handler Structs

Group related handlers into a struct with dependencies injected:

type GameHandler struct {
    gameSvc *service.GameService
    sseHub  *service.SSEHub
}

func NewGameHandler(gameSvc *service.GameService, sseHub *service.SSEHub) *GameHandler {
    return &GameHandler{gameSvc: gameSvc, sseHub: sseHub}
}

func (h *GameHandler) CreateGame(w http.ResponseWriter, r *http.Request) {
    game, err := h.gameSvc.CreateGame(r.Context())
    if err != nil {
        http.Error(w, "Failed to create game", 500)
        return
    }
    http.Redirect(w, r, "/game/"+strconv.Itoa(int(game.ID)), http.StatusSeeOther)
}

Centralized Route Registration

Keep all routes in one file for visibility:

func Setup(mux *http.ServeMux, handlers *Handlers, auth *middleware.AuthMiddleware) {
    // Public routes
    mux.HandleFunc("GET /health", healthCheck)
    mux.HandleFunc("GET /login", handlers.Auth.Login)
    mux.HandleFunc("GET /login/callback", handlers.Auth.Callback)

    // Public with optional auth (user populated if logged in)
    mux.Handle("GET /{$}", auth.OptionalAuth(http.HandlerFunc(handlers.Template.Index)))
    mux.Handle("GET /game/{id}", auth.OptionalAuth(http.HandlerFunc(handlers.Game.Show)))

    // JSON API (for Alpine.js)
    mux.Handle("GET /api/game/{id}/state", auth.OptionalAuth(http.HandlerFunc(handlers.Game.APIGetState)))
    mux.Handle("POST /api/game/{id}/throw", auth.OptionalAuth(http.HandlerFunc(handlers.Game.APIThrow)))

    // SSE streams (no auth wrapper needed)
    mux.HandleFunc("GET /game/{id}/events", handlers.SSE.GameStream)

    // Static files
    mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static"))))

    // Protected routes (require auth)
    protected := http.NewServeMux()
    protected.HandleFunc("GET /settings", handlers.Template.Settings)
    protected.HandleFunc("POST /game/new", handlers.Game.CreateGame)
    mux.Handle("/", auth.Auth(protected))
}

Protected Route Pattern

Use a nested ServeMux for auth-required routes:

// All routes registered on `protected` require authentication
protected := http.NewServeMux()
protected.HandleFunc("GET /dashboard", handleDashboard)
protected.HandleFunc("POST /game/new", handleCreateGame)

// Wrap with auth middleware and mount as catch-all
mux.Handle("/", auth.Auth(protected))

This is clean — unmatched public routes fall through to the protected mux, which checks auth before dispatching.

Gotchas

  • GET /{$} vs GET / — The {$} suffix means exact match. Without it, GET / matches everything.
  • Method is required for specificity. mux.HandleFunc("/foo", h) matches all methods. mux.HandleFunc("GET /foo", h) only matches GET.
  • Path params are strings. Always parse and validate: strconv.ParseInt(r.PathValue("id"), 10, 32).
  • Order doesn’t matter. The new mux picks the most specific pattern, not first-match.