The server owns state; a template only presents it; htmx moves HTML between the two. Most drift in these apps comes from that split being decided ad hoc, per handler and per template: seven ways to detect an htmx request, the same four attributes hand-copied across files, errors that vanish. The rules below make each of those decisions once.
This skill holds the general rules. The repo's AGENTS.md holds what only the repo knows: its swap-target ids, the names of its shared partials, and the htmx version it vendors. Read it first.
Where to look
| If you're working on… | Load |
|---|---|
A handler: choosing page / fragment / redirect, htmx headers, Vary, 303, error responses |
references/responses.md |
| A template: partials, repeated attribute bundles, forms, live regions, conditionals | references/templates.md |
Any client JS: whether you need it at all, islands, htmx glue, polling guards, aria-busy |
references/client.md |
The rules
- htmx headers go through one helper set, never hand-typed strings.
A typo in
"HX-Request"or a check for""instead of"true"fails silently; one package of constants and predicates makes the wrong spelling unrepresentable. If the repo has no such set yet, introduce it the first time a handler you touch needs an htmx header —Varyincluded. - Every route declares its response shape — page, fragment or redirect — in one place.
A handler that sniffs headers inline grows a new branch per feature. When one URL can return
either a page or a fragment, set
Vary: HX-Request, or a cache or the back button can serve the bare fragment as a page. A history-restore request (HX-History-Restore-Request: true) always gets the full page. - A non-htmx form POST answers 303 to a GET; an htmx one answers
HX-Locationor a fragment. 303 stops a refresh from resubmitting. A 303 to an htmx request is followed by the XHR and the whole redirected page gets swapped into the target, which is never what you want. - Forms work without JavaScript. Real
actionandmethod, a real submit button; htmx attributes layer on top. The non-JS path is also the path the 303 rule serves. - A repeated attribute bundle becomes a named
{{define}}partial. Four attributes copied to seventeen links drift the moment one copy changes. One partial, one place to change it. (codebase-designhas the vocabulary for why: one deep module beats many shallow copies.) - Polling never fights the user.
hx-syncon every polling element, a guard that skips the poll while the user is focused or typing inside the target, and a target that never contains an input the user may be editing. - Errors reach the user. htmx 2.x does not swap 4xx/5xx bodies by default, so a refused
action looks like nothing happened. Fix it in two halves: a
responseHandlingentry that swaps the status you use (typically 422), andHX-Retarget/HX-Reswapfrom the server so the error lands in a known region. That region is a persistentaria-livecontainer. - In-flight state is visible.
hx-disabled-elton submit controls so a double-click cannot double-submit;hx-indicatororaria-busyon the region being replaced. - Client JS is the exception, and it is never inline. Before writing any, ask whether the
server can own it (a preference, a re-rendered region) or HTML can (
<details>,<dialog>,popover). A widget with real client state becomes an island in the repo's frontend framework, mounted throughhtmx.onLoadand unmounted on swap. The only other JS is a fixed set of htmx glue listeners. No<script>blocks,onclickorhx-onin templates: inline handlers are untestable and re-run unpredictably on swaps. htmx config goes in<meta name="htmx-config">with"allowEval": false, so htmx itself refuseshx-onandjs:values. - Templates branch on presentation only.
{{if eq .Status "failed"}}chains are domain logic in the wrong layer. Compute a view-model field in Go (.Badge,.CanRetry) and let the template read it.
Choosing a response shape
request ──► HX-History-Restore-Request: true? ──yes──► page
│ no
▼
HX-Request: true? ──no──► POST? ──yes──► 303 to GET (or re-render page with 422 on invalid)
│ yes └─no──► page
▼
route declares: fragment ──► ExecuteTemplate(w, "<block>", vm)
page+select ──► page (template uses hx-select)
redirect ──► HX-Location (or HX-Redirect for a full reload)
references/responses.md has the helper set and a handler written both ways.
htmx version
These rules target htmx 2.x. Check the version the repo vendors before relying on defaults. If it is on 4.x, re-check rules 7 and 9 against the 4.x migration notes:
- error responses swap by default, with per-status control through
hx-status; - attribute inheritance is explicit, so bundles that relied on inheriting
hx-targetbreak; - event names changed, so listeners in the TS module need renaming.
What not to do
- Don't reach for the
response-targetsextension to route errors. It moves a server decision into markup and adds a second mechanism next toHX-Retarget. - Don't swap an
aria-liveregion withouterHTML. Screen readers track the original node; a replaced live region announces nothing. Swap itsinnerHTML. - Don't render into the
ResponseWriterdirectly. Execute into a buffer first, so a template error becomes a 500 rather than a half-written 200. - Don't add
hx-boostto fix a navigation problem. Declare the nav-link partial instead; boost changes every link and form under it at once. - Don't restate the htmx attribute reference here or in the repo; link htmx.org/reference.