Skip to content

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.

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.

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.

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:

  1. The carrier — the server’s layout renders the token into a meta tag in <head>:

    <meta name="csrf-token" content="{{ csrf_token }}">
  2. The attachmentinstallCsrfHeader(), shipped in the auto-init @hypermedia-components/core/behaviors bundle, reads that meta tag on every htmx:configRequest and 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-headers value for the same header name is never overwritten.
  • No meta tag → inert. Pages that don’t opt in pay nothing.

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.

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>

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 + chrome

Each framework has an idiomatic way to test for the header — see the framework guide for snippets.

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 OK
HX-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}
HTTP/1.1 422 Unprocessable Entity
HX-Reswap: outerHTML

Useful 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 Error
HX-Retarget: #flash
HX-Reswap: innerHTML

Redirect the swap to a flash region when the original target would have hidden the error.

If you build the docs site or read the source, you’ll notice that the behaviors hook into a small set of htmx events:

EventUsed byWhat it triggers
htmx:configRequestinstallCsrfHeaderAttach the page’s CSRF token (<meta name="csrf-token">) as a request header — see the convention above.
htmx:afterRequestinstallCloseDialog / installClosePopoverClose the closest <dialog> / [popover] on a successful response.
htmx:afterSwapinstallRemoteDialogOpen a <dialog> that just appeared inside a [data-hc-remote-dialog-root].
hc:toastinstallToastRender 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.

Many destructive endpoints return an empty body plus an HX-Trigger header:

HTTP/1.1 200 OK
HX-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.

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.

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 wantUseNotes
Auto-refresh a region (polling / events)data-regiondata-hx-trigger="load, every 15s", event-driven via HX-Trigger, and whole-region self-replacement with hx-select (below).
Busy indicator + double-submit guardrequest-actiondata-hx-indicator + data-hx-disabled-elt — see Indicators.
Confirm before a destructive requestconfirm-actionThe gate is event-based — read the specification below.
Inline server validation errorsfield-errorsA documented 422 fragment + installFieldErrors() — see non-2xx responses.
Notify after a requesttoastHX-Trigger: {"hc:toast": …} from any endpoint.
Load content into a dialogremote-dialogSwap into [data-hc-remote-dialog-root].

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):

  1. 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.
  2. Cancel → nothing else happens. No request.
  3. Confirm → a bubbling hc:confirmed event is dispatched on the trigger element. htmx, wired with data-hx-trigger="hc:confirmed", issues the request now.
  4. 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.
  5. Do not combine with hx-confirm: htmx never sees the activation, so hx-confirm simply never runs. Pick one mechanism.
  6. The interception is delegated (document-level), so buttons swapped in by htmx are confirmed with no re-initialization.

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).

  • 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.