htmx 4.0 is under construction — migration guide

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.

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