htmx 4.0 is under construction — migration guide

Full Documentation

From installation to advanced usage.

Get Started

Installation

htmx is a single JavaScript file with no dependencies. No build step is required to use it.

CDN

Add this in your <head> tag:

<script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta3" integrity="sha384-bq4nTap5u8w4XlVP8JHkDioQVZBI5wUx5PxNwlbCq27H5QJ+q0CSeJcTYU+PLdCp" crossorigin="anonymous"></script>
Unminified
<script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta3/dist/htmx.js" integrity="sha384-Vnl21sHZ3FMn5TK6RULLbuXxCg4dsI8fUC5hZBVIj4uZWI7+pGZg5svM+Dt17DAV" crossorigin="anonymous"></script>
ES Module
<script type="module" src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta3/dist/htmx.esm.min.js" integrity="sha384-vyZja/GJMJ0ApzUoUvZ4zRkSIFTxnnQ7tBFQZRH4wKV8lVQk1l1hpzNMQK62sOeb" crossorigin="anonymous"></script>
ES Module (unminified)
<script type="module" src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta3/dist/htmx.esm.js" integrity="sha384-X/TaewiqShi/ztjAUmWNAFs+E7vl5V1CBtA+nTap/2HzeMxuI2e68oX63ebr5E6X" crossorigin="anonymous"></script>

Download

Instead of using a CDN, consider self-hosting in production.

  1. Download htmx.min.js
  2. Save it to your project (e.g., /js/htmx.min.js)
  3. Add this in your <head> tag:
<script src="/js/htmx.min.js"></script>
Other formats

Download: htmx.js (unminified)

Download: htmx.esm.min.js (ES module)

Download: htmx.esm.js (ES module, unminified)

npm

npm install htmx.org@4.0.0-beta3
import 'htmx.org';
Named import
import htmx from 'htmx.org'; // Now you can use htmx.ajax(), htmx.find(), etc.

htmax

The htmax.js file bundles htmx with the most popular extensions in a single file:

The extensions are automatically available, you can just use their attributes directly (e.g. hx-sse:connect, hx-ws:connect).

<script src="/js/htmax.min.js"></script>

Migration

Quick Start

There are two major behavioral changes between htmx 2.x and 4.x:

  • In htmx 2.0 attribute inheritance is implicit by default while in 4.0 it is explicit by default
  • In htmx 2.0, 400 and 500 response codes are not swapped by default, whereas in htmx 4.0 these requests will be swapped

Add these two config lines to restore htmx 2.x behavior:

<script> htmx.config.implicitInheritance = true; htmx.config.noSwap = [204, 304, '4xx', '5xx']; </script> <script src="https://cdn.jsdelivr.net/npm/htmx.org@next/dist/htmx.min.js"></script>

implicitInheritance restores htmx 2’s implicit attribute inheritance. noSwap prevents swapping error responses.

Or load the htmx-2-compat extension, which restores implicit inheritance, old event names, and previous error-swapping defaults:

<script src="/path/to/htmx.js"></script> <script src="/path/to/ext/htmx-2-compat.js"></script>

Most htmx 2 apps should work with either approach. Then migrate incrementally using this guide.

Upgrade Checker

htmx 4 ships with a command-line tool scans your templates and JS files for htmx 2 code that needs updating. It checks for removed attributes, old event names, inheritance patterns, extension changes, etc.

npx htmx.org@next upgrade-check -- ./path/to/project/root npx htmx.org@next upgrade-check --ext .vue ./path/to/project/root

By default, the tool scans .html, .php, .js, .ts, .jinja, .jinja2, .j2, .erb, and .hbs files.

Output is file:line format, clickable in most editors. You can add additional file types with the --ext option.

The tool requires Python 3.

What Changed

fetch() replaces XMLHttpRequest

All requests use the native fetch() API. This cannot be reverted.

Explicit inheritance

Add :inherited to any attribute that should inherit down the DOM tree.

<!-- htmx 2: implicit inheritance --> <div hx-confirm="Are you sure?"> <button hx-delete="/item/1">Delete</button> </div> <!-- htmx 4: explicit inheritance --> <div hx-confirm:inherited="Are you sure?"> <button hx-delete="/item/1">Delete</button> </div>

Works on any attribute: hx-boost:inherited, hx-target:inherited, hx-confirm:inherited, etc.

Use :append to add to an inherited value instead of replacing it:

<div hx-include:inherited="#global-fields"> <!-- appends .extra to the inherited value --> <form hx-include:inherited:append=".extra">...</form> </div>

Revert: htmx.config.implicitInheritance = true

Error responses swap

htmx 4 swaps all HTTP responses. Only 204 and 304 do not swap.

htmx 2 did not swap 4xx and 5xx responses. In htmx 4, if your server returns HTML with a 422 or 500, that HTML gets swapped into the target. Design your error responses to work as swap content, or use hx-status to control per-code behavior.

Revert: htmx.config.noSwap = [204, 304, '4xx', '5xx']

hx-delete excludes form data

Like hx-get, hx-delete no longer includes the enclosing form’s inputs.

Fix: add hx-include="closest form" where needed.

No history cache

History no longer caches pages in localStorage. When navigating back, htmx re-fetches the page and swaps it into <body>, or into the [hx-history-elt] element if one is present — the same behavior as htmx 2.

Use htmx.config.history = "reload" for a full page reload instead. Use htmx.config.history = false to disable.

OOB swap order

In htmx 2, out-of-band (hx-swap-oob) elements swapped before the main content.

In htmx 4, the main content swaps first. OOB and <hx-partial> elements swap after (in document order).

This matters if an OOB swap creates or modifies DOM that the main swap depends on. If your app relies on that ordering, restructure so each swap is independent.

60-second timeout

htmx 2 had no timeout (0). htmx 4 sets defaultTimeout to 60000.

Revert: htmx.config.defaultTimeout = 0

Extension loading

Include extension scripts directly. No attribute needed:

<script src="/path/to/htmx.js"></script> <script src="/path/to/ext/sse.js"></script>

Restrict which extensions can load:

<meta name="htmx-config" content='{"extensions": "sse, ws"}'>

Extension authors use htmx.registerExtension(name, methodMap) to register.

See Extensions documentation for details.

Renames and Removals

Rename hx-disable

Do this before upgrading. The name hx-disable has been reassigned:

  • In htmx 2, hx-disable meant “skip htmx processing on this element”
  • In htmx 4, that role is hx-ignore
  • The name hx-disable now does what hx-disabled-elt used to do (disable form elements during requests)

Rename in this order to avoid conflicts:

  1. Rename hx-disable to hx-ignore
  2. Rename hx-disabled-elt to hx-disable
Removed attributes
RemovedUse instead
hx-varshx-vals with js: prefix
hx-paramshtmx:config:request event
hx-prompthx-confirm with js: prefix
hx-extInclude extension script directly
hx-disinheritNot needed (inheritance is explicit)
hx-inheritNot needed (inheritance is explicit)
hx-requesthx-config
hx-historyRemoved (no localStorage)
Renamed events

All events follow a new pattern: htmx:phase:action[:sub-action]

Most error events are consolidated to htmx:error. HTTP error responses have a dedicated htmx:response:error event.

htmx 2.xhtmx 4.x
htmx:afterOnLoadhtmx:after:init
htmx:afterProcessNodehtmx:after:init
htmx:afterRequesthtmx:after:request
htmx:afterSettlehtmx:after:swap
htmx:afterSwaphtmx:after:swap
htmx:beforeCleanupElementhtmx:before:cleanup
htmx:beforeHistorySavehtmx:before:history:update
htmx:beforeOnLoadhtmx:before:init
htmx:beforeProcessNodehtmx:before:process
htmx:beforeRequesthtmx:before:request
htmx:beforeSwaphtmx:before:swap
htmx:configRequesthtmx:config:request
htmx:historyCacheMisshtmx:before:history:restore
htmx:historyRestorehtmx:before:history:restore
htmx:loadhtmx:after:init
htmx:oobAfterSwaphtmx:after:swap
htmx:oobBeforeSwaphtmx:before:swap
htmx:pushedIntoHistoryhtmx:after:history:push
htmx:replacedInHistoryhtmx:after:history:replace
htmx:responseErrorhtmx:response:error
htmx:sendErrorhtmx:error
htmx:swapErrorhtmx:error
htmx:targetErrorhtmx:error
htmx:timeouthtmx:error
Removed events

Validation events are removed. Use native browser form validation:

  • htmx:validation:validate
  • htmx:validation:failed
  • htmx:validation:halted

XHR events are removed (htmx uses fetch() now):

RemovedUse instead
htmx:xhr:loadstartNo replacement
htmx:xhr:loadendhtmx:finally:request
htmx:xhr:progressNo replacement
htmx:xhr:aborthtmx:error
Config changes

Renamed:

htmx 2.xhtmx 4.x
defaultSwapStyledefaultSwap
globalViewTransitionstransitions
historyEnabledhistory
includeIndicatorStylesincludeIndicatorCSS
timeoutdefaultTimeout

Changed defaults:

Confightmx 2htmx 4
defaultTimeout0 (no timeout)60000 (60 seconds)
defaultSettleDelay201

Removed:

addedClass, allowEval, allowNestedOobSwaps, allowScriptTags, attributesToSettle, defaultSwapDelay, disableSelector (use hx-ignore), getCacheBusterParam, historyCacheSize, ignoreTitle (still works per-swap via hx-swap="... ignoreTitle:true"), inlineStyleNonce (removed — indicator CSS now uses Constructable Stylesheets and does not require a nonce), methodsThatUseUrlParams, refreshOnHistoryMiss, responseHandling (use hx-status and noSwap), scrollBehavior, scrollIntoViewOnBoost, selfRequestsOnly (use htmx.config.mode), settlingClass, swappingClass, triggerSpecsCache, useTemplateFragments, withCredentials (use hx-config), wsBinaryType, wsReconnectDelay

The htmx-swapping, htmx-settling, and htmx-added CSS classes are still applied during swaps. The config keys to customize their names have been removed.

Request headers
htmx 2.xhtmx 4.xNotes
HX-TriggerHX-SourceFormat changed to tagName#id (e.g. button#submit)
HX-TargetHX-TargetFormat changed to tagName#id
HX-Trigger-NameremovedUse HX-Source
HX-PromptremovedUse hx-confirm with js: prefix
(new)HX-Request-Type"full" or "partial"
(new)AcceptNow explicitly text/html
Response headers

