htmx 4.0 is under construction — migration guide

Migration

Migrate from htmx 2.x to htmx 4.x.

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 explicity 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.

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>.

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)
hx-history-eltRemoved

Renamed events

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

All error events are consolidated to htmx:error.

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:error
htmx:sendErrorhtmx:error
htmx:swapErrorhtmx:error
htmx:targetErrorhtmx:error
htmx:timeouthtmx:error

hx-on:: shorthand

The double-colon shorthand for htmx events no longer works. Because event names changed from camelCase to colon-separated (e.g. htmx:afterRequesthtmx:after:request), the hx-on:: prefix can no longer map to the correct event name.

Use the full event name instead:

<!-- htmx 2 --> <form hx-on::after-request="this.reset()"> <!-- htmx 4 --> <form hx-on:htmx:after:request="this.reset()">

This applies to all hx-on:: event handlers. Find and replace hx-on:: with hx-on:htmx: and update the event name to the new colon-separated format (see table above).

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"), 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()
htmx.logAll()htmx.config.logAll = true
htmx.logNone()htmx.config.logAll = false

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().

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 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:

<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. A cleaner alternative to out-of-band swaps.

Etag support

htmx 4 supports Etag-based conditional requests automatically:

  • Response includes an Etag header: htmx stores it on the source element
  • Next request from that element includes an If-None-Match header
  • 304 Not Modified responses do not swap, avoiding unnecessary DOM updates

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.forEvent(eventName, timeout): returns a promise that resolves when an event fires
  • htmx.takeClass(element, className, container): removes class from siblings, adds to element
  • htmx.timeout(time): returns a promise that resolves after a delay

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
inlineStyleNonce''Nonce for inline styles
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

SSE extension

The SSE extension uses fetch() and ReadableStream instead of EventSource. This enables request bodies, custom headers, and all HTTP methods.

See the SSE extension documentation for details.

Core extensions

htmx 4 ships with 9 core extensions:

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
sseServer-Sent Events streaming support
upsertUpdates existing elements by ID and inserts new ones, preserving unmatched elements
wsBi-directional Web Socket communication

Checklist

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

Migration Notes

Individual documentation pages include migration notes where features changed.

Look for these:

Changes in htmx 4.0

Get Help