Route bindings

Symbol and constructor bindings: every htmx URL is resolved against a real Go route at build time.

Live demo ↗

hx-bindings

Route-aware hx-* bindings end to end: a symbol binding (hx-get={ handlers.ListItems }) folds a registered path into static markup, and a typed constructor (hx-get={ ghtmxgen.GetItem(id) }) substitutes URL-escaped parameters. Renaming either route breaks the build at the binding site.

ghtmx generate && go run ./cmd    # serves http://127.0.0.1:8081/items

One deliberate wrinkle: the handlers live in a subpackage so the templates can demonstrate a cross-package symbol binding. Because the templates bind handlers.* symbols, the handlers package cannot import the template package back — the example installs the render bodies through the handlers.ListItemsBody/GetItemBody hooks in its init. Serving the handlers without those hooks installed is a loud 500 naming the missing hook, never a healthy-looking empty response. A single-package application needs none of this plumbing; the crud example is the canonical single-package shape.

items.ghtmx

package hxbindings

import (
	"github.com/go-monolith/ghtmx/examples/hx-bindings/handlers"
	"github.com/go-monolith/ghtmx/ghtmxgen"
)

templ itemList(ids []string) {
	<div id="items">
		<div class="toolbar">
			<button hx-get={ handlers.ListItems } hx-target="#items">Refresh</button>
		</div>
		<div class="pills">
			for _, id := range ids {
				<a class="pill" hx-get={ ghtmxgen.GetItem(id) } hx-target="#detail">{ id }</a>
			}
		</div>
		<div id="detail" class="detail">Select an item — its URL was built by a generated, escaping constructor.</div>
	</div>
}