Removed:

  • HX-Trigger-After-Swap
  • HX-Trigger-After-Settle

Use HX-Trigger or JavaScript instead.

Unchanged: HX-Trigger, HX-Location, HX-Push-Url, HX-Redirect, HX-Refresh, HX-Replace-Url, HX-Retarget, HX-Reswap, HX-Reselect.

JavaScript API changes

Removed methods. Use native JavaScript:

htmx 2.xUse instead
htmx.addClass()element.classList.add()
htmx.removeClass()element.classList.remove()
htmx.toggleClass()element.classList.toggle()
htmx.closest()element.closest()
htmx.remove()element.remove()
htmx.off()removeEventListener() (htmx.on() returns the callback)
htmx.location()htmx.ajax()

Renamed: htmx.defineExtension() is now htmx.registerExtension().

Still available: htmx.ajax(), htmx.config, htmx.find(), htmx.findAll(), htmx.on(), htmx.onLoad(), htmx.parseInterval(), htmx.process(), htmx.swap(), htmx.trigger().

Removed: htmx.logAll(), htmx.logNone(), and the pluggable htmx.logger. htmx now logs directly via console.error / console.warn / console.log. Set htmx.config.logAll = true to surface event-level output. Observability tools (Sentry, DataDog RUM, LogRocket, etc.) capture console.* automatically.

Note: htmx.onLoad() now listens on htmx:after:process, not htmx:after:init.

What’s New

Attributes
AttributePurpose
hx-actionSpecify URL (use with hx-method)
hx-methodSpecify HTTP method
hx-configPer-element request config (JSON or key:value syntax)
hx-ignoreDisable htmx processing (was hx-disable)
hx-validateControl form validation behavior
hx-swap scroll modifiers

The show and scroll modifiers no longer support the combined selector:position syntax. Use separate keys instead:

<!-- htmx 2 (broken in 4) --> <div hx-swap="innerHTML show:#other:top"></div> <!-- htmx 4 --> <div hx-swap="innerHTML show:top showTarget:#other"></div> <div hx-swap="innerHTML scroll:bottom scrollTarget:#other"></div>
hx-swap styles
<div hx-get="/data" hx-swap="innerMorph">...</div> <div hx-get="/data" hx-swap="outerMorph">...</div> <div hx-get="/text" hx-swap="textContent">...</div> <div hx-get="/remove" hx-swap="delete">...</div>
  • innerMorph / outerMorph: morph swaps using the idiomorph algorithm. Better for preserving state in complex UIs.
  • textContent: set the target’s text content (no HTML parsing).
  • delete: remove the target element entirely.

New aliases for existing swap styles (both old and new names work):

NewEquivalent to
beforebeforebegin
afterafterend
prependafterbegin
appendbeforeend
Status code swaps

Set different swap behavior per HTTP status code:

<form hx-post="/save" hx-status:422="swap:innerHTML target:#errors select:#validation-errors" hx-status:5xx="swap:none push:false"> <!-- form fields --> </form>

Available config keys: swap:, target:, select:, push:, replace:, transition:.

Supports exact codes (404), single-digit wildcards (50x), and range wildcards (5xx). Evaluated in order of specificity.

<hx-partial>

Target multiple elements from one response. An alternative to hx-swap-oob for when you need explicit control over targeting and swap strategy:

<hx-partial hx-target="#messages" hx-swap="beforeend"> <div>New message</div> </hx-partial> <hx-partial hx-target="#count"> <span>5</span> </hx-partial>

Each <hx-partial> specifies its own hx-target and hx-swap strategy. See Multi-Target Updates for full documentation.

View transitions

View Transitions API support is available but disabled by default.

Enable: htmx.config.transitions = true

JSX compatibility

Frameworks that don’t support : in attribute names can use metaCharacter to replace it:

htmx.config.metaCharacter = "-"; // hx-ws-connect instead of hx-ws:connect // hx-confirm-inherited instead of hx-confirm:inherited
JavaScript methods
  • htmx.timeout(time): returns a promise that resolves after a delay (number ms, or interval string '500ms'/'1s'/'5m')

htmx.takeClass is removed from core. Equivalent functionality is exposed by the hx-live extension on the htmx.live namespace:

htmx.live.take(target, className, source) // strip class from `source`, add to `target` htmx.live.forEvent(...args) // race events/timeouts htmx.live.nextFrame() // promise that resolves on next animation frame htmx.live.q(selector) // jQuery-like proxy rooted at documentElement htmx.live.debounce(ms[, fn]) // global debounce htmx.live.refresh() // recompute every live expression

Inside hx-live/hx-on expression scope these are available unprefixed (take, forEvent, nextFrame, q, debounce, toggle) with the current element used as the implicit context — see the hx-live extension docs.

Auto-logged events

Internally-dispatched events route to the console as follows:

  • If detail.error is set on the event, output goes to console.error (the Error instance is inlined first when applicable, so DevTools renders the stack). This covers request failures, hx-on handler exceptions, and other thrown paths. Apps that listen for htmx:error get the same data via the event.
  • If detail.warn is set, output goes to console.warn.
  • Otherwise, the event is logged at console.log (silent by default; set htmx.config.logAll = true to surface).

This restores the htmx 2.x convention: if you want an internal failure path to show up in the console, fire an event with detail.error (or detail.warn); no per-site console.error needed.

Request context

All events provide a consistent ctx object with request/response information.

Events
EventFires
htmx:after:cleanupAfter element cleanup
htmx:after:history:updateAfter history update
htmx:after:processAfter element processing
htmx:before:responseBefore response body is read (cancellable)
htmx:before:settleBefore settle phase
htmx:after:settleAfter settle phase
htmx:before:viewTransitionBefore a view transition starts (cancellable)
htmx:after:viewTransitionAfter a view transition completes
htmx:finally:requestAlways fires after a request (success or failure)
Config keys
ConfigDefaultPurpose
extensions''Comma-separated list of allowed extension names
mode'same-origin'Fetch mode (replaces selfRequestsOnly)
inlineScriptNonce''Nonce for inline scripts
metaCharacter':'Separator character in attribute/event names
morphIgnore''CSS selector for elements to ignore during morph
morphScanLimitMax elements to scan during morph matching
morphSkip''CSS selector for elements to skip during morph
morphSkipChildren''CSS selector for elements whose children to skip during morph
Core extensions

htmx 4 ships with 9 core extensions. The SSE and WebSocket extensions have been significantly rewritten. See their upgrade guides for details.

ExtensionDescription
alpine-compatAlpine.js compatibility: initializes Alpine on fragments before swap
browser-indicatorShows the browser’s native loading indicator during requests
head-supportMerges head tag information (styles, etc.) in htmx requests
htmx-2-compatRestores implicit inheritance, old event names, and previous error-swapping defaults
optimisticShows expected content from a template before the server responds
preloadTriggers requests early (on mouseover/mousedown) for near-instant page loads (upgrade guide)
sseServer-Sent Events streaming support (upgrade guide)
upsertUpdates existing elements by ID and inserts new ones, preserving unmatched elements
wsBi-directional WebSocket communication (upgrade guide)

Checklist

  1. Optionally, add config options or load htmx-2-compat for backward compatibility
  2. Run the upgrade checker to get a full list of issues
  3. Rename hx-disable to hx-ignore, then hx-disabled-elt to hx-disable
  4. Replace removed attributes with alternatives
  5. Find/replace event names in JavaScript and hx-on attributes
  6. Replace removed API methods with native JS
  7. Update extensions
  8. Rename changed config keys
  9. Test error handling (4xx/5xx now swap by default)
  10. Test attribute inheritance
  11. Test history navigation

Migration Notes

Individual documentation pages include migration notes where features changed.

Look for these:

Changes in htmx 4.0

Get Help

Migrating Your Own Extensions

If you maintain a custom extension written for htmx 2.x, the extension API has changed substantially. The catalog of bundled extensions (/extensions) has already been ported. This section is for porting your own.

Quick Start

htmx 4 replaces the callback-based extension API with event-based hooks. Extensions register handlers for lifecycle events instead of implementing callback methods.

The simplest migration: rename defineExtension to registerExtension and map your callbacks to hooks.

// htmx 2.x htmx.defineExtension('my-ext', { onEvent: function(name, evt) { if (name === 'htmx:beforeRequest') { /* ... */ } } }); // htmx 4 htmx.registerExtension('my-ext', { htmx_before_request: (elt, detail) => { /* ... */ } });
What Changed
No hx-ext attribute

Extensions load by including the script. No attribute needed:

<script src="/path/to/htmx.js"></script> <script src="/path/to/ext/my-extension.js"></script>

Restrict which extensions can load:

<meta name="htmx-config" content='{"extensions": "sse, ws"}'>
Event hooks replace callbacks

Instead of a single onEvent callback that switches on event names, each event gets its own hook method. Hook names use underscores where events use colons:

htmx 2.x eventhtmx 4 hook
htmx:configRequesthtmx_config_request
htmx:beforeRequesthtmx_before_request
htmx:afterRequesthtmx_after_request
htmx:beforeSwaphtmx_before_swap
htmx:afterSwaphtmx_after_swap

All hooks receive (elt, detail). Return false to cancel.

handle_swap is special

Unlike other hooks, handle_swap is called directly with positional parameters (no htmx_ prefix, no detail object):

handle_swap: (swapStyle, target, fragment, swapSpec) => { if (swapStyle === 'my-swap') { target.appendChild(fragment); return true; } return false; }
Detail object replaces event properties

All hooks receive detail.ctx with full request/response context:

  • detail.ctx.request.body (FormData in htmx_config_request)
  • detail.ctx.request.headers
  • detail.ctx.response.status
  • detail.ctx.text (response body, modifiable in htmx_after_request)
  • detail.ctx.target
OOB swap stripping

OOB swaps automatically strip the wrapper element for non-outer swap styles. Name custom swap styles starting with “outer” (e.g., outerMorph) to preserve the wrapper.

Callback Migration Map
init
// htmx 2.x init: function(api) { return null; } // htmx 4 init: (internalAPI) => { api = internalAPI; }

Store the internalAPI reference for use in other hooks. No return value needed.

getSelectors

Removed. Use htmx_after_init to check for attributes:

// htmx 2.x getSelectors: function() { return ['[my-custom-attr]']; }, onEvent: function(name, evt) { if (name === 'htmx:afterProcessNode') { initializeCustomBehavior(evt.target); } } // htmx 4 htmx_after_init: (elt) => { if (api.attributeValue(elt, 'my-custom-attr')) { initializeCustomBehavior(elt); } }
onEvent

