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,
400and500response 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.
hx-trigger queue modifier removed
The queue modifier on hx-trigger (e.g. hx-trigger="click queue:all") no longer
works. Request queuing is now controlled exclusively by hx-sync.
<!-- htmx 2 --> <div hx-trigger="click queue:all" hx-get="/test">...</div> <!-- htmx 4: use hx-sync instead --> <div hx-trigger="click" hx-get="/test" hx-sync="this:queue all">...</div>
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-disablemeant “skip htmx processing on this element” - In htmx 4, that role is
hx-ignore - The name
hx-disablenow does whathx-disabled-eltused to do (disable form elements during requests)
Rename in this order to avoid conflicts:
- Rename
hx-disabletohx-ignore - Rename
hx-disabled-elttohx-disable
Removed attributes
| Removed | Use instead |
|---|---|
hx-vars | hx-vals with js: prefix |
hx-params | htmx:config:request event |
hx-prompt | hx-confirm with js: prefix |
hx-ext | Include extension script directly |
hx-disinherit | Not needed (inheritance is explicit) |
hx-inherit | Not needed (inheritance is explicit) |
hx-request | hx-config |
hx-history | Removed (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.x | htmx 4.x |
|---|---|
htmx:afterOnLoad | htmx:after:init |
htmx:afterProcessNode | htmx:after:init |
htmx:afterRequest | htmx:after:request |
htmx:afterSettle | htmx:after:swap |
htmx:afterSwap | htmx:after:swap |
htmx:beforeCleanupElement | htmx:before:cleanup |
htmx:beforeHistorySave | htmx:before:history:update |
htmx:beforeOnLoad | htmx:before:init |
htmx:beforeProcessNode | htmx:before:process |
htmx:beforeRequest | htmx:before:request |
htmx:beforeSwap | htmx:before:swap |
htmx:configRequest | htmx:config:request |
htmx:historyCacheMiss | htmx:before:history:restore |
htmx:historyRestore | htmx:before:history:restore |
htmx:load | htmx:after:init |
htmx:oobAfterSwap | htmx:after:swap |
htmx:oobBeforeSwap | htmx:before:swap |
htmx:pushedIntoHistory | htmx:after:history:push |
htmx:replacedInHistory | htmx:after:history:replace |
htmx:responseError | htmx:response:error |
htmx:sendError | htmx:error |
htmx:swapError | htmx:error |
htmx:targetError | htmx:error |
htmx:timeout | htmx:error |
Removed events
Validation events are removed. Use native browser form validation:
htmx:validation:validatehtmx:validation:failedhtmx:validation:halted
XHR events are removed (htmx uses fetch() now):
| Removed | Use instead |
|---|---|
htmx:xhr:loadstart | No replacement |
htmx:xhr:loadend | htmx:finally:request |
htmx:xhr:progress | No replacement |
htmx:xhr:abort | htmx:error |
Config changes
Renamed:
| htmx 2.x | htmx 4.x |
|---|---|
defaultSwapStyle | defaultSwap |
globalViewTransitions | transitions |
historyEnabled | history |
includeIndicatorStyles | includeIndicatorCSS |
timeout | defaultTimeout |
Changed defaults:
| Config | htmx 2 | htmx 4 |
|---|---|---|
defaultTimeout | 0 (no timeout) | 60000 (60 seconds) |
defaultSettleDelay | 20 | 1 |
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.x | htmx 4.x | Notes |
|---|---|---|
HX-Trigger | HX-Source | Format changed to tagName#id (e.g. button#submit) |
HX-Target | HX-Target | Format changed to tagName#id |
HX-Trigger-Name | removed | Use HX-Source |
HX-Prompt | removed | Use hx-confirm with js: prefix |
| (new) | HX-Request-Type | "full" or "partial" |
| (new) | Accept | Now explicitly text/html |
Response headers
Removed:
HX-Trigger-After-SwapHX-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.x | Use 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
| Attribute | Purpose |
|---|---|
hx-action | Specify URL (use with hx-method) |
hx-method | Specify HTTP method |
hx-config | Per-element request config (JSON or key:value syntax) |
hx-ignore | Disable htmx processing (was hx-disable) |
hx-validate | Control 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):
| New | Equivalent to |
|---|---|
before | beforebegin |
after | afterend |
prepend | afterbegin |
append | beforeend |
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.erroris set on the event, output goes toconsole.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 forhtmx:errorget the same data via the event. - If
detail.warnis set, output goes toconsole.warn. - Otherwise, the event is logged at
console.log(silent by default; sethtmx.config.logAll = trueto 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
| Event | Fires |
|---|---|
htmx:after:cleanup | After element cleanup |
htmx:after:history:update | After history update |
htmx:after:process | After element processing |
htmx:before:response | Before response body is read (cancellable) |
htmx:before:settle | Before settle phase |
htmx:after:settle | After settle phase |
htmx:before:viewTransition | Before a view transition starts (cancellable) |
htmx:after:viewTransition | After a view transition completes |
htmx:finally:request | Always fires after a request (success or failure) |
Config keys
| Config | Default | Purpose |
|---|---|---|
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 |
morphScanLimit | Max 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.
| Extension | Description |
|---|---|
alpine-compat | Alpine.js compatibility: initializes Alpine on fragments before swap |
browser-indicator | Shows the browser’s native loading indicator during requests |
head-support | Merges head tag information (styles, etc.) in htmx requests |
htmx-2-compat | Restores implicit inheritance, old event names, and previous error-swapping defaults |
optimistic | Shows expected content from a template before the server responds |
preload | Triggers requests early (on mouseover/mousedown) for near-instant page loads (upgrade guide) |
sse | Server-Sent Events streaming support (upgrade guide) |
upsert | Updates existing elements by ID and inserts new ones, preserving unmatched elements |
ws | Bi-directional WebSocket communication (upgrade guide) |
Checklist
- Optionally, add config options or load
htmx-2-compatfor backward compatibility - Run the upgrade checker to get a full list of issues
- Rename
hx-disabletohx-ignore, thenhx-disabled-elttohx-disable - Replace removed attributes with alternatives
- Find/replace event names in JavaScript and
hx-onattributes - Replace removed API methods with native JS
- Update extensions
- Rename changed config keys
- Test error handling (4xx/5xx now swap by default)
- Test attribute inheritance
- 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 event | htmx 4 hook |
|---|---|
htmx:configRequest | htmx_config_request |
htmx:beforeRequest | htmx_before_request |
htmx:afterRequest | htmx_after_request |
htmx:beforeSwap | htmx_before_swap |
htmx:afterSwap | htmx_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 inhtmx_config_request)detail.ctx.request.headersdetail.ctx.response.statusdetail.ctx.text(response body, modifiable inhtmx_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 callback | htmx 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
- Rename
defineExtensiontoregisterExtension - Replace
onEventwith individualhtmx_*hooks - Replace
transformResponsewithhtmx_after_request - Replace
encodeParameterswithhtmx_config_request - Merge
isInlineSwapandhandleSwapintohandle_swap - Replace
getSelectorswithhtmx_after_init - Remove
hx-extattributes from HTML - Update event names (colons to underscores in hook names)
- Test custom swap styles with OOB swaps