Routing

Design routes by response surface: page HTML, partial HTML, JSON, and SSE.

In GO(A)HT, route design starts with response surfaces. Decide what the browser needs back, then give that surface a clear handler.

The four common surfaces are:

  1. page HTML
  2. partial HTML
  3. JSON for Alpine islands
  4. SSE

Start With ServeMux

Go 1.22+ gives you method-aware routes and path params in http.ServeMux:

mux := http.NewServeMux()

mux.HandleFunc("GET /{$}", homePage)
mux.HandleFunc("GET /games/{id}", gamePage)
mux.HandleFunc("GET /games/{id}/scoreboard", gameScoreboard)
mux.HandleFunc("GET /api/games/{id}/state", gameStateJSON)
mux.HandleFunc("GET /games/{id}/events", gameEvents)

Use r.PathValue("id") to read params:

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

Group Routes By Response Surface

This keeps the contract obvious:

func RegisterRoutes(mux *http.ServeMux, h *Handlers) {
    // Page HTML
    mux.HandleFunc("GET /{$}", h.Pages.Home)
    mux.HandleFunc("GET /games/{id}", h.Pages.ShowGame)

    // Partial HTML
    mux.HandleFunc("GET /games/{id}/scoreboard", h.Partials.Scoreboard)
    mux.HandleFunc("GET /games/{id}/players", h.Partials.PlayerList)

    // JSON for Alpine islands
    mux.HandleFunc("GET /api/games/{id}/state", h.API.GameState)
    mux.HandleFunc("POST /api/games/{id}/throws", h.API.RecordThrow)

    // SSE
    mux.HandleFunc("GET /games/{id}/events", h.SSE.StreamGame)
}

The pattern is simple:

  • /games/{id} returns a full page
  • /games/{id}/scoreboard returns a fragment
  • /api/games/{id}/state returns JSON
  • /games/{id}/events returns text/event-stream

Page HTML

Page routes render a full document. They are the default.

func (h *PageHandlers) ShowGame(w http.ResponseWriter, r *http.Request) {
    vm, err := h.games.BuildGamePage(r.Context(), r.PathValue("id"))
    if err != nil {
        http.Error(w, "not found", http.StatusNotFound)
        return
    }

    h.render.Page(w, "pages/game.html", vm)
}

Use page routes when the browser is loading a document or when HTMX needs to replace a large surface such as <main>.

Partial HTML

Partial routes return only the fragment HTMX needs to swap.

func (h *PartialHandlers) Scoreboard(w http.ResponseWriter, r *http.Request) {
    vm, err := h.games.BuildScoreboard(r.Context(), r.PathValue("id"))
    if err != nil {
        http.Error(w, "not found", http.StatusNotFound)
        return
    }

    h.render.Partial(w, "partials/scoreboard.html", vm)
}

Use partial routes for:

  • inline refreshes
  • form validation errors
  • list rows
  • status banners
  • scoreboards and lobby panels

For most UI work, this is the main surface. HTML fragments are the primary UI API.

JSON for Alpine Islands

JSON is not the default app surface. It exists for small Alpine islands that need local state or optimistic updates.

func (h *APIHandlers) GameState(w http.ResponseWriter, r *http.Request) {
    state, err := h.games.StateJSON(r.Context(), r.PathValue("id"))
    if err != nil {
        http.Error(w, "not found", http.StatusNotFound)
        return
    }

    writeJSON(w, state)
}

Good JSON endpoints are narrow:

  • one island
  • one state shape
  • one mutation flow

If the same interaction can be expressed as an HTML fragment, prefer the fragment.

SSE

SSE routes are the push surface. They do not render UI directly; they notify clients that something changed.

func (h *SSEHandlers) StreamGame(w http.ResponseWriter, r *http.Request) {
    gameID := r.PathValue("id")
    h.stream.Game(w, r, gameID)
}

Pair SSE with one of two consumers:

  • HTMX refresh events that re-fetch HTML
  • Alpine state events that merge JSON into local state

A Practical Handler Shape

Handler grouping can mirror the surfaces:

type Handlers struct {
    Pages    *PageHandlers
    Partials *PartialHandlers
    API      *APIHandlers
    SSE      *SSEHandlers
}

That is usually clearer than one giant handler type once an app has a mix of pages, fragments, JSON, and streams.

Naming and URL Rules

  • Keep page and partial URLs user-facing and stable.
  • Put Alpine-only JSON under /api/... so it is visibly exceptional.
  • Keep SSE URLs beside the resource they describe, such as /games/{id}/events.
  • Name fragment routes after what they render, not after the JS action that calls them.

Gotchas

  • GET /{$} is an exact root match. GET / is a catch-all.
  • ServeMux chooses the most specific route, not the first one registered.
  • Path params are strings. Parse and validate before use.
  • Do not collapse partial HTML and JSON into one handler just because they read the same data.
  • If a route returns HTML, let that be obvious from the path and the handler name.