// itemsPage wraps the list for the live demo's full-page response.
templ itemsPage(ids []string) {
	<!DOCTYPE html>
	<html lang="en">
		<head>
			<meta charset="utf-8"/>
			<meta name="viewport" content="width=device-width, initial-scale=1"/>
			<title>hx-bindings</title>
			@ghtmxgen.HTMXScript()
			<style>
				:root { --primary: #008391; --bg: #f5f6f7; --surface: #ffffff; --text: #1c1e21; --muted: #525860; --border: #dadde1; color-scheme: light dark; }
				@media (prefers-color-scheme: dark) {
					:root { --primary: #dbbc30; --bg: #1b1b1d; --surface: #242526; --text: #e3e3e3; --muted: #a5adba; --border: #444950; }
				}
				* { box-sizing: border-box; }
				body {
					font-family: system-ui, sans-serif; margin: 0; min-height: 100vh;
					background: var(--bg); color: var(--text);
					display: flex; justify-content: center; align-items: flex-start;
				}
				.app {
					width: min(36rem, 92vw); margin: 3rem 0; padding: 1.6rem 1.8rem;
					background: var(--surface); border: 1px solid var(--border);
					border-radius: .6rem; box-shadow: 0 8px 24px rgba(0, 0, 0, .1);
				}
				.badge {
					display: inline-block; font-size: .72rem; font-weight: 600;
					color: var(--primary); border: 1px solid var(--primary);
					border-radius: 999px; padding: .1rem .6rem; margin-bottom: .8rem;
				}
				h1 { margin: 0 0 .4rem; font-size: 1.5rem; }
				.tagline { margin: 0 0 1.2rem; color: var(--muted); font-size: .95rem; }
				button, a.action {
					font-size: .9rem; padding: .45rem .9rem; border-radius: .45rem; cursor: pointer;
					border: 1px solid var(--border); background: var(--bg); color: inherit;
					text-decoration: none; display: inline-block;
				}
				button:hover, a.action:hover { border-color: var(--primary); color: var(--primary); }
				table { width: 100%; border-collapse: collapse; margin: .6rem 0 1rem; }
				td { padding: .5rem .3rem; border-bottom: 1px solid var(--border); }
				tr:last-child td { border-bottom: none; }
				td:last-child { text-align: right; }
				.toolbar { margin-bottom: 1rem; }
				.pills { display: flex; flex-wrap: wrap; gap: .5rem; margin-bottom: 1rem; }
				.pill {
					padding: .35rem .9rem; border: 1px solid var(--border); border-radius: 999px;
					cursor: pointer; font-size: .92rem;
				}
				.pill:hover { border-color: var(--primary); color: var(--primary); }
				.detail {
					padding: .8rem 1rem; border: 1px dashed var(--border); border-radius: .45rem;
					color: var(--muted); font-size: .92rem; min-height: 2.8rem;
				}
			</style>
		</head>
		<body>
			<main class="app">
				<span class="badge">ghtmx example</span>
				<h1>Route bindings</h1>
				<p class="tagline">Every URL below is resolved against a real Go route at build time — the refresh button by handler symbol, each item by a typed constructor.</p>
				@itemList(ids)
			</main>
		</body>
	</html>
}

// itemDetail is what a constructor-bound link swaps into #detail.
templ itemDetail(id string) {
	<span>item <strong>{ id }</strong> — fetched from a constructor-built, percent-escaped URL</span>
}

handlers/handlers.go

// Package handlers holds the hx-bindings example's HTTP handlers.
//
// The templates bind these symbols (hx-get={ handlers.ListItems }),
// so this package must not import the template package back — the
// example installs the render bodies below from its own init instead.
// Serving these handlers without importing the example package is a
// wiring bug, and they fail loudly rather than masking it with an
// empty 200.
package handlers

import "net/http"

// ListItemsBody and GetItemBody are installed by the example package
// so the handlers can render its templates without an import cycle.
var (
	ListItemsBody func(http.ResponseWriter, *http.Request)
	GetItemBody   func(http.ResponseWriter, *http.Request)
)

func ListItems(w http.ResponseWriter, r *http.Request) {
	if ListItemsBody == nil {
		http.Error(w, "hx-bindings: handlers.ListItemsBody hook is not installed — import the example package", http.StatusInternalServerError)
		return
	}
	ListItemsBody(w, r)
}

func GetItem(w http.ResponseWriter, r *http.Request) {
	if GetItemBody == nil {
		http.Error(w, "hx-bindings: handlers.GetItemBody hook is not installed — import the example package", http.StatusInternalServerError)
		return
	}
	GetItemBody(w, r)
}

hxbindings.go

// The hx-bindings example exercises route-aware hx-* bindings end to end:
// a symbol binding folds a registered path into static markup, and a typed
// route constructor substitutes URL-escaped parameters (FR-020, FR-021,
// FR-023). Its test is the mandatory route-binding case of the escaping
// conformance suite (NFR-007).
package hxbindings

import (
	"log"
	"net/http"

	"github.com/go-monolith/ghtmx"
	"github.com/go-monolith/ghtmx/examples/hx-bindings/handlers"
)

// demoItems is the in-memory data the live demo lists.
var demoItems = []string{"alpha", "beta", "gamma", "a/b?c#d"}

// The handlers package cannot import this one back (the templates
// bind its symbols), so the render bodies install here.
func init() {
	handlers.ListItemsBody = func(w http.ResponseWriter, r *http.Request) {
		view := itemsPage(demoItems)
		if ghtmx.IsHTMXRequest(r) {
			view = itemList(demoItems)
		}
		if err := view.Render(r.Context(), w); err != nil {
			log.Printf("render items: %v", err)
		}
	}
	handlers.GetItemBody = func(w http.ResponseWriter, r *http.Request) {
		if err := itemDetail(r.PathValue("id")).Render(r.Context(), w); err != nil {
			log.Printf("render item detail: %v", err)
		}
	}
}

// Routes builds the example's router; the official docs site mounts
// it as a live demo.
func Routes() *http.ServeMux {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /items", handlers.ListItems)
	// The GetItem constructor this registration generates is also the
	// conformance suite's mandatory FR-023 route-binding case
	// (conformance/conformance.ghtmx) — rename in both places.
	mux.HandleFunc("GET /items/{id}", handlers.GetItem)
	return mux
}

cmd/main.go

// Command hxbindings serves the hx-bindings example standalone.
package main

import (
	"fmt"
	"net/http"
	"os"

	example "github.com/go-monolith/ghtmx/examples/hx-bindings"
)

func main() {
	addr := "127.0.0.1:8081"
	if v := os.Getenv("GHTMX_EXAMPLE_ADDR"); v != "" {
		addr = v
	}
	fmt.Printf("Listening on http://%s/items\n", addr)
	if err := http.ListenAndServe(addr, example.Routes()); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

All examples