htmx
Hypermedia Components is built around htmx. The framework guides cover server-side glue; this page collects the htmx-side concerns that are the same regardless of which template engine you use.
Loading htmx
Section titled “Loading htmx”htmx is a single ~30 KB minified+gzipped file. Drop it in the page
before any markup that uses data-hx-* attributes:
<script defer src="/assets/htmx.min.js"></script>Pin a major version when you copy a release; htmx 2.x is the line we target.
data-hx-* over hx-*
Section titled “data-hx-* over hx-*”The Hypermedia Components docs prefer data-hx-* (the HTML5-compliant
form) over hx-*. Both work — htmx supports either — but
data-hx-* survives strict linters, validates against the HTML spec,
and tends to round-trip cleanly through template engines that escape
custom attributes.
<!-- Preferred --><button data-hx-post="/items">Save</button>
<!-- Also valid --><button hx-post="/items">Save</button>Pick one and stay consistent within a project. Mixing inside the same markup is fine for htmx but harder to grep.
CSRF tokens — the blessed convention
Section titled “CSRF tokens — the blessed convention”Server frameworks validate state-changing browser requests with a CSRF token, usually carried in a request header. Hypermedia Components blesses one convention so server frameworks and code generators have a stable markup target instead of each app inventing its own wiring:
-
The carrier — the server’s layout renders the token into a meta tag in
<head>:<meta name="csrf-token" content="{{ csrf_token }}"> -
The attachment —
installCsrfHeader(), shipped in the auto-init@hypermedia-components/core/behaviorsbundle, reads that meta tag on everyhtmx:configRequestand adds the header to the request. No per-app JavaScript beyond loading the bundle:<script type="module" src="/assets/hc/hc.behaviors.min.js"></script>
The header name defaults to X-CSRF-Token (the Rails convention).
For a backend that expects a different name, set it on the carrier — no
code change:
<!-- Django --><meta name="csrf-token" content="{{ csrf_token }}" data-header="X-CSRFToken">What the behavior guarantees:
- Read at request time, so a rotated token (a fresh value swapped into the meta tag) is picked up automatically — nothing is captured at install.
- Every htmx request carries it (not just non-GET). A same-origin header on a GET is harmless; the server enforces only where it enforces, and this sidesteps verb-classification edge cases.
- An explicit header wins. A per-request
data-hx-headersvalue for the same header name is never overwritten. - No meta tag → inert. Pages that don’t opt in pay nothing.
Rolling your own in htmx:configRequest
Section titled “Rolling your own in htmx:configRequest”If you’d rather not ship the behavior — or you need to layer other
headers — htmx:configRequest is the underlying hook. It fires once
per request, before htmx serializes the form data and headers, and is
the single most useful place to add auth, locale, or feature-flag
headers:
<script> document.body.addEventListener('htmx:configRequest', (event) => { // CSRF — equivalent to installCsrfHeader(), done by hand. const csrfMeta = document.querySelector('meta[name="csrf-token"]'); if (csrfMeta) { event.detail.headers['X-CSRF-Token'] = csrfMeta.content; }
// Locale, feature flag, anything else… event.detail.headers['Accept-Language'] = document.documentElement.lang; });</script>The framework guides spell out the exact header name each backend
expects (X-CSRFToken for Django, X-CSRF-Token for Rails,
RequestVerificationToken for ASP.NET Core, the Spring Security
_csrf header/token meta pair, …). Pick the right one for your
stack — the data-header attribute and this hook both target it.
Per-page or per-request overrides
Section titled “Per-page or per-request overrides”For a one-off override (a header that should only travel with one
button), use the data-hx-headers attribute instead. It accepts a JSON
object and merges into the configured headers.
<button class="hc-button" data-hx-post="/items" data-hx-headers='{"X-Source":"hero"}'> Save</button>Detecting htmx server-side
Section titled “Detecting htmx server-side”Every htmx request sets HX-Request: true. Handlers branch on it to
return either a full page (browser navigation) or just a fragment
(an htmx swap):
GET /items HX-Request: true → return rows fragment (no header) → return full page with layout + chromeEach framework has an idiomatic way to test for the header — see the framework guide for snippets.
Response headers htmx understands
Section titled “Response headers htmx understands”htmx watches the response for a handful of headers that change how it processes the swap. Hypermedia Components uses three regularly:
HX-Trigger — dispatch events into the page
Section titled “HX-Trigger — dispatch events into the page”Returns a JSON map of event-name → detail. htmx dispatches each event
on <body> after the swap completes. This is how
the toast recipe ships a
notification without coupling to a DOM location:
HTTP/1.1 200 OKHX-Trigger: {"hc:toast":{"message":"Saved","variant":"success"}}Multiple events in one response are comma-separated entries inside the JSON object:
HX-Trigger: {"hc:toast":{"message":"Saved"}, "items:refresh":true}HX-Reswap — change the swap strategy
Section titled “HX-Reswap — change the swap strategy”HTTP/1.1 422 Unprocessable EntityHX-Reswap: outerHTMLUseful for validation responses where the original swap was
innerHTML but the new HTML is a complete replacement.
HX-Retarget — change where the swap lands
Section titled “HX-Retarget — change where the swap lands”HTTP/1.1 500 Internal Server ErrorHX-Retarget: #flashHX-Reswap: innerHTMLRedirect the swap to a flash region when the original target would have hidden the error.
Events the HC behaviors listen for
Section titled “Events the HC behaviors listen for”If you build the docs site or read the source, you’ll notice that the behaviors hook into a small set of htmx events:
| Event | Used by | What it triggers |
|---|---|---|
htmx:configRequest | installCsrfHeader | Attach the page’s CSRF token (<meta name="csrf-token">) as a request header — see the convention above. |
htmx:afterRequest | installCloseDialog / installClosePopover | Close the closest <dialog> / [popover] on a successful response. |
htmx:afterSwap | installRemoteDialog | Open a <dialog> that just appeared inside a [data-hc-remote-dialog-root]. |
hc:toast | installToast | Render a toast into the region. (Not an htmx event, but htmx is usually the dispatcher via HX-Trigger.) |
You generally don’t have to interact with these directly; they’re listed here for debugging and for projects that want to plug in their own htmx middleware.
Empty bodies are valid
Section titled “Empty bodies are valid”Many destructive endpoints return an empty body plus an HX-Trigger
header:
HTTP/1.1 200 OKHX-Trigger: {"hc:toast":{"message":"Deleted.","variant":"success"}}
(empty body)The client swap is typically outerHTML on the deleted row, so htmx
removes the row regardless of body content — the body would have
nothing to swap in anyway.
Indicators
Section titled “Indicators”data-hx-indicator points at an element that should switch on while
the request is in flight. The HC convention pairs it with a
.hc-spinner.htmx-indicator inside a .hc-action wrapper — see the
request-action recipe
for the canonical shape.
<span class="hc-action"> <button class="hc-button" data-hx-post="/items" data-hx-target="#items" data-hx-disabled-elt="this" data-hx-indicator="closest .hc-action"> Save </button> <span class="hc-spinner htmx-indicator" aria-hidden="true"></span></span>hc.htmx.css styles .htmx-indicator (hidden by default) and
.htmx-request (the in-flight state), so the wiring is purely
declarative.
Disabling controls during requests
Section titled “Disabling controls during requests”data-hx-disabled-elt="this" adds the native disabled attribute
during the request. It prevents double-submits without any custom JS
and pairs naturally with the spinner pattern above. For destructive
actions, combine it with data-hc-confirm (the
confirm-action recipe).
Common patterns — which recipe answers what
Section titled “Common patterns — which recipe answers what”The questions every hypermedia app ends up asking, and the blessed answer for each:
| You want | Use | Notes |
|---|---|---|
| Auto-refresh a region (polling / events) | data-region | data-hx-trigger="load, every 15s", event-driven via HX-Trigger, and whole-region self-replacement with hx-select (below). |
| Busy indicator + double-submit guard | request-action | data-hx-indicator + data-hx-disabled-elt — see Indicators. |
| Confirm before a destructive request | confirm-action | The gate is event-based — read the specification below. |
| Inline server validation errors | field-errors | A documented 422 fragment + installFieldErrors() — see non-2xx responses. |
| Notify after a request | toast | HX-Trigger: {"hc:toast": …} from any endpoint. |
| Load content into a dialog | remote-dialog | Swap into [data-hc-remote-dialog-root]. |
Confirm gating specification
Section titled “Confirm gating specification”data-hc-confirm and htmx compose through one rule: the behavior
intercepts the activation, and htmx listens for hc:confirmed.
<button class="hc-button" data-variant="error" data-hc-confirm="Disable user alice?" data-hx-post="/users/alice/disable" data-hx-trigger="hc:confirmed" data-hx-target="#user-row"> Disable user</button>The exact semantics (browser-tested):
installConfirm()intercepts the click in the capture phase (preventDefault+stopPropagation), so no other click handler — htmx’s included — ever observes it. The shared dialog opens.- Cancel → nothing else happens. No request.
- Confirm → a bubbling
hc:confirmedevent is dispatched on the trigger element. htmx, wired withdata-hx-trigger="hc:confirmed", issues the request now. - Therefore
data-hx-trigger="hc:confirmed"is required on a confirmed element. With the default click trigger the element is inert for htmx — the click never reaches it, and it doesn’t listen for the confirm event. - Do not combine with
hx-confirm: htmx never sees the activation, sohx-confirmsimply never runs. Pick one mechanism. - The interception is delegated (document-level), so buttons swapped in by htmx are confirmed with no re-initialization.
Validation errors and non-2xx responses
Section titled “Validation errors and non-2xx responses”htmx ≥ 2 does not swap non-2xx responses by default. For the
field-errors contract
(a 422 returning the documented hc-alert fragment), allow the swap
once, globally:
document.body.addEventListener('htmx:beforeSwap', (event) => { if (event.detail.xhr.status === 422) { event.detail.shouldSwap = true; event.detail.isError = false; }});Alternatives that need no JS: answer 200 with the error fragment, or
send HX-Retarget: #form-errors + HX-Reswap: innerHTML to steer the
response into the error container (response headers).
Related
Section titled “Related”- Plain HTML — the simplest asset layout (linked from the integrations index).
- Hyperscript — optional scripting language that pairs with htmx the same way HC’s behaviors do.
- Framework guides — Thymeleaf, Django, Rails, Go, Razor — each inherits the conventions above and adds the language-specific glue.
- htmx documentation — the upstream reference for every attribute and header listed here.