Replace with individual hooks:

// htmx 2.x onEvent: function(name, evt) { if (name === 'htmx:beforeSwap' && evt.detail.xhr.status !== 200) { var target = getRespCodeTarget(evt.detail.requestConfig.elt, evt.detail.xhr.status); if (target) { evt.detail.shouldSwap = true; evt.detail.target = target; } } } // htmx 4 htmx_before_swap: (elt, detail) => { if (detail.ctx.response.status !== 200) { var target = getRespCodeTarget(elt, detail.ctx.response.status); if (target) { detail.ctx.target = target; } } }
transformResponse

Removed. Modify detail.ctx.text in htmx_after_request:

// htmx 2.x transformResponse: function(text, xhr, elt) { var tpl = htmx.closest(elt, '[mustache-template]'); if (tpl) { var data = JSON.parse(text); var template = htmx.find('#' + tpl.getAttribute('mustache-template')); return Mustache.render(template.innerHTML, data); } return text; } // htmx 4 htmx_after_request: (elt, detail) => { var tpl = elt.closest('[mustache-template]'); if (tpl) { var data = JSON.parse(detail.ctx.text); var template = document.querySelector('#' + tpl.getAttribute('mustache-template')); detail.ctx.text = Mustache.render(template.innerHTML, data); } }

Event flow: response received, ctx.text set, htmx:after:request fires, ctx.text consumed into fragment, htmx:before:swap.

encodeParameters

Removed. Modify detail.ctx.request.body in htmx_config_request:

// htmx 2.x onEvent: function(name, evt) { if (name === 'htmx:configRequest') { evt.detail.headers['Content-Type'] = 'application/json'; } }, encodeParameters: function(xhr, parameters, elt) { var object = {}; parameters.forEach(function(value, key) { if (Object.hasOwn(object, key)) { if (!Array.isArray(object[key])) object[key] = [object[key]]; object[key].push(value); } else { object[key] = value; } }); return JSON.stringify(object); } // htmx 4 htmx_config_request: (elt, detail) => { detail.ctx.request.headers['Content-Type'] = 'application/json'; var object = {}; detail.ctx.request.body.forEach(function(value, key) { if (Object.hasOwn(object, key)) { if (!Array.isArray(object[key])) object[key] = [object[key]]; object[key].push(value); } else { object[key] = value; } }); detail.ctx.request.body = JSON.stringify(object); }

ctx.request.body is FormData in htmx_config_request. It can be replaced with any value (string, JSON, URLSearchParams). For GET/DELETE, body becomes query parameters. For POST/PUT/PATCH, body becomes URLSearchParams (unless multipart).

isInlineSwap and handleSwap

Both replaced by handle_swap:

// htmx 2.x isInlineSwap: function(swapStyle) { return swapStyle === 'morphdom'; }, handleSwap: function(swapStyle, target, fragment) { if (swapStyle === 'morphdom') { morphdom(target, fragment.firstElementChild || fragment.firstChild); return [target]; } } // htmx 4 handle_swap: (swapStyle, target, fragment) => { if (swapStyle === 'morphdom') { morphdom(target, fragment.firstElementChild || fragment.firstChild); return true; } return false; }

Return truthy if handled, falsy otherwise. Can return an array of elements for settle tracking.

Removed Callbacks
htmx 2.x callbackhtmx 4 replacement
getSelectors()htmx_after_init hook
onEvent(name, evt)Individual htmx_* hooks
transformResponse(text, xhr, elt)htmx_after_request hook (modify detail.ctx.text)
encodeParameters(xhr, params, elt)htmx_config_request hook (modify detail.ctx.request.body)
isInlineSwap(swapStyle)handle_swap or name swap style with “outer” prefix
handleSwap(style, target, frag, info)handle_swap(style, target, frag, spec)
Checklist
  1. Rename defineExtension to registerExtension
  2. Replace onEvent with individual htmx_* hooks
  3. Replace transformResponse with htmx_after_request
  4. Replace encodeParameters with htmx_config_request
  5. Merge isInlineSwap and handleSwap into handle_swap
  6. Replace getSelectors with htmx_after_init
  7. Remove hx-ext attributes from HTML
  8. Update event names (colons to underscores in hook names)
  9. Test custom swap styles with OOB swaps

Core Concepts

Mental Model

htmx extends HTML’s built-in concept of hypermedia controls.

To understand htmx, you should first understand what these are.

HTML’s Native Controls

HTML has two major elements that issue HTTP requests in response to user actions: <a> (anchors, aka “links”) and <form>.

The Anchor Tag
<a href="/blog">Blog</a>

When a user click this link a browser will issue an HTTP GET request to /blog. It will then load the HTML response into the browser’s window.

The Form Tag
<form method="post" action="/register"> <label>Email: <input type="email"></label> <button type="submit">Submit</button> </form>

When a user submits this form (by, say, clicking on the “Submit” button) a browser will issue an HTTP POST request to /register. Again, it will load the HTML response to this request into the browser window.

These two hypermedia controls demonstrate the core idea behind them: in response to a user action a request is made and new content is loaded into the client.

Transclusion

Both of these HTML elements support a (relatively little-known and unused) target attribute.

By using this attribute you can place the response in, for example, an iframe rather than replacing the entire content in the window:

<form method="post" action="/register" target="iframe1"> <label>Email: <input type="email"></label> <button type="submit">Submit</button> </form> <iframe name="iframe1"> <!-- Response appears here --> </iframe>

This is transclusion, where one HTML document is included inside of another.

How htmx Extends This Idea

htmx generalizes these concepts. Any element can issue any type of HTTP request to any URL. Any event can trigger the request. And the response HTML can be placed anywhere in the DOM.

<button hx-post="/clicked" hx-trigger="click" hx-target="#output" hx-swap="outerHTML"> Click Me </button> <output id="output"></output>

These htmx attributes (which start with hx-) tell a browser:

When a user clicks this button, issue a POST request to /clicked. Use the response to replace the element with id output.

Like anchor and form tags, htmx expects HTML responses from the server.

This is in contrast with many front-end libraries and frameworks today which instead expect JSON and use client-side templating to transform that JSON into HTML on the client.

htmx’s Philosophy

Because htmx works in terms of HTML it follows the original web programming model. It uses Hypertext As The Engine Of Application State (HATEOAS).

The server controls what the user sees by sending HTML. Users select actions from that HTML and the server responds with more HTML (i.e. hypertext).

Thus, the hypermedia itself drives the application.

Hypermedia Controls

htmx extends HTML with attributes that control how requests are made and how responses update the page.

Making Requests

Add hx-get to an element. It makes an AJAX request when clicked.

Your HTML:

<button hx-get="/messages"> Load Messages </button>

What the server returns:

<div>You have 3 new messages</div>

What the user sees:

<button hx-get="/messages"> <div>You have 3 new messages</div> </button>

The response replaced the button’s content. No JavaScript required.

How It Works
StepWhat Happens
1. User clicks buttonhtmx intercepts the click
2. htmx makes requestSends GET request to /messages
3. Server respondsReturns HTML (not JSON)
4. htmx updates pageSwaps HTML into the button
HTTP Methods

Use different attributes for different operations:

<button hx-get="/users">Load Users</button> <button hx-post="/users">Create User</button> <button hx-put="/users/1">Update User</button> <button hx-patch="/users/1">Patch User</button> <button hx-delete="/users/1">Delete User</button>

Each attribute combines the URL and HTTP method.

Common Patterns

Load data on click:

<button hx-get="/profile">View Profile</button>

Submit a form:

<form hx-post="/contact"> <input name="email" type="email"> <button type="submit">Send</button> </form>

Form submits via AJAX instead of full page reload.

Delete an item:

<button hx-delete="/items/5">Delete Item</button>
What Gets Sent

htmx sends standard HTTP requests:

Request to server:

GET /messages HTTP/1.1 HX-Request: true HX-Target: button

htmx adds custom headers so your server knows it’s an htmx request.

Server response:

HTTP/1.1 200 OK Content-Type: text/html <div>You have 3 new messages</div>

Just HTML. No JSON parsing needed.

Triggers

By default, requests are triggered by the “natural” event of an element:

  • input, textarea & select are triggered on the change event
  • form is triggered on the submit event
  • everything else is triggered by the click event

If you want different behavior you can use the hx-trigger attribute to specify which event will cause the request.

Here is a div that posts to /mouse_entered when a mouse enters it:

<div hx-post="/mouse_entered" hx-trigger="mouseenter"> Mouse Trap </div>
Trigger Modifiers

A trigger can also have additional modifiers that change its behavior. For example, if you want a request to only happen once, you can use the once modifier for the trigger:

<div hx-post="/mouse_entered" hx-trigger="mouseenter once"> Mouse Trap </div>

Other modifiers you can use for triggers are (parsed as HCON):

  • changed - only issue a request if the value of the element has changed
  • delay:<time interval> - wait the given amount of time (e.g. 1s) before issuing the request. If the event triggers again, the countdown is reset.
  • throttle:<time interval> - wait the given amount of time (e.g. 1s) before issuing the request. Unlike delay if a new event occurs before the time limit is hit the event will be discarded, so the request will trigger at the end of the time period.
  • from:<CSS Selector> - listen for the event on a different element. This can be used for things like keyboard shortcuts. Note that this CSS selector is not re-evaluated if the page changes.

Multiple triggers can be specified in the hx-trigger attribute, separated by commas.

You can use these features to implement many common UX patterns, such as Active Search:

<input type="text" name="q" placeholder="Search..." hx-get="/search" hx-trigger="input delay:500ms, keyup[key=='Enter']" hx-target="#search-results"> <div id="search-results"></div>

This input will issue a request 500 milliseconds after an input event occurs, or the enter key is pressed and inserts the results into the div with the id search-results.

Trigger Filters

In the example above, you may have noticed the square brackets after the event name. This is called a “trigger filter”.

Trigger filters allow you to place a filtering javascript expression after the event name that will prevent the trigger if the filter does not return true.

Here is an example that triggers only on a Shift-Click of the element

<div hx-get="/shift_clicked" hx-trigger="click[shiftKey]"> Shift Click Me </div>

Properties like shiftKey will be resolved against the triggering event first, then against the global scope.

The this symbol will be set to the current element.

Special Events

htmx provides a few special events for use in hx-trigger:

  • load - fires once when the element is first loaded
  • revealed - fires once when an element first scrolls into the viewport
  • intersect - fires once when an element first intersects the viewport. This supports two additional options:
    • root:<selector> - a CSS selector of the root element for intersection
    • threshold:<float> - a floating point number between 0.0 and 1.0, indicating what amount of intersection to fire the event on

