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:
- page HTML
- partial HTML
- JSON for Alpine islands
- 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}/scoreboardreturns a fragment/api/games/{id}/statereturns JSON/games/{id}/eventsreturnstext/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.ServeMuxchooses 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.