Server Integration
htmx’s server contract is small: read a few HX-* request headers, return HTML (usually a fragment), and set HX-* response headers when you want to control the client. This page is framework-agnostic; Go snippets are minimal.
Branch on HX-Request
The core server-side decision: full page or fragment?
func stories(w http.ResponseWriter, r *http.Request) {
// HX-Request is "true" on every htmx-driven request
if r.Header.Get("HX-Request") == "true" {
tmpl.ExecuteTemplate(w, "story-rows", data) // fragment
return
}
tmpl.ExecuteTemplate(w, "page", data) // full page
}
Gotcha: history restoration also sends
HX-Request: trueby default (confighistoryRestoreAsHxRequest,src/htmx.js:281). If you branch on that header, a back-button restore can receive a fragment where it needs a full page. Sethtmx.config.historyRestoreAsHxRequesttofalse, or check for theHX-History-Restore-Requestheader explicitly.
Request headers
| Header | Meaning |
|---|---|
HX-Request | true on htmx requests (except history restores when historyRestoreAsHxRequest is disabled) |
HX-Boosted | request came from an hx-boost element |
HX-Current-URL | the browser’s current URL |
HX-Target | id of the target element, if it has one |
HX-Trigger | id of the triggering element, if it has one |
HX-Trigger-Name | name of the triggering element, if it has one |
HX-Prompt | the user’s answer to an hx-prompt |
HX-History-Restore-Request | true when restoring a page the local history cache missed |
Response headers
| Header | Effect |
|---|---|
HX-Trigger | fire client-side events after the swap; JSON map of event names |
HX-Trigger-After-Swap | same, but after the swap step |
HX-Trigger-After-Settle | same, but after the settle step |
HX-Redirect | full-page client-side redirect |
HX-Location | client-side navigation without a full reload |
HX-Refresh | true forces a full page refresh |
HX-Push-Url / HX-Replace-Url | push or replace the history entry |
HX-Reswap | override the swap style for this response |
HX-Retarget | CSS selector overriding the target |
HX-Reselect | CSS selector choosing which part of the response swaps in |
Status codes
htmx’s default responseHandling array decides what a status means:
- 204 No Content - do nothing, not an error. The cheapest “acknowledged, change nothing” response.
- 2xx and 3xx - swap the response body.
- 4xx and 5xx - treated as errors; the body is not swapped, and
htmx:responseErrorfires.
Gotcha: 422 is in the 4xx bucket, so a validation response with inline error HTML is silently dropped by default. Either return 200 with the error fragment, or add
422toresponseHandlingvia a meta config:
<meta name="htmx-config"
content='{"responseHandling":[
{"code":"204","swap":false},
{"code":"[23]..","swap":true},
{"code":"422","swap":true},
{"code":"[45]..","swap":false,"error":true}]}'>
The response-targets extension is the other fix: it swaps error responses into a per-element target.
Gotcha: 3xx and htmx response headers do not mix. The browser follows a 302 internally and hands htmx the redirected response; your
HX-*headers on the 302 never get processed. To redirect from an htmx request, return 200 withHX-Redirect: /url(orHX-Locationto navigate without reloading). A side benefit: successful POSTs need no Post/Redirect/Get dance - return the new fragment directly.
Out-of-band swaps
To update a second element alongside the main target, mark part of the response with hx-swap-oob="true". htmx replaces the element with the same id instead of swapping it into the target:
<!-- main target content -->
<li>New story</li>
<!-- replaces #unread-badge wherever it lives -->
<span id="unread-badge" hx-swap-oob="true">3</span>
Because the OOB content must be a valid standalone element, table rows need a wrapper:
<template>
<tr id="story-42" hx-swap-oob="true">
<td>Updated row</td>
</tr>
</template>
hx-partial envelopes
<hx-partial> is a server-sent swap command, implemented in 2.x (src/htmx.js:2004). The server wraps content and instructs htmx where it goes; htmx swaps it and discards the envelope:
<li>New story</li>
<hx-partial id="unread-badge">
3
</hx-partial>
Differences from OOB worth knowing:
- The envelope is always consumed; nothing from the tag enters the DOM.
- Targets resolve relative to the triggering element, with the full extended selector vocabulary (
closest,next, …), so responses stay reusable across pages. - Partials execute before the main swap; a response containing only partials skips the main swap entirely.
hx-swapon the envelope accepts all swap styles; anidwith nohx-targetmeans#<id>.
Rule of thumb: OOB for “replace this id everywhere”, hx-partial for relational targets next to the trigger.
History and boosting
hx-push-url="true" stores a DOM snapshot in localStorage and pushes the request URL. On back, htmx restores the snapshot; on a cache miss it requests the URL with HX-History-Restore-Request: true and expects a full page back. So: any URL you push must serve a complete page when requested directly. Mark sensitive pages hx-history="false" to keep them out of localStorage.
hx-boost="true" on a container converts plain anchors and forms to AJAX swaps targeting the body - progressive enhancement for existing server-rendered pages, with the same HX-Request branching on the server.
CSS transitions without JS
The swap-plus-settle model runs CSS transitions with zero scripting. Keep element ids stable across responses, then define a transition from the old state:
tr {
transition: opacity 1s ease-out;
}
tr.htmx-swapping td {
opacity: 0;
}
<tbody hx-target="closest tr" hx-swap="outerHTML swap:1s">
htmx adds htmx-swapping during the swap delay (1s here, enough to fade out), then swaps, then settles for 20 ms copying old attributes onto the new element (htmx-settling), which is the window your entrance transitions use.
Security
htmx makes HTML more expressive, which cuts both ways: injected HTML becomes more dangerous too. The htmx docs’ security section is short and worth internalizing:
Rule 1: escape all untrusted content. Same as it ever was. The htmx-specific twist: if you inject raw HTML (a raw() escape hatch, user-authored markdown rendering, third-party content), scrub attributes starting with hx- and data-hx along with <script> tags. Whitelist the tags and attributes you allow rather than blacklisting the ones you do not.
Defense in depth, all documented in htmx itself:
| Tool | Effect |
|---|---|
hx-disable | wrapper attribute; htmx processes no attributes inside it or its descendants, and injected content cannot re-enable itself (the check walks the whole ancestor chain) |
hx-history="false" | keeps a page’s DOM out of the localStorage history cache (sensitive pages) |
htmx.config.selfRequestsOnly | only same-origin requests |
htmx.config.allowScriptTags | set false to ignore <script> tags in swapped content |
htmx.config.historyCacheSize | set 0 to disable the history cache entirely |
htmx.config.allowEval | set false to kill every eval-based feature: event filters, hx-on: attributes, js:-prefixed hx-vals/hx-headers (all reimplementable with your own JS + the event model) |
htmx:validateUrl event | inspect evt.detail.url / evt.detail.sameHost and preventDefault() to enforce a domain allowlist |
CSRF tokens ride along via hx-headers on <html> or <body>:
<body hx-headers='{"X-CSRF-TOKEN": "token-here"}'>
Gotcha:
hx-boostdoes not update<html>/<body>, so a token declared there will not reach boosted requests. Put it on an element that gets replaced, or prefer the standard hidden-input-in-form approach, which keeps working without htmx.
For content policies, htmx pairs with CSP rather than replacing it: default-src 'self' in a CSP meta tag is layered with (not redundant to) htmx.config.selfRequestsOnly.
Caching and CORS
Two operational notes:
- Caching: if a URL returns a full page to browsers and a fragment to htmx, set
Vary: HX-Requestso the cache keys on both. Otherwise a browser can serve a cached fragment as the full page. - CORS: htmx response headers must be exposed for cross-origin JS to read them - add
HX-*names toAccess-Control-Expose-Headers; request headers go inAccess-Control-Allow-Headers.
Where to next: First Page puts the contract into a runnable Go app, and Common Patterns applies it per UI pattern.