You can also use custom events to trigger requests.

Polling

Polling is a simple technique where a web page periodically issues a request to the server to see if any updates have occurred. It is not very highly respected in many web development circles, but it is simple, can be relatively resource-light because it does not maintain a constant network connection, and it tolerates network failures well

In htmx you can implement polling via the every syntax in the hx-trigger attribute:

<div hx-get="/news" hx-trigger="every 2s"></div>

This tells htmx:

Every 2 seconds, issue a GET to /news and load the response into the div

Load Polling

Another technique that can be used to achieve polling in htmx is “load polling”, where an element specifies a load trigger along with a delay, and replaces itself with the response:

<div hx-get="/messages" hx-trigger="load delay:1s" hx-swap="outerHTML"> </div>

If the /messages end point keeps returning a div set up this way, it will keep “polling” back to the URL every second.

Load polling can be useful in situations where a poll has an end point at which point the polling terminates, such as when you are showing the user a progress bar.

Request Indicators

When an AJAX request is issued it is often good to let the user know that something is happening since the browser will not give them any feedback. You can accomplish this in htmx by using htmx-indicator class.

The htmx-indicator class is defined so that the opacity of any element with this class is 0 by default, making it invisible but present in the DOM.

When htmx issues a request, it will put a htmx-request class onto an element (either the requesting element or another element, if specified). The htmx-request class will cause a child element with the htmx-indicator class on it to transition to an opacity of 1, showing the indicator.

<button hx-get="/click"> Click Me! <img class="htmx-indicator" src="/spinner.gif" alt="Loading..."> </button>

Here we have a button. When it is clicked the htmx-request class will be added to it, which will reveal the spinner gif element.

The htmx-indicator class uses opacity to hide and show the progress indicator but if you would prefer another mechanism you can create your own CSS transition like so:

.htmx-indicator { display: none; } .htmx-request .htmx-indicator { display: inline; } .htmx-request.htmx-indicator { display: inline; }

If you want the htmx-request class added to a different element, you can use the hx-indicator attribute with a CSS selector to do so:

<div> <button hx-get="/click" hx-indicator="#indicator"> Click Me! </button> <img id="indicator" class="htmx-indicator" src="/spinner.gif" alt="Loading..."/> </div>

Here we call out the indicator explicitly by id.

Note that we could have placed the class on the parent div as well and had the same effect.

You can also add the disabled attribute to elements for the duration of a request by using the hx-disable attribute.

Targets

By default, responses replace the element that made the request.

Change this with hx-target.

<button hx-get="..." hx-target="#results"> Load Results </button> <div id="results"> <!-- Response goes here --> </div>

The button makes the request.

The response loads into #results.

The button stays unchanged.

Extended Selectors

Use extended selectors to target elements flexibly.

Beyond standard CSS selectors, you can use:

See the full extended selectors guide for all options and examples.

This keeps your HTML cleaner without requiring id attributes everywhere.

Swaps

htmx offers many different ways to swap the HTML returned into the DOM. By default, the content replaces the innerHTML of the target element, which is called an innerHTML swap.

This is similar to how the target attribute on links and forms works, placing the retrieved document within an iframe.

You can modify this by using the hx-swap attribute with any of the following values:

NameDescription
innerHTMLthe default, puts the content inside the target element
outerHTMLreplaces the entire target element with the returned content
beforebegin (or before)prepends the content before the target in the target’s parent element
afterbegin (or prepend)prepends the content before the first child inside the target
beforeend (or append)appends the content after the last child inside the target
afterend (or after)appends the content after the target in the target’s parent element
deletedeletes the target element regardless of the response
nonedoes not append content from response (Out of Band Swaps and Response Headers will still be processed)
innerMorphmorphs the children of the target element, preserving as much of the existing DOM as possible
outerMorphmorphs the target element itself, preserving as much of the existing DOM as possible
textContentSet the target’s text content (no HTML parsing)
Morph Swaps

htmx includes built-in innerMorph and outerMorph swaps that merge new content into the existing DOM rather than simply replacing it. They often do a better job preserving things like focus, video state, etc. by mutating existing nodes in-place during the swap operation, at the cost of more CPU.

Consider this HTML:

<div id="video-elt"> <h1>Title</h1> <iframe id="video" width="791" height="445" src="https://www.youtube.com/embed/dQw4w9WgXcQ"></iframe> </div> <button hx-get="/swap" hx-target="#video-elt" hx-swap="outerMorph"> Swap Header To Bottom </button>

If the response content for this looks like this:

<div id="video-elt"> <iframe id="video" width="791" height="445" src="https://www.youtube.com/embed/dQw4w9WgXcQ"></iframe> <h1>Title</h1> </div>

Then htmx will “morph” the existing content to the new structure. Note that the h1 element has moved below the video. With the outerHTML swap this will cause the video to stop playing and reset. However, the morphing algorithm uses ID elements to intelligently mutate the DOM and preserve the existing video element, keeping the video playing smoothly.

Note that a similar effect can be achieved with the hx-preserve attribute, discussed below.

Excluding Elements from Morphing

Exclude specific elements from morphing using config options:

Useful for third-party widgets, custom web components, or active animations.

View Transitions

The View Transitions API gives developers a way to create an animated transition between different DOM states.

htmx supports view transitions via:

  • Setting htmx.config.transitions to true globally
  • Per-swap via hx-swap transition property: hx-swap="outerHTML transition:true"
  • For boosted elements: hx-boost="transition:true"
Swap Options

The hx-swap attribute also supports options for tuning the swapping behavior of htmx. For example, by default htmx will swap in the title of a title tag found anywhere in the new content. You can turn this behavior off by setting the ignoreTitle modifier to true:

<button hx-post="/like" hx-swap="outerHTML ignoreTitle:true">Like</button>

The modifiers available on hx-swap are (parsed as HCON):

OptionDescription
swapA time interval (e.g., 100ms, 1s) to delay the swap operation
transitiontrue or false, whether to use the view transition API for this swap
ignoreTitleIf set to true, any title found in the new content will be ignored and not update the document title
striptrue or false, whether to strip the outer element when swapping (unwrap the content)
focus-scrolltrue or false, whether to scroll focused elements into view
scrolltop or bottom, will scroll the target element to its top or bottom
showtop or bottom, will scroll the target element’s top or bottom into view
targetA selector to retarget the swap to a different element

All swap modifiers appear after the swap style is specified, and are colon-separated.

See the hx-swap documentation for more details on these options.

Parameters

By default, an element that causes a request will include its value if it has one. If the element is a form it will include the values of all inputs within it.

As with HTML forms, the name attribute of the input is used as the parameter name in the request that htmx sends.

Additionally, if the element causes a non-GET request, the values of all the inputs of the associated form will be included (typically this is the nearest enclosing form, but could be different if e.g. <button form="associated-form"> is used).

If you wish to include the values of other elements, you can use the hx-include attribute with a CSS selector of all the elements whose values you want to include in the request.

Finally, if you want to programmatically modify the parameters, you can use the htmx:config:request event.

File Upload

If you wish to upload files via an htmx request, you can set the hx-encoding attribute to multipart/form-data. This will use a FormData object to submit the request, which will properly include the file in the request.

Note that depending on your server-side technology, you may have to handle requests with this type of body content very differently.

Requests & Responses

Htmx expects responses to the AJAX requests it makes to be HTML, typically HTML fragments (although a full HTML document, matched with a hx-select tag can be useful too).

Htmx will then swap the returned HTML into the document at the target specified and with the swap strategy specified.

Sometimes you might want to do nothing in the swap, but still perhaps trigger a client side event (see below).

For this situation, by default, you can return a 204 - No Content response code, and htmx will ignore the content of the response.

In the event of a connection error, the htmx:error event will be triggered.

Configuring Response Handling

By default, htmx will swap content for all HTTP responses except 204 and 304 status codes. This includes error responses (4xx, 5xx). You can customize this behavior using the hx-status attribute pattern (hx-status:XXX) or by configuring htmx.config.noSwap.

Status-Code Conditional Swapping

The hx-status:XXX attribute allows you to specify different swap behaviors based on the HTTP status code of the response. This gives you fine-grained control over how different response statuses are handled.

<button hx-get="/data" hx-status:404="none" hx-status:500="target:#error-container"> Load Data </button>
<form hx-post="/submit" hx-target="#result" hx-status:422="target:#validation-errors" hx-status:500="target:#server-error" hx-status:503="none"> <input name="email"> <button type="submit">Submit</button> </form> <div id="result"></div> <div id="validation-errors"></div> <div id="server-error"></div>

In this example:

  • Successful responses (2xx) swap into #result (default behavior)
  • 422 responses swap into #validation-errors
  • 500 responses swap into #server-error
  • 503 responses don’t swap at all
Request Headers

htmx includes headers in the requests it makes:

HeaderDescription
HX-Boostedindicates that the request is via an element using hx-boost
HX-Current-URLthe current URL of the browser
HX-Requestalways “true”
HX-Request-Type"partial" for targeted swaps, "full" for body-level or hx-select requests
HX-Sourcethe source element in tag#id format (e.g. button#submit)
HX-Targetthe target element in tag#id format (e.g. div#results)
Response Headers

htmx supports htmx-specific response headers:

HeaderDescription
HX-Locationallows you to do a client-side redirect that does not do a full page reload
HX-Push-Urlpushes a new url into the history stack
HX-Redirectcan be used to do a client-side redirect to a new location
HX-Refreshif set to “true” the client-side will do a full refresh of the page
HX-Replace-Urlreplaces the current URL in the location bar
HX-Reswapallows you to specify how the response will be swapped. See hx-swap for possible values
HX-Retargeta CSS selector that updates the target of the content update to a different element on the page
HX-Reselecta CSS selector that allows you to choose which part of the response is used to be swapped in. Overrides an existing hx-select on the triggering element
HX-Triggerallows you to trigger client-side events

For more on the HX-Trigger headers, see HX-Trigger Response Headers.

Submitting a form via htmx has the benefit of no longer needing the Post/Redirect/Get Pattern. After successfully processing a POST request on the server, you don’t need to return a HTTP 302 (Redirect). You can directly return the new HTML fragment.

Also, the response headers above are not provided to htmx for processing with 3xx Redirect response codes like HTTP 302 (Redirect). Instead, the browser will intercept the redirection internally and return the headers and response from the redirected URL. Where possible use alternative response codes like 200 to allow returning of these response headers.

