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: true by default (config historyRestoreAsHxRequest, src/htmx.js:281). If you branch on that header, a back-button restore can receive a fragment where it needs a full page. Set htmx.config.historyRestoreAsHxRequest to false, or check for the HX-History-Restore-Request header explicitly.

Request headers

HeaderMeaning
HX-Requesttrue on htmx requests (except history restores when historyRestoreAsHxRequest is disabled)
HX-Boostedrequest came from an hx-boost element
HX-Current-URLthe browser’s current URL
HX-Targetid of the target element, if it has one
HX-Triggerid of the triggering element, if it has one
HX-Trigger-Namename of the triggering element, if it has one
HX-Promptthe user’s answer to an hx-prompt
HX-History-Restore-Requesttrue when restoring a page the local history cache missed

Response headers

HeaderEffect
HX-Triggerfire client-side events after the swap; JSON map of event names
HX-Trigger-After-Swapsame, but after the swap step
HX-Trigger-After-Settlesame, but after the settle step
HX-Redirectfull-page client-side redirect
HX-Locationclient-side navigation without a full reload
HX-Refreshtrue forces a full page refresh
HX-Push-Url / HX-Replace-Urlpush or replace the history entry
HX-Reswapoverride the swap style for this response
HX-RetargetCSS selector overriding the target
HX-ReselectCSS 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:responseError fires.

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 422 to responseHandling via 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 with HX-Redirect: /url (or HX-Location to 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-swap on the envelope accepts all swap styles; an id with no hx-target means #<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:

ToolEffect
hx-disablewrapper 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.selfRequestsOnlyonly same-origin requests
htmx.config.allowScriptTagsset false to ignore <script> tags in swapped content
htmx.config.historyCacheSizeset 0 to disable the history cache entirely
htmx.config.allowEvalset 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 eventinspect 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-boost does 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-Request so 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 to Access-Control-Expose-Headers; request headers go in Access-Control-Allow-Headers.

Where to next: First Page puts the contract into a runnable Go app, and Common Patterns applies it per UI pattern.