HTMX v2 to v4 Migration Guide¶
This guide covers the breaking changes when upgrading from htmx v2 to v4 in Vibetuner projects. It also documents what changed between htmx 4 pre-release versions (alpha → beta1 → beta3), so users on any pre-release can migrate.
Quick Start¶
The two biggest behavioral changes from v2 to v4:
- Attribute inheritance is explicit (was implicit in v2)
- Error responses (4xx/5xx) swap by default (were skipped in v2)
To restore v2 behavior while you migrate incrementally, add this before loading htmx:
<script>
htmx.config.implicitInheritance = true;
htmx.config.noSwap = [204, 304, '4xx', '5xx'];
</script>
Or load the htmx-2-compat extension, which restores implicit inheritance,
old event names, and previous error-swapping defaults:
Error Response Swapping¶
htmx 4 swaps all HTTP responses by default. Only 204 and 304 are skipped.
In v2, 4xx and 5xx responses were not swapped. In v4, if your server
returns HTML with a 422 or 500, that HTML gets swapped into the target.
This means your error responses need to produce valid swap content.
Options:
- Design error responses as HTML fragments suitable for swapping
- Use the new
hx-statusattribute for fine-grained control - Revert globally:
htmx.config.noSwap = [204, 304, '4xx', '5xx']
Per-Status-Code Swap Control¶
The new hx-status attribute lets you control 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.
Attribute Inheritance Requires :inherited¶
In v2, many htmx attributes were inherited by child elements automatically. In v4,
inheritance must be explicitly opted into using the :inherited modifier.
Before (v2):
<div hx-target="#results">
<!-- All children inherit hx-target="#results" -->
<button hx-get="/search">Search</button>
<button hx-get="/filter">Filter</button>
</div>
After (v4):
<div hx-target:inherited="#results">
<!-- Children inherit hx-target via :inherited modifier -->
<button hx-get="/search">Search</button>
<button hx-get="/filter">Filter</button>
</div>
Without :inherited, each child element must set its own attributes explicitly.
Use :append to add to an inherited value instead of replacing it:
<div hx-include:inherited="#global-fields">
<form hx-include:inherited:append=".extra">...</form>
</div>
Multi-Target Updates with <hx-partial>¶
<hx-partial> is a new alternative to hx-swap-oob for targeting multiple
elements from one response:
<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.
Note
OOB swap order changed in v4: the main content swaps first, then
OOB and <hx-partial> elements swap after (in document order). In v2,
OOB elements swapped before the main content.
SSE: Native Support Replaces Extension¶
htmx v4 includes Server-Sent Events support in core. The separate sse extension
and hx-ext="sse" attribute are no longer needed.
Before (v2):
<div hx-ext="sse" sse-connect="/events/notifications" sse-swap="update">
<!-- updates appear here -->
</div>
After (v4):
The sse-connect and sse-swap attributes work exactly the same, just remove
hx-ext="sse" from the element.
Warning
The SSE and WebSocket extensions were significantly rewritten for v4. If you use advanced SSE/WS features (custom config, event handling), see the upstream upgrade guides: SSE, WS.
Extension Auto-Registration¶
In v2, extensions had to be explicitly activated via hx-ext="..." on each element
or a parent. In v4, extensions auto-register when imported, no hx-ext attribute
needed.
Before (v2):
After (v4):
Extensions activate automatically once their script is loaded.
To restrict which extensions can register, use an allow list:
hx-vars Replaced by hx-vals with js: Prefix¶
The hx-vars attribute (which evaluated JavaScript expressions) has been removed.
Use hx-vals with the js: prefix instead.
Before (v2):
<button hx-post="/api/action"
hx-vars="csrfToken:getCsrfToken(), timestamp:Date.now()">
Submit
</button>
After (v4):
<button hx-post="/api/action"
hx-vals='js:{"csrfToken": getCsrfToken(), "timestamp": Date.now()}'>
Submit
</button>
Note
Plain hx-vals (without js: prefix) still works for static JSON values and
is unchanged.
hx-disable Renamed to hx-ignore¶
The attribute that prevents htmx from processing an element has been renamed.
Do this rename before upgrading, because hx-disable means something
different in v4 (it now does what hx-disabled-elt used to do).
Rename in this order to avoid conflicts:
- Rename
hx-disabletohx-ignore - Rename
hx-disabled-elttohx-disable
JavaScript Import Changes¶
htmx v4 uses a default export. The import pattern in your config.js (or equivalent
entry point) must be updated.
Before (v2):
After (v4):
The default import is required, and you must explicitly assign htmx to window for
it to be available globally (e.g., in inline scripts or the browser console).
@alltuner/vibetuner re-exports htmx as a subpath, so scaffolded projects don't need
htmx.org as a direct dependency. The bare-specifier import works under any package
manager linker mode (Bun hoisted or isolated, pnpm-style stores, npm v9+ isolated).
Preload Extension¶
The preload extension moved from a separate package to a built-in module.
Before (v2):
After (v4):
SSE Extension¶
The SSE extension also moved from a separate package to a built-in module.
Before (v2):
After (v4):
Use hx-sse:connect="/events" (and hx-sse:swap, hx-sse:close) on elements
that should subscribe to a server-sent events stream. The hx-ext="sse"
attribute is no longer needed — the extension auto-registers on import.
Event Names Changed from camelCase to Colon-Separated¶
All htmx event names switched from camelCase to a colon-separated
htmx:phase:action format.
Before (v2):
document.addEventListener("htmx:afterRequest", handler);
document.addEventListener("htmx:beforeSwap", handler);
document.addEventListener("htmx:afterSettle", handler);
After (v4):
element.addEventListener("htmx:after:request", handler);
element.addEventListener("htmx:before:swap", handler);
element.addEventListener("htmx:after:swap", handler);
Key renames:
| 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:beforeRequest |
htmx:before:request |
htmx:beforeSwap |
htmx:before:swap |
htmx:configRequest |
htmx:config:request |
htmx:responseError |
htmx:response:error (added in beta3) |
All error events are consolidated to htmx:error. As of beta3, the
specific htmx:response:error event also fires for 4xx/5xx responses,
restoring the convenience of htmx 2's htmx:responseError for handlers
that only care about HTTP error status codes.
Warning
Events no longer bubble to document.body in v4. You must attach listeners
directly to the element or use hx-on attributes. Delegation patterns like
document.body.addEventListener('htmx:after:request', ...) do not work.
Event Handler Attributes (hx-on)¶
The hx-on:: shorthand uses kebab-case event names (DOM attributes are
case-insensitive):
<!-- These are equivalent -->
<button hx-get="/info" hx-on:htmx:before-request="alert('Request!')">
<button hx-get="/info" hx-on::before-request="alert('Request!')">
Note
The hx-on:: shorthand was broken in alpha8 but is fixed in beta1.
If you are upgrading from alpha8, you can now use the shorter form.
For JSX compatibility, dashes can replace colons:
Event Detail Structure Changed¶
The event.detail object was restructured. Properties that existed at the top level
are now nested under event.detail.ctx.
Before (v2):
event.detail.successful // boolean
event.detail.elt // the triggering element
event.detail.xhr // XMLHttpRequest object
After (v4):
event.detail.ctx // context object
event.detail.ctx.sourceElement // the triggering element
event.detail.ctx.response // response object
event.detail.ctx.status // request status string
Danger
event.detail.successful is undefined in v4, which is falsy. Any code
checking if(event.detail.successful) silently skips the handler body
without errors.
fetch() Replaces XMLHttpRequest¶
All requests use the native fetch() API. This cannot be reverted. If you
have code that interacts with XMLHttpRequest objects (e.g., via
event.detail.xhr), it must be updated to use the Response object
available at event.detail.ctx.response.
hx-delete Excludes Form Data¶
Like hx-get, hx-delete no longer includes the enclosing form's inputs.
Add hx-include="closest form" where needed.
hx-swap Scroll Modifier Syntax Changed¶
The show and scroll modifiers no longer support the combined
selector:position syntax. Use separate keys:
<!-- v2 (broken in v4) -->
<div hx-swap="innerHTML show:#other:top"></div>
<!-- v4 -->
<div hx-swap="innerHTML show:top showTarget:#other"></div>
<div hx-swap="innerHTML scroll:bottom scrollTarget:#other"></div>
Config Key Renames¶
Several config keys were renamed:
| htmx 2.x | htmx 4.x |
|---|---|
globalViewTransitions |
transitions |
defaultSwapStyle |
defaultSwap |
historyEnabled |
history |
includeIndicatorStyles |
includeIndicatorCSS |
timeout |
defaultTimeout |
As of beta3, htmx.config.prefix defaults to "data-hx-", so
both hx-* and data-hx-* attributes work out of the box (matching
htmx 2 behavior). Set to "" to disable the data-prefixed alias.
Vibetuner templates use the canonical hx-* form; no change needed.
Changed defaults:
| Config | htmx 2 | htmx 4 |
|---|---|---|
defaultTimeout |
0 (no timeout) |
60000 (60 seconds) |
defaultSettleDelay |
20 |
1 |
Warning
The 60-second default timeout may break long-running requests. If you
have endpoints that take longer, set htmx.config.defaultTimeout = 0
or increase the value.
View Transitions¶
View transitions are disabled by default in htmx 4 beta1 (transitions: false).
Earlier alpha releases (alpha2 through alpha8) had view transitions enabled
by default, which caused ~500ms UI blocking after each request
(htmx#3566).
Vibetuner previously included a <meta> tag to disable them as a
workaround. That tag has been removed since beta1 defaults to disabled.
If you had added {"globalViewTransitions": false} to your own templates
as a workaround, you can safely remove it. The old config key name is
ignored in beta1.
To enable view transitions, set htmx.config.transitions = true and add
CSS transition rules per the
htmx view transitions docs.
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 in v4) |
Note
hx-history-elt was removed in earlier 4.x pre-releases but
restored in beta3 alongside an improved hx-history-cache
extension. Use it as you did in htmx 2 to mark the element whose
inner HTML is captured for history snapshots.
New Attributes¶
| Attribute | Purpose |
|---|---|
hx-status |
Per-status-code swap behavior |
hx-action |
Specify URL (use with hx-method) |
hx-method |
Specify HTTP method |
hx-config |
Per-element request config (replaces hx-request) |
hx-ignore |
Disable htmx processing (replaces hx-disable) |
hx-validate |
Control form validation behavior |
New Extensions¶
htmx 4 ships with these core extensions. All auto-register when imported.
| Extension | Description |
|---|---|
browser-indicator |
Shows the browser's native loading indicator during requests |
optimistic |
Shows expected content from a template before the server responds |
upsert |
Update-or-insert swap strategy for dynamic lists |
download |
Save responses as file downloads with streaming progress |
targets |
Swap the same response into multiple elements |
history-cache |
Client-side history cache in sessionStorage |
ptag |
Per-element polling tags to skip unchanged content |
alpine-compat |
Alpine.js compatibility |
htmx-2-compat |
Backward compatibility layer for htmx 2.x code |
nonce |
CSP nonce-based protection for inline scripts and eval-style code paths (beta3) |
live |
DOM-reactivity via hx-live and richer hx-on helpers: q(), toggle(), debounce() (beta3) |
htmx also provides an htmax bundle (htmax.min.js) that includes htmx
plus the most popular extensions (SSE, WebSockets, preload,
browser-indicator, download, optimistic, targets) in a single file.
JavaScript API Changes¶
Removed methods (use native JS):
| 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.location() |
htmx.ajax() |
htmx.logAll() |
htmx.config.logAll = true |
Renamed: htmx.defineExtension() is now htmx.registerExtension().
Alpha to Beta1 Changes¶
If you are upgrading from an htmx 4 alpha release (not from v2), here is what changed specifically between the alpha series and beta1:
hx-on::shorthand fixed: The double-colon shorthand (e.g.,hx-on::before-request) was broken in alpha8. It works correctly in beta1.globalViewTransitionsconfig removed: Renamed totransitions. The old key is silently ignored. Remove any<meta>tags using the old name.- View transitions disabled by default: No longer need the
{"globalViewTransitions": false}workaround that was required in alpha2-alpha8. - SSE/WS extensions rewritten: New APIs with per-element config,
exponential backoff,
HX-Request-IDcorrelation. See the upstream SSE and WS upgrade guides. - New extensions added:
history-cache,ptag,targets,download. htmaxbundle available: Single-file bundle with htmx + popular extensions.
Beta1 to Beta3 Changes¶
If you are upgrading from htmx 4 beta1 or beta2 (not from v2), here are the changes specific to beta3 (which is the 4.0 release candidate).
New Extensions¶
hx-nonce: CSP nonce-based protection for inline scripts andeval-style code paths. Blocks elements without a matchinghx-nonceattribute, integrates with TrustedTypes, and defends againstjs:/javascript:action URLs and unnonced boosted-form submitters. Vibetuner enforces nonce-based CSP by default, so this extension is a natural fit. See the vibetuner CSP docs.hx-live: DOM-reactivity viahx-live="..."(a JS expression re-evaluated whenever any DOM input/change/mutation event fires) plus a richer JavaScript surface insidehx-on:q(selector)jQuery-like proxy, sigil-syntaxtoggle('@attr')/toggle('*display=none|block'), per-elementdebounce(ms[, fn]), andhtmx.live.q/htmx.live.take(target, className, source)outside expression scope.
New Swap Style: outerSync¶
Copies attributes onto the existing target and replaces children. Useful
for clean <body> swaps in history replacement where you want to update
the body's attributes without losing the element identity.
Restored Attribute: hx-history-elt¶
hx-history-elt is back. Mark the element whose inner HTML is captured
for history snapshots, the same as in htmx 2.
Behavior Changes¶
htmx.config.prefixdefaults to"data-hx-": Bothhx-*anddata-hx-*work out of the box, matching htmx 2 behavior. Set to""to disable the data-prefixed alias.htmx:response:errorevent added: Fires for HTTP 4xx/5xx responses, restoring the convenience of htmx 2'shtmx:responseError.hx-downloadauto-detection: Thehx-downloadextension now auto-detects downloads via theContent-Dispositionresponse header, so you no longer need anhx-downloadattribute on each triggering element.hx-preloadboost knobs: AddedboostEvent,boostTimeout, andautoBoostconfig keys for tighter integration withhx-boost.
Security Hardening¶
hx-configno longer accepts requestmodeoverrides: Removes a privilege-escalation surface where a swap could downgrade origin enforcement.- Constructable stylesheet for indicator CSS: The runtime indicator
CSS now uses a
CSSStyleSheetconstructor instead of an injected<style>tag, avoiding CSPunsafe-inlineviolations onstyle-src. With this change, CSP-strict deployments can drop'unsafe-inline'fromstyle-src(Vibetuner is moving in this direction). - Pantry element switched from inline
styletohidden: Resolves another CSPunsafe-inlineviolation.
Breaking JavaScript API Changes¶
htmx.takeClass() and htmx.forEvent() moved out of htmx core into
the new hx-live extension and are exposed via the htmx.live
namespace (e.g. htmx.live.take(target, className, source)). If you
were calling them directly, import the hx-live extension or migrate
to native equivalents.
Common Migration Issues¶
SSE elements stop updating after upgrade¶
Symptom: SSE-powered elements no longer receive updates.
Cause: The hx-ext="sse" attribute was removed but the SSE extension script is
still being loaded, conflicting with htmx v4's built-in SSE support.
Fix: Remove both the hx-ext="sse" attribute and any <script> tag loading
htmx-ext-sse. The sse-connect and sse-swap attributes work natively in v4.
window.htmx is undefined in inline scripts¶
Symptom: Inline <script> tags or browser console show htmx is not defined.
Cause: htmx v4 uses a default export that must be explicitly assigned to window.
Fix: Update your JS entry point:
Attributes no longer inherited by child elements¶
Symptom: Buttons or links inside a container stop working because they relied on
a parent's hx-target, hx-swap, or similar attribute.
Cause: htmx v4 no longer inherits attributes by default.
Fix: Add :inherited to the parent attribute:
Preload extension not working¶
Symptom: preload="mouseover" has no effect after upgrade.
Cause: The import path changed and the old hx-ext="preload" is no longer needed.
Fix: Update your import and remove hx-ext:
hx-vars attribute ignored¶
Symptom: Dynamic values previously set via hx-vars are no longer sent.
Cause: hx-vars was removed in v4.
Fix: Use hx-vals with the js: prefix:
Error responses replacing page content unexpectedly¶
Symptom: A 422 or 500 response swaps error HTML into the page where it
previously was ignored.
Cause: htmx 4 swaps all HTTP responses by default.
Fix: Use hx-status for per-element control, or revert globally:
Long-running requests timing out¶
Symptom: Requests that worked in v2 now fail after 60 seconds.
Cause: htmx 4 defaults to a 60-second timeout (v2 had no timeout).
Fix: Increase or disable the timeout:
Migration Checklist¶
- [ ] Replace
hx-on::shorthand withhx-on:htmx:long form, or upgrade to beta1+ where the shorthand works - [ ] Replace
event.detail.successfulwithevent.detail.ctx.response - [ ] Replace camelCase event names with colon-separated
(e.g.,
afterRequest→after:request) - [ ] Move
document.bodyevent listeners to the element or usehx-onattributes - [ ] Remove all
hx-ext="sse"attributes from SSE elements - [ ] Remove all other
hx-ext="..."attributes (extensions auto-register) - [ ] Replace
hx-varswithhx-valsusingjs:prefix - [ ] Rename
hx-disabletohx-ignore, thenhx-disabled-elttohx-disable - [ ] Add
:inheritedmodifier to attributes that rely on inheritance - [ ] Update JS imports to use default import and
window.htmx = htmx - [ ] Update preload extension import path
- [ ] Remove
htmx-ext-sseandhtmx-ext-preloadfrompackage.jsonif present - [ ] Rename config keys (
globalViewTransitions→transitions, etc.) - [ ] Remove any
{"globalViewTransitions": false}meta tags - [ ] Test error handling (4xx/5xx now swap by default)
- [ ] Test long-running requests against the 60-second timeout
- [ ] Update
hx-swapscroll modifiers to new syntax if used