Client-Side Scripting

Changes in htmx 4.0

htmx 4.0 changed event names significantly when compared with htmx 2.0, making them much more standardized.

See the full event mapping in the Changes in htmx 4.0 document.

Note: All events now provide a consistent ctx object with request/response information.

While htmx encourages a hypermedia approach to building web applications, it offers many options for client scripting. Scripting is included in the REST-ful description of web architecture, see: Code-On-Demand. As much as is feasible, we recommend a hypermedia-friendly approach to scripting in your web application:

The primary integration point between htmx and scripting solutions is the events that htmx sends and can respond to.

We have an entire chapter entitled “Client-Side Scripting” in our book that looks at how scripting can be integrated into your htmx-based application.

Events

Htmx has an extensive events mechanism, which doubles as the logging system.

If you want to register for a given htmx event you can use

document.body.addEventListener('htmx:after:init', function (evt) { myJavascriptLib.init(evt.detail.elt); });

or, if you would prefer, you can use the following htmx helper:

htmx.on("htmx:after:init", function (evt) { myJavascriptLib.init(evt.detail.elt); });

The htmx:after:process event is fired every time an element is processed by htmx, and is effectively the equivalent to the normal load event.

Initialize A 3rd Party Library With Events

Using the htmx:after:process event to initialize content is so common that htmx provides a helper function:

htmx.onLoad(function (target) { myJavascriptLib.init(target); });

This does the same thing as the first example, but is a little cleaner.

Configure a Request With Events

You can handle the htmx:config:request event in order to modify an AJAX request before it is issued:

