Go
This guide pairs Hypermedia Components with Go’s standard library
(net/http + html/template) and htmx. The patterns also work with
chi, gorilla/mux, and any router that exposes the standard
http.ResponseWriter / *http.Request pair.
Asset loading
Section titled “Asset loading”Put the HC dist files under a static/ directory served by
http.FileServer.
static/ assets/ hc/ hc.css hc.behaviors.min.js macros/ index.min.js htmx.min.jstemplates/ layout.html items/ list.html row.htmlpackage main
import ( "net/http")
func main() { fs := http.FileServer(http.Dir("static")) http.Handle("/static/", http.StripPrefix("/static/", fs)) http.HandleFunc("/items", listItems) http.HandleFunc("/items/", itemHandler) // /items/{id} http.ListenAndServe(":8080", nil)}Templates
Section titled “Templates”html/template auto-escapes attribute values, which is exactly what
Hypermedia Components needs. Compose pages from a layout + named
blocks.
{{define "layout"}}<!DOCTYPE html><html lang="en"><head> <meta charset="utf-8"> <title>{{.Title}}</title> <link rel="stylesheet" href="/static/assets/hc/hc.css"> <script defer src="/static/assets/htmx.min.js"></script> <script type="module" src="/static/assets/hc/hc.behaviors.min.js"></script> <script type="module" src="/static/assets/hc/macros/index.min.js"></script></head><body> {{template "content" .}}
<div class="hc-toast-region" data-hc-toast-region role="region" aria-label="Notifications"></div></body></html>{{end}}A row partial reused by both the full list and per-row swaps:
{{define "row"}}<tr id="item-{{.ID}}"> <td>{{.Name}}</td> <td><span class="hc-badge" data-variant="{{.Status}}">{{.StatusLabel}}</span></td> <td> <span class="hc-action"> <button class="hc-button" data-size="sm" data-variant="error" type="button" data-hc-confirm="Delete {{.Name}}?" data-hx-delete="/items/{{.ID}}" data-hx-trigger="hc:confirmed" data-hx-target="closest tr" data-hx-swap="outerHTML" data-hx-disabled-elt="this" data-hx-indicator="closest .hc-action"> Delete </button> <span class="hc-spinner htmx-indicator" aria-hidden="true"></span> </span> </td></tr>{{end}}html/template’s contextual escaping means {{.Name}} is safe in
attribute values too — no manual escaping needed.
Handlers and fragments
Section titled “Handlers and fragments”Pre-parse templates once at startup:
var tpl = template.Must(template.ParseGlob("templates/**/*.html"))A handler that returns a single row for an htmx POST:
func createItem(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } name := strings.TrimSpace(r.FormValue("name")) if name == "" { w.WriteHeader(http.StatusUnprocessableEntity) fmt.Fprintf(w, `<tr><td colspan="3"><p class="hc-field__message" style="color: var(--hc-color-error);">Name is required.</p></td></tr>`) return } item := store.Create(name)
w.Header().Set("HX-Trigger", `{"hc:toast":{"message":"Added.","variant":"success"}}`) w.WriteHeader(http.StatusCreated) _ = tpl.ExecuteTemplate(w, "row", item)}A handler that returns nothing but fires a toast:
func deleteItem(w http.ResponseWriter, r *http.Request) { id := pathID(r) // your router's id extraction item, ok := store.Delete(id) if !ok { http.NotFound(w, r) return } trigger := map[string]any{ "hc:toast": map[string]string{ "message": fmt.Sprintf("Deleted %q.", item.Name), "variant": "success", }, } payload, _ := json.Marshal(trigger) w.Header().Set("HX-Trigger", string(payload)) // empty body — htmx removes the row via outerHTML swap}Toasts via HX-Trigger
Section titled “Toasts via HX-Trigger”A small helper formats the JSON payload safely:
func HxTrigger(w http.ResponseWriter, events map[string]any) { if existing := w.Header().Get("HX-Trigger"); existing != "" { var current map[string]any if json.Unmarshal([]byte(existing), ¤t) == nil { for k, v := range events { current[k] = v } events = current } } if b, err := json.Marshal(events); err == nil { w.Header().Set("HX-Trigger", string(b)) }}Then in handlers:
HxTrigger(w, map[string]any{ "hc:toast": map[string]string{"message": "Saved.", "variant": "success"},})Detecting htmx requests
Section titled “Detecting htmx requests”func isHTMX(r *http.Request) bool { return r.Header.Get("HX-Request") == "true"}
func listItems(w http.ResponseWriter, r *http.Request) { items := store.All() if isHTMX(r) { _ = tpl.ExecuteTemplate(w, "rows", items) return } _ = tpl.ExecuteTemplate(w, "list", items)}CSRF tips
Section titled “CSRF tips”html/template does not ship CSRF; pick a middleware (e.g.
gorilla/csrf) and forward the
token to htmx in the layout:
<meta name="csrf-token" content="{{.CSRFToken}}"><script> document.body.addEventListener("htmx:configRequest", (e) => { const token = document.querySelector('meta[name="csrf-token"]')?.content; if (token) e.detail.headers["X-CSRF-Token"] = token; });</script>Pass the token into the template data from your middleware
(gorilla/csrf exposes csrf.Token(r)).
embed.FS— embed templates and static assets into the binary with//go:embed templates/* static/*and serve fromhttp.FS(embedFS). Keeps deploys to a single file.text/templatevshtml/template— always usehtml/templatefor HC fragments; the contextual escaping is what keeps attribute interpolation safe.- Streaming swaps — for long-running searches, return chunked
fragments; htmx supports out-of-band swaps via
data-hx-swap-oob="true"on returned elements.
Related
Section titled “Related”- Recipes → confirm-action
- Recipes → live-search
- Examples → htmx — the bundled example uses a Node http server with the same handler shape.