document.body.addEventListener('htmx:config:request', function (evt) { evt.detail.ctx.request.parameters['auth_token'] = getAuthToken(); // add a new parameter into the request evt.detail.ctx.request.headers['Authentication-Token'] = getAuthToken(); // add a new header into the request });

Here we add a parameter and header to the request before it is sent.

The hx-on:* Attributes

HTML allows the embedding of inline scripts via the onevent properties, such as onClick:

<button onclick="alert('You clicked me!')"> Click Me! </button>

This feature allows scripting logic to be co-located with the HTML elements the logic applies to, giving good Locality of Behaviour (LoB).

Unfortunately, HTML only allows on* attributes for a fixed number of specific DOM events (e.g. onclick) and doesn’t provide a generalized mechanism for responding to arbitrary events on elements.

In order to address this shortcoming, htmx offers hx-on:* attributes.

These attributes allow you to respond to any event in a manner that preserves the LoB of the standard on* properties, and provide some nice quality of life improvements over the standard javascript API.

If you want to respond to the click event using an hx-on attribute, we would write this:

<button hx-on:click="alert('You clicked me!')"> Click Me! </button>

So, the string hx-on, followed by a colon (or a dash), then by the name of the event.

The Scripting API

htmx provides some top level helper methods in hx-on handlers that make async scripting more enjoyable:

functiondescription
find()allows you to find content relative to the current element (e.g. find('next div') will find the next div after the current element
findAll()allows you to find multiple elements relative to the current element
timeout()allows you to wait for a given amount of time (e.g. await timeout(100) before continuing
Scripting Examples

Here is an example that adds a parameter to an htmx request

Here the `example` parameter is added to the `POST` request before it is issued, with the value 'Hello Scripting!'. Another use case is to [reset user input](/patterns/forms/reset-on-submit) on successful requests using the [`htmx:after:swap`](/reference/events/htmx-after-swap) event: ```html <button hx-post="/example" hx-on:htmx:after:request="find('closest form').reset()"> Post Me! </button>

3rd Party Javascript

Htmx integrates well with third party libraries.

If the library fires events on the DOM, you can use those events to trigger requests from htmx.

A good example of this is the SortableJS demo:

<form class="sortable" hx-post="/items" hx-trigger="end"> <div class="htmx-indicator">Updating...</div> <div><input type='hidden' name='item' value='1'/>Item 1</div> <div><input type='hidden' name='item' value='2'/>Item 2</div> <div><input type='hidden' name='item' value='2'/>Item 3</div> </form>

With Sortable, as with most javascript libraries, you need to initialize content at some point.

In htmx, the cleanest way to do this is using the htmx.onLoad() method to register a callback.

This callback will be called whenever htmx inserts new content into the DOM, allowing you to initialize any widgets in the new content.

htmx.onLoad((content) => { var sortables = content.querySelectorAll(".sortable"); for (var i = 0; i < sortables.length; i++) { var sortable = sortables[i]; new Sortable(sortable, { animation: 150, ghostClass: 'blue-background-class' }); } })

This will ensure that as new content is added to the DOM by htmx, sortable elements are properly initialized.

Multi-Target Updates

The Problem

htmx requests normally update one target element. Sometimes you need to update multiple parts of the page at once.

For example: After submitting a form, you want to update both the form itself and a notification counter.

The Solution

htmx provides two ways to update multiple targets from a single response:

  1. Out-of-Band Swaps - Match elements by their id attribute
  2. Partial Tags - Explicitly specify where content goes

Choose the method that fits your needs.

Out-of-Band Swaps

Use out-of-band swaps when you want to match elements by their id.

Add hx-swap-oob="true" to any element in your response. htmx will find the element with the same id in your page and swap it.

Server response:

<div id="message" hx-swap-oob="true"> Form submitted successfully! </div> <form id="my-form"> <!-- Updated form content --> </form>

Result:

  • The div#message updates wherever it exists in your page
  • The form updates in its normal target location
Customize the Swap

Specify a different swap style:

<div id="notifications" hx-swap-oob="beforeend"> <span>New notification</span> </div>

This appends the content to div#notifications instead of replacing it.

Target a Different Element

Override the id matching by specifying a custom target:

<div hx-swap-oob="innerHTML:#status"> Processing... </div>

This swaps the content into the element matching #status, regardless of the element’s own id.

When to Use Out-of-Band Swaps

Use out-of-band swaps when:

  • Elements have consistent, unique id attributes
  • You want simple, ID-based updates
  • You’re updating notification areas, counters, or status indicators

Partials (<hx-partial>)

Use partials when you need explicit control over targeting.

Wrap content in <hx-partial> tags. Specify where it goes with hx-target.

Server response:

<hx-partial hx-target="#messages" hx-swap="beforeend"> <div class="message">New message content</div> </hx-partial> <hx-partial hx-target="#notifications"> <span class="badge">5</span> </hx-partial> <form id="my-form"> <!-- Main form content --> </form>

Result:

  • First partial’s content appends to #messages
  • Second partial’s content replaces contents of #notifications
  • Form updates in its normal target location
Attributes

Each <hx-partial> accepts:

  • hx-target - CSS selector for where to place content
  • id - Shorthand alternative to hx-target. Targets the element with that ID (e.g. <hx-partial id="messages"> targets #messages)
  • hx-swap - Optional. Swap style (defaults to innerHTML)

Either hx-target or id is required. If both are present, hx-target takes precedence.

Alternative syntax for template languages that strip unknown tags

You can use the equivalent <template> form: <template hx type="partial" hx-target="..." hx-swap="...">. htmx converts <hx-partial> to this form internally.

When to Use Partials

Use partials when:

  • Elements don’t have id attributes
  • You need to target by class or other selectors
  • You want explicit control over what goes where
  • You’re building more complex update patterns

Choosing Between Them

Both methods work together. Use them in the same response if needed.

Use out-of-band swaps for simple ID-based updates.

Use partial tags for everything else.

Additional Features

Select Specific Elements for OOB

Use hx-select-oob on the triggering element to extract specific elements from the response for out-of-band swapping:

<button hx-post="/submit" hx-target="#form" hx-select-oob="#message, #counter"> Submit </button>

This pulls #message and #counter from the response and swaps them out-of-band, even if they don’t have hx-swap-oob attributes.

Preserve Content During Swaps

Add hx-preserve="true" to elements you want to keep across swaps:

<video id="my-video" hx-preserve="true"> <source src="video.mp4"> </video>

This keeps the video playing even when the parent container gets updated.

HCON — htmx Configuration Object Notation

HCON (htmx Configuration Object Notation) is the mini config language that htmx uses to parse structured values out of HTML attributes. It is designed to feel natural in attribute values — no outer braces required, flexible quoting, flag-style booleans — while still accepting plain JSON when you need it.

You encounter HCON any time htmx reads a structured attribute:

  • hx-swap modifiers — innerHTML swap:200ms settle:100ms
  • hx-trigger modifiers — click delay:500ms throttle:1s
  • hx-configcredentials:"include" timeout:5000
  • hx-vals / hx-headerstoken:"abc" retry:3
  • The <meta name="htmx-config"> tag
  • HX-Location response header

Syntax

Key-value pairs

Pairs are separated by spaces or commas. Both are equivalent.

key:value key2:value2 key:value, key2:value2
Value types
InputParsed as
true / falseboolean
42, 500integer
"quoted string"string (double quotes)
'quoted string'string (single quotes)
bare-wordstring
(no value)true
<!-- booleans --> <button hx-get="/api" hx-config="validate"> <!-- validate: true --> <button hx-get="/api" hx-config="validate:false"> <!-- validate: false --> <!-- numbers --> <button hx-get="/api" hx-config="timeout:5000"> <!-- strings — quotes needed when value contains spaces or special chars --> <button hx-get="/api" hx-config='credentials:"include"'> <button hx-get="/api" hx-config="mode:'cors'">
Dot-notation for nested keys

Use . to set nested object properties:

<meta name="htmx-config" content="sse.reconnect:true sse.reconnectDelay:1000">

This produces { sse: { reconnect: true, reconnectDelay: 1000 } }.

JSON fallback

Any value starting with { is parsed as JSON instead of HCON. This lets you compose config server-side and inject it directly:

<!-- server renders this --> <meta name="htmx-config" content='{"defaultSwap":"outerHTML","transitions":true}'> <!-- or on an element --> <button hx-get="/api" hx-config='{"credentials":"include","timeout":5000}'>

JSON and HCON are not mixed — the entire string is one or the other.


Where HCON is used

hx-swap modifiers

The swap style comes first (not HCON), then modifiers are parsed as HCON:

<div hx-get="/update" hx-swap="innerHTML swap:200ms settle:100ms scroll:top"> <div hx-get="/update" hx-swap="outerHTML transition:true ignoreTitle:true">

The full JSON form is also accepted. When using JSON, omitting "style" falls back to config.defaultSwap as expected; including it overrides the style:

<!-- modifiers only, style = config.defaultSwap --> <div hx-swap='{"swap":"200ms","settle":"100ms"}'> <!-- explicit style --> <div hx-swap='{"style":"outerHTML","swap":"200ms"}'>

Available swap modifiers: swap, settle, scroll, show, scrollTarget, showTarget, transition, strip, ignoreTitle, focusScroll, target.

hx-trigger modifiers

The event name comes first, then modifiers:

<input hx-get="/search" hx-trigger="keyup delay:300ms"> <button hx-post="/save" hx-trigger="click throttle:1s"> <div hx-get="/poll" hx-trigger="every 2s"> <form hx-post="/submit" hx-trigger="submit once">

Available trigger modifiers: delay, throttle, from, target, consume, changed, once.

hx-config

Merges into the request context before the request is issued. Useful for per-element fetch options:

<button hx-get="/slow" hx-config="timeout:30000"> <button hx-get="https://api.example.com/data" hx-config='credentials:"include" mode:"cors"'>
<meta name="htmx-config">

Sets global htmx.config values. Accepts HCON or JSON:

<!-- HCON --> <meta name="htmx-config" content="defaultSwap:outerHTML transitions:true"> <!-- JSON --> <meta name="htmx-config" content='{"defaultSwap":"outerHTML","transitions":true}'> <!-- nested via dot notation --> <meta name="htmx-config" content="sse.reconnect:true sse.reconnectMaxAttempts:5">
HX-Location response header

The server can return HCON or JSON in this header:

HX-Location: /new-page HX-Location: path:"/new-page" push:"true" HX-Location: {"path":"/new-page","push":"true"}

Attribute inheritance and :append

HCON itself is a parsing format. The :append composition feature lives one level up, in htmx’s attribute inheritance system. When you use :append on an attribute name, htmx merges the child value with the inherited parent value by concatenating them (stripping {}):

<div hx-headers:inherited='{"X-Tenant": "acme"}'> <button hx-get="/api" hx-headers:append='{"X-Request-ID": "123"}'> <!-- sends both headers --> </button> </div>

This works for any attribute that accepts HCON/JSON, including hx-vals and hx-headers.


Limitations

  • No arrays — HCON has no array literal syntax. Use JSON ([...]) or the JSON fallback ({...}) when you need arrays.
  • No nesting beyond dot-notationa.b.c:value works; a:{b:{c:value}} does not (use JSON for that).
  • Integer values only — bare numbers are parsed as integers via parseInt. Floats must be quoted: threshold:"0.5".
  • No expressions — values are literals only. For dynamic values use js: prefix on the attribute (e.g. hx-vals="js:{token: getToken()}").
  • Prototype safety — keys __proto__, constructor, and prototype are silently ignored.
  • Mixed JSON+HCON — a string starting with { is always JSON; you cannot mix the two syntaxes in one attribute value.

Features

CSS Transitions

htmx makes it easy to use CSS Transitions without javascript. Consider this HTML content:

<div id="div1">Original Content</div>

Imagine this content is replaced by htmx via an ajax request with this new content:

<div id="div1" class="red">New Content</div>

Note two things:

  • The div has the same id in the original and in the new content
  • The red class has been added to the new content

Given this situation, we can write a CSS transition from the old state to the new state:

.red { color: red; transition: all ease-in 1s; }

When htmx swaps in this new content, it will do so in such a way that the CSS transition will apply to the new content, giving you a nice, smooth transition to the new state.

So, in summary, all you need to do to use CSS transitions for an element is keep its id stable across requests!

Synchronization

Often you want to coordinate the requests between two elements. For example, you may want a request from one element to supersede the request of another element, or to wait until the other element’s request has finished.

htmx offers a hx-sync attribute to help you accomplish this.

Consider a race condition between a form submission and an individual input’s validation request in this HTML:

<form hx-post="/store"> <input id="title" name="title" type="text" hx-post="/validate" hx-trigger="change"> <button type="submit">Submit</button> </form>

Without using hx-sync, filling out the input and immediately submitting the form triggers two parallel requests to /validate and /store.

Using hx-sync="closest form" on the input and hx-sync="this:replace" on the form will watch for requests from the form and abort an input’s in flight request:

<form hx-post="/store" hx-sync="this:replace"> <input id="title" name="title" type="text" hx-post="/validate" hx-trigger="change" hx-sync="closest form"> <button type="submit">Submit</button> </form>

This resolves the synchronization between the two elements in a declarative way.

htmx also supports a programmatic way to cancel requests: you can send the htmx:abort event to an element to cancel any in-flight requests:

<button id="request-button" hx-post="/example"> Issue Request </button> <button onclick="htmx.trigger('#request-button', 'htmx:abort')"> Cancel Request </button>

More examples and details can be found on the hx-sync attribute page.

Confirmations

Often you will want to confirm an action before issuing a request. htmx supports the hx-confirm attribute, which allows you to confirm an action using a simple javascript dialog:

<button hx-delete="/account" hx-confirm="Are you sure you wish to delete your account?"> Delete My Account </button>

hx-confirm may also contain JavaScript by using the js: or javascript: prefix. In this case the JavaScript will be evaluated and, if a promise is returned, it will wait until the promise resolves with a true value to continue

<script> async function swalConfirm() { let result = await Swal.fire({ title: "Are you sure?", text: "You won't be able to revert this!", icon: "warning", showCancelButton: true, confirmButtonColor: "#3085d6", cancelButtonColor: "#d33", confirmButtonText: "Yes, delete it!" }) return result.isConfirmed } </script> <button hx-delete="/account" hx-confirm="js:swalConfirm()"> Delete My Account </button>

Boosting

Htmx supports “boosting” regular HTML anchors and forms with the hx-boost attribute. This attribute will convert all anchor tags and forms into AJAX requests that, by default, target the body of the page.

Here is an example:

<div hx-boost:inherited="true"> <a href="/blog">Blog</a> <a href="/about">About</a> <a href="/contact">Contact</a> </div>

The anchor tags in this div will issue an AJAX GET request to /blog and swap the response into the body tag.

Note that hx-boost is using the inherited modifier here.

Progressive Enhancement

A nice feature of hx-boost is that it degrades gracefully if javascript is not enabled: the links and forms continue to work, they simply don’t use ajax requests.

This is known as Progressive Enhancement, and it allows a wider audience to use your site’s functionality.

Other htmx patterns can be adapted to achieve progressive enhancement as well, but they will require more thought.

Consider the active search example. As it is written, it will not degrade gracefully: someone who does not have javascript enabled will not be able to use this feature. This is done for simplicity’s sake, to keep the example as brief as possible.

However, you could wrap the htmx-enhanced input in a form element:

<form action="/search" method="POST"> <input class="form-control" type="search" name="search" placeholder="Begin typing to search users..." hx-post="/search" hx-trigger="keyup changed delay:500ms, search" hx-target="#search-results" hx-indicator=".htmx-indicator"> </form>

With this in place, javascript-enabled clients would still get the nice active-search UX, but non-javascript enabled clients would be able to hit the enter key and still search. Even better, you could add a “Search” button as well. You would then need to update the form with an hx-post that mirrored the action attribute, or perhaps use hx-boost on it.

You would need to check on the server side for the HX-Request header to differentiate between an htmx-driven and a regular request, to determine exactly what to render to the client.

Other patterns can be adapted similarly to achieve the progressive enhancement needs of your application.

As you can see, this requires more thought and more work. It also rules some functionality entirely out of bounds. These tradeoffs must be made by you, the developer, with respect to your projects goals and audience.

Accessibility is a concept closely related to progressive enhancement. Using progressive enhancement techniques such as hx-boost will make your htmx application more accessible to a wide array of users.

htmx-based applications are very similar to normal, non-AJAX driven web applications because htmx is HTML-oriented.

As such, the normal HTML accessibility recommendations apply. For example:

  • Use semantic HTML as much as possible (i.e. the right tags for the right things)
  • Ensure focus state is clearly visible
  • Associate text labels with all form fields
  • Maximize the readability of your application with appropriate fonts, contrast, etc.

History

Changes in htmx 4.0

History support in htmx 4.0 has changed significantly. We no longer snapshot the DOM and keep a copy in sessionStorage.

Instead, we issue a full page request every time someone navigates to a history element. This is much less error-prone and foolproof. It also eliminates security concerns regarding keeping history state in accessible storage

This change makes history restoration much more reliable and reduces client-side complexity.

Htmx provides a simple mechanism for interacting with the browser history API:

If you want a given element to push its request URL into the browser navigation bar and add the current state of the page to the browser’s history, include the hx-push-url attribute:

<a hx-get="/blog" hx-push-url="true">Blog</a>

When a user clicks on this link, htmx will push a new location onto the history stack.

When a user hits the back button, htmx will retrieve the old content from the original URL and swap it back into the body, simulating “going back” to the previous state.

NOTE: If you push a URL into the history, you must be able to navigate to that URL and get a full page back! A user could copy and paste the URL into an email, or new tab.

Validation

Htmx integrates with the HTML5 Validation API and will not issue a request for a form if a validatable input is invalid.

Non-form elements do not validate before they make requests by default, but you can enable validation by setting the hx-validate attribute to “true”.

Web Components

htmx doesn’t automatically scan inside web components’ shadow DOM. You must manually initialize it.

After creating your shadow DOM, call htmx.process:

customElements.define('my-counter', class extends HTMLElement { connectedCallback() { const shadow = this.attachShadow({mode: 'open'}) shadow.innerHTML = ` <button hx-post="/increment" hx-target="#count">+1</button> <div id="count">0</div> ` htmx.process(shadow) // Initialize htmx for this shadow DOM } })
Targeting Elements Outside Shadow DOM

Selectors like hx-target only see elements inside the same shadow DOM.

To break out:

  1. Target the host element, using host:
<button hx-get="..." hx-target="host"> ... </button>
  1. Target elements in main document, using global:<selector>:
<button hx-get="..." hx-target="global:#target"> ... </button>
Components Without Shadow DOM

Still call htmx.process on the component:

customElements.define('simple-widget', class extends HTMLElement { connectedCallback() { this.innerHTML = `Load` htmx.process(this) } })

Attribute Inheritance

Changes in htmx 4.0

In htmx 2.0 attribute inheritance was implicit by default: elements inherited the attributes on their parents, such as hx-target. In htmx 4.0 attribute inheritance is now explicit by default, using the :inherited modifier.

Inheritance allows you to “hoist” attributes up the DOM to avoid code duplication.

Consider the following htmx:

<button hx-delete="/account" hx-confirm="Are you sure?"> Delete My Account </button> <button hx-put="/account" hx-confirm="Are you sure?"> Update My Account </button>

Here we have a duplicate hx-confirm attribute.

We can hoist this attribute to a parent element using the :inherited modifier:

<div hx-confirm:inherited="Are you sure?"> <button hx-delete="/account"> Delete My Account </button> <button hx-put="/account"> Update My Account </button> </div>

This hx-confirm attribute will now apply to all htmx-powered elements within it.

Extended Selectors

Extended selectors let you target elements in flexible ways.

Use them with hx-target, hx-sync, and other attributes that accept selectors.

Standard CSS Selectors

Start with any CSS selector.

<!-- Target by ID --> <button hx-get="/data" hx-target="#results">Load</button> <!-- Target by class --> <button hx-get="/data" hx-target=".container">Load</button> <!-- Target by attribute --> <button hx-get="/data" hx-target="[data-results]">Load</button>

this

Target the element itself.

<!-- Update the button when clicked --> <button hx-get="/status" hx-target="this">Check Status</button>

The button will replace itself with the response.

closest <selector>

Find the nearest ancestor matching the selector.

Searches upward through parent elements.

<div class="card"> <div class="card-body"> <button hx-get="/refresh" hx-target="closest .card">Refresh</button> </div> </div>

The button targets its parent .card element.

Works like Element.closest().

find <selector>

Find the first child matching the selector.

Searches downward through descendant elements.

<div hx-get="/user" hx-target="find .username"> <span class="username">Loading...</span> </div>

The div targets its child .username element.

Works like Element.querySelector().

findAll <selector>

Find all children matching the selector.

<div hx-get="/items" hx-target="findAll .item"> <div class="item">Item 1</div> <div class="item">Item 2</div> </div>

Targets all .item elements inside the div.

Works like Element.querySelectorAll().

next

Target the next sibling element.

<button hx-get="/more" hx-target="next">Load More</button> <div>Content loads here</div>

Targets the element immediately after the button.

Works like Element.nextElementSibling.

next <selector>

Scan forward for the first matching element.

Searches through all following siblings.

<button hx-get="/data" hx-target="next .results">Load</button> <div class="other">Not here</div> <div class="results">Loads here</div>

Skips siblings until it finds .results.

previous

Target the previous sibling element.

<div>Content loads here</div> <button hx-get="/more" hx-target="previous">Load More</button>

Targets the element immediately before the button.

Works like Element.previousElementSibling.

previous <selector>

Scan backward for the first matching element.

Searches through all preceding siblings.

<div class="results">Loads here</div> <div class="other">Not here</div> <button hx-get="/data" hx-target="previous .results">Load</button>

Skips siblings until it finds .results.

Special Keywords

body

Target the document body.

<button hx-get="/page" hx-target="body">Load Page</button>

Useful for full-page updates.

document

Reference the entire document.

<div hx-trigger="click from:document">...</div>

Used primarily with event triggers.

window

Reference the window object.

<div hx-trigger="scroll from:window">...</div>

Used primarily with window events.

host

Target the shadow DOM host element.

<!-- Inside shadow DOM --> <button hx-get="/data" hx-target="host">Update Host</button>

Only works inside shadow DOM.

global <selector>

Search the entire document tree.

By default, selectors search within the current shadow DOM boundary.

<!-- Inside shadow DOM --> <button hx-get="/data" hx-target="global #results">Load</button> <!-- Targets #results in the main document --> <div id="results"></div>

Crosses shadow DOM boundaries.

Multiple Targets

Separate multiple selectors with commas.

<button hx-get="/data" hx-target="#results, #cache">Load</button>

Updates both elements with the response.

Hyperscript-Style Syntax

Wrap selectors in <.../> for hyperscript compatibility.

<button hx-get="/data" hx-target="<#results/>">Load</button>

This mimics hyperscript query literals.

Useful if you’re using hyperscript alongside htmx.

Common Patterns

Update parent card
<div class="card"> <button hx-delete="/item/1" hx-target="closest .card">Delete</button> </div>
Update sibling container
<button hx-get="/data" hx-target="next .results">Load</button> <div class="results"></div>
Update self
<div hx-get="/refresh" hx-target="this">Click to refresh</div>
Update child element
<div hx-get="/user" hx-target="find .username"> <span class="username">Loading...</span> </div>

Tips

Start with simple CSS selectors.

Use this for self-updates.

Use closest to update parent containers.

Use find to update child elements.

Use next and previous for sibling relationships.

Avoid id attributes when relative selectors work.

This keeps your HTML cleaner.

Extensions

htmx supports extensions to augment its core hypermedia infrastructure. The extension mechanism takes pressure off the core library to add new features, allowing it to focus on its main purpose of generalizing hypermedia controls.

For the catalog of core extensions shipped with htmx, see /extensions.

Using Extensions

In htmx 4, extensions hook into standard events rather than callback extension points. They are lightweight with no performance penalty.

Extensions apply page-wide without requiring hx-ext on parent elements. They activate via custom attributes where needed.

Loading an Extension

Include the extension script after htmx. Core extensions ship with htmx in the /ext/ directory:

<script src="https://cdn.jsdelivr.net/npm/htmx.org@next/dist/htmx.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/htmx.org@next/dist/ext/hx-sse.js"></script>

Or with a bundler:

import 'htmx.org'; import 'htmx.org/dist/ext/hx-sse';
Restricting Extensions

To restrict which extensions can register, use an allow list:

<meta name="htmx-config" content='{"extensions": "my-ext,another-ext"}'>

When this config is set, only the listed extensions will be loaded. Without it, all registered extensions are active.

Building Extensions

htmx 4 introduces an extension system based on event hooks rather than the old callback-based API.

Defining an Extension

Extensions are defined using htmx.registerExtension():

htmx.registerExtension("my-ext", { init: (internalAPI) => { // Called once when extension is registered // Store internalAPI reference if needed }, htmx_before_request: (elt, detail) => { // Called before each request // Return false to cancel }, htmx_after_request: (elt, detail) => { // Called after each request }, });
Event Hooks

Extensions hook into htmx lifecycle events. Event names use underscores instead of colons:

Core Lifecycle Events
Hook NameTriggered EventParametersDescription
htmx_before_inithtmx:before:init(elt, detail)Before element initialization
htmx_after_inithtmx:after:init(elt, detail)After element initialization
htmx_before_processhtmx:before:process(elt, detail)Before processing element
htmx_after_processhtmx:after:process(elt, detail)After processing element
htmx_before_cleanuphtmx:before:cleanup(elt, detail)Before cleaning up element
htmx_after_cleanuphtmx:after:cleanup(elt, detail)After cleaning up element
Request Lifecycle Events
Hook NameTriggered EventParametersDescription
htmx_config_requesthtmx:config:request(elt, detail)Configure request before sending
htmx_before_requesthtmx:before:request(elt, detail)Before request is sent
htmx_before_responsehtmx:before:response(elt, detail)After fetch, before body consumed
htmx_after_requesthtmx:after:request(elt, detail)After request completes
htmx_finally_requesthtmx:finally:request(elt, detail)Always called after request
htmx_errorhtmx:error(elt, detail)On request error
Swap Events
Hook NameTriggered EventParametersDescription
htmx_before_swaphtmx:before:swap(elt, detail)Before content swap
htmx_after_swaphtmx:after:swap(elt, detail)After content swap
htmx_before_settlehtmx:before:settle(elt, detail)Before settle phase
htmx_after_settlehtmx:after:settle(elt, detail)After settle phase
handle_swap(direct call)(swapStyle, target, fragment, swapSpec)Custom swap handler
History Events
Hook NameTriggered EventParametersDescription
htmx_before_history_updatehtmx:before:history:update(elt, detail)Before updating history
htmx_after_history_updatehtmx:after:history:update(elt, detail)After updating history
htmx_after_history_pushhtmx:after:history:push(elt, detail)After pushing to history
htmx_after_history_replacehtmx:after:history:replace(elt, detail)After replacing history
htmx_before_history_restorehtmx:before:history:restore(elt, detail)Before restoring from history
Cancelling Events

Return false or set detail.cancelled = true to cancel an event:

htmx.registerExtension("validator", { htmx_before_request: (elt, detail) => { if (!isValid(detail.ctx)) { return false; // Cancel request } }, });
Internal API

The init hook receives an internal API object with helper methods:

let api; htmx.registerExtension("my-ext", { init: (internalAPI) => { api = internalAPI; }, htmx_after_init: (elt) => { let value = api.attributeValue(elt, "hx-my-attr"); let specs = api.parseTriggerSpecs("click, keyup delay:500ms"); let { method, action } = api.determineMethodAndAction(elt, evt); }, });

Available internal API methods:

  • attributeValue(elt, name, defaultVal, returnElt) - Get htmx attribute value with inheritance
  • parseTriggerSpecs(spec) - Parse trigger specification string
  • determineMethodAndAction(elt, evt) - Get HTTP method and URL
  • createRequestContext(elt, evt) - Create request context object
  • collectFormData(elt, form, submitter) - Collect form data
  • handleHxVals(elt, body) - Process hx-vals attribute
Request Context

The detail.ctx object contains request information:

{ sourceElement, // Element triggering request sourceEvent, // Event that triggered request status, // Request status target, // Target element for swap swap, // Swap strategy request: { action, // Request URL method, // HTTP method headers, // Request headers body, // Request body (FormData) validate, // Whether to validate abort, // Function to abort request signal // AbortSignal }, response: { // Available after request raw, // Raw Response object status, // HTTP status code headers // Response headers }, text, // Response text (after request) hx // HX-* response headers (parsed) }
Custom Swap Strategies

Extensions can implement custom swap strategies:

htmx.registerExtension("my-swap", { handle_swap: (swapStyle, target, fragment, swapSpec) => { if (swapStyle === "my-custom-swap") { target.appendChild(fragment); return true; // Handled } return false; // Not handled }, });

For migrating extensions written for htmx 2.x, see Migration → Migrating Your Own Extensions.

Security

Best Practices

htmx allows you to define logic directly in your DOM. This has a number of advantages, the largest being Locality of Behavior, which makes your system easier to understand and maintain.

A concern with this approach, however, is security: since htmx increases the expressiveness of HTML, if a malicious user is able to inject HTML into your application, they can leverage this expressiveness of htmx to malicious ends.

Rule 1: Escape All User Content

The first rule of HTML-based web development has always been: do not trust input from the user. You should escape all 3rd party, untrusted content that is injected into your site. This is to prevent, among other issues, XSS attacks.

There is extensive documentation on XSS and how to prevent it on the excellent OWASP Website, including a Cross Site Scripting Prevention Cheat Sheet.

The good news is that this is a very old and well understood topic, and the vast majority of server-side templating languages support automatic escaping of content to prevent just such an issue.

That being said, there are times people choose to inject HTML more dangerously, often via some sort of raw() mechanism in their templating language. This can be done for good reasons, but if the content being injected is coming from a 3rd party then it must be scrubbed, including removing attributes starting with hx- and data-hx, as well as inline <script> tags, etc.

If you are injecting raw HTML and doing your own escaping, a best practice is to whitelist the attributes and tags you allow, rather than to blacklist the ones you disallow.

htmx Security Tools

Of course, bugs happen and developers are not perfect, so it is good to have a layered approach to security for your web application, and htmx provides tools to help secure your application as well.

Let’s take a look at them.

hx-ignore

The first tool htmx provides to help further secure your application is the hx-ignore attribute. This attribute will prevent processing of all htmx attributes on a given element, and on all elements within it. So, for example, if you were including raw HTML content in a template (again, this is not recommended!) then you could place a div around the content with the hx-ignore attribute on it:

<div hx-ignore> <%= raw(user_content) %> </div>

And htmx will not process any htmx-related attributes or features found in that content. This attribute cannot be disabled by injecting further content: if an hx-ignore attribute is found anywhere in the parent hierarchy of an element, it will not be processed by htmx.

CSP Options

Browsers also provide tools for further securing your web application. The most powerful tool available is a Content Security Policy. Using a CSP you can tell the browser to, for example, not issue requests to non-origin hosts, to not evaluate inline script tags, etc.

CSP can be set via an HTTP header or a <meta> tag. HTTP headers are preferred — <meta> tags do not enforce all directives and scripts that appear before the <meta> tag in the document are not covered by it:

Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-<nonce>'

A full discussion of CSPs is beyond the scope of this document, but the MDN Article provides a good jumping-off point for exploring this topic.

Controlling Cross-Origin Requests

htmx defaults htmx.config.mode to "same-origin", which causes the browser to reject any cross-origin fetch — even if an attacker injects an hx-get pointing elsewhere.

This setting is enforced globally: any mode value in a per-element hx-config attribute is ignored and reset to the global config value. This means injected markup like hx-config='{"mode":"cors"}' cannot widen request scope.

If your application legitimately needs CORS (e.g. an API on a different subdomain):

  1. Set the mode globally:
    htmx.config.mode = "cors";
  2. Lock down reachable origins with connect-src:
    <meta http-equiv="Content-Security-Policy" content="connect-src 'self' https://api.example.com">

With both in place, htmx can reach your API but injected target URLs to other origins are blocked by CSP.

hx-nonce Extension

For sites using CSP script nonces, the hx-nonce extension provides deep integration:

  • Gates all htmx attribute processing behind a per-request nonce, blocking injected htmx attributes
  • Automatically creates a 'htmx' Trusted Types policy so only htmx can write HTML into DOM sinks
  • Replaces new Function() eval with nonce-based script injection when safeEval:true is set, removing the need for unsafe-eval

See the hx-nonce extension docs for full setup instructions.

htmx & Eval

htmx uses new Function() for some optional features:

  • Event filters
  • The hx-on attribute
  • Attribute values starting with js: or javascript:

All of these are optional. If you don’t use them you can omit unsafe-eval from your CSP entirely.

If you do use these features, the hx-nonce extension with safeEval:true replaces new Function() with nonce-based script injection, enabling them without unsafe-eval.

CSP & Inline Styles

htmx injects its indicator CSS using Constructable Stylesheets (document.adoptedStyleSheets), which are not subject to style-src CSP restrictions.

The one area to be aware of is morph swaps when used alongside JS frameworks like Alpine that set style attributes via JavaScript. During morph, htmx’s __copyAttributes reads all attributes from the new element and copies them to the old one — including any style attributes set by the framework. Under a strict style-src policy without 'unsafe-inline', this setAttribute("style", ...) call will produce a CSP violation.

Add "style" to morphIgnore to skip it:

<meta name="htmx-config" content='{"morphIgnore":["data-htmx-powered","style"]}'>

Class-based CSS transitions continue to work normally.

CSRF Prevention

The assignment and checking of CSRF tokens are typically backend responsibilities, but htmx can support returning the CSRF token automatically with every request using the hx-headers attribute. The attribute needs to be added to the element issuing the request or one of its ancestor elements. This makes the html and body elements effective global vehicles for adding the CSRF token to the HTTP request header, as illustrated below.

<html lang="en" hx-headers='{"X-CSRF-TOKEN": "CSRF_TOKEN_INSERTED_HERE"}'> : </html>

The above elements are usually unique in an HTML document and should be easy to locate within templates.

Caching

htmx works with standard HTTP caching mechanisms out of the box.

If your server adds the Last-Modified HTTP response header to the response for a given URL, the browser will automatically add the If-Modified-Since request HTTP header to the next requests to the same URL.

For polling use cases where you want the server to skip responses when content hasn’t changed, see the ptag extension.

Be mindful that if your server can render different content for the same URL depending on some other headers, you need to use the Vary response HTTP header.

For example, if your server renders the full HTML when the HX-Request header is missing or false, and it renders a fragment of that HTML when HX-Request: true, you need to add Vary: HX-Request. That causes the cache to be keyed based on a composite of the response URL and the HX-Request request header rather than being based just on the response URL.

Troubleshoot

Debugging

Declarative and event driven programming with htmx (or any other declarative language) can be a wonderful and highly productive activity, but one disadvantage when compared with imperative approaches is that it can be trickier to debug.

Figuring out why something isn’t happening, for example, can be difficult if you don’t know the tricks.

Here are some tips:

Errors and warnings flow to console.error / console.warn by default. To also see every event htmx dispatches, set htmx.config.logAll = true:

htmx.config.logAll = true;

Observability tools (Sentry, DataDog RUM, LogRocket, etc.) capture console.* automatically, so htmx logs flow into your existing pipeline without any extra setup.

Of course, that won’t tell you why htmx isn’t doing something. You might also not know what events a DOM element is firing to use as a trigger. To address this, you can use the monitorEvents() method available in the browser console:

monitorEvents(htmx.find("#theElement"));

This will spit out all events that are occurring on the element with the id theElement to the console, and allow you to see exactly what is going on with it.

Note that this only works from the console, you cannot embed it in a script tag on your page.

Finally, push come shove, you might want to just debug htmx.js by loading up the unminimized version.

You would most likely want to set a break point in the methods to see what’s going on.

And always feel free to jump on the Discord if you need help.

Configuration

Htmx has configuration options that can be accessed either programmatically or declaratively.

They are listed below:

<div class="info-table"> | Config Variable | Info | |-----------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `htmx.config.logAll` | defaults to `false`, if set to `true` htmx will log all events to the console for debugging | | `htmx.config.prefix` | defaults to `"data-hx-"`, a secondary attribute prefix recognised alongside the always-active `hx-` prefix (e.g. `data-hx-get` works by default). Set to `""` to disable. **Must be set via meta tag** — setting this after page load will not apply correctly. | | `htmx.config.transitions` | defaults to `false`, whether to use view transitions when swapping content (if browser supports it) | | `htmx.config.history` | defaults to `true`, whether to enable history support. Set to `"reload"` to do a full page reload on history navigation instead of an AJAX request | | `htmx.config.mode` | defaults to `'same-origin'`, the fetch mode for AJAX requests. Can be `'cors'`, `'no-cors'`, or `'same-origin'` | | `htmx.config.defaultSwap` | defaults to `innerHTML` | | `htmx.config.indicatorClass` | defaults to `htmx-indicator` | | `htmx.config.requestClass` | defaults to `htmx-request` | | `htmx.config.includeIndicatorCSS` | defaults to `true` (determines if the indicator styles are loaded) | | `htmx.config.defaultTimeout` | defaults to `60000` (60 seconds), the number of milliseconds a request can take before automatically being terminated | | `htmx.config.inlineScriptNonce` | defaults to `''`, meaning that no nonce will be added to inline scripts | | `htmx.config.extensions` | defaults to `''`, a comma-separated list of extension names to load (e.g., `'preload,optimistic'`) | | `htmx.config.morphIgnore` | defaults to `["data-htmx-powered"]`, array of attribute names to ignore when morphing elements | | `htmx.config.morphScanLimit` | limits the number of nodes scanned during morphing | | `htmx.config.morphSkip` | CSS selector for elements to completely skip during morphing (they stay frozen) | | `htmx.config.morphSkipChildren` | CSS selector for elements whose attributes update but children are preserved during morphing | | `htmx.config.noSwap` | defaults to `[204, 304]`, array of HTTP status codes that should not trigger a swap | | `htmx.config.implicitInheritance` | defaults to `false`, if set to `true` attributes will be inherited from parent elements automatically without requiring the `:inherited` modifier | | `htmx.config.defaultFocusScroll` | defaults to `false`, whether to scroll focused elements into view after swap | | `htmx.config.defaultSettleDelay` | defaults to `1` (ms), delay between swap and settle phases | | `htmx.config.metaCharacter` | defaults to `undefined`, allows you to use a custom character instead of `:` for attribute modifiers (e.g., `-` to use `hx-get-inherited` instead of `hx-get:inherited`) | </div> You can set most options directly in JavaScript, or you can use a `meta` tag (accepts [HCON](/docs/core-concepts/hcon#meta-name-htmx-config) or JSON): > **Note:** Some options are read only once during initialisation and must be set via the `meta` tag to take effect. These include `prefix`, `extensions`, and `metaCharacter`. <meta name="htmx-config" content='{"defaultSwap":"innerHTML"}'>

Conclusion

And that’s it!

Have fun with htmx!

You can accomplish quite a bit without writing a lot of code!

Editor Support

VS Code

The HTMX Toolkit extension adds htmx support to Visual Studio Code with autocomplete, hover documentation, and snippets.

Features

  • Attribute autocomplete for all htmx attributes (hx-get, hx-post, hx-target, etc.)
  • Hover documentation with links to the official docs
  • Snippets for common htmx patterns
  • Support for htmx 2.x and 4.x

Installing

Search for HTMX Toolkit in the VS Code Extensions panel, or install from the Visual Studio Marketplace.

Source

The extension source code is maintained at atoolz/htmx-vscode-toolkit.