--- name: htmx-upgrade-from-htmx2 description: Use when helping a user upgrade or migrate their codebase from htmx 2.x to htmx 4.x. Covers attribute renames, event name changes, config updates, header changes, extension migration, and step-by-step upgrade workflow. --- # Upgrading from htmx 2 to htmx 4 htmx 4 is a ground-up rewrite. This guide covers the practical steps to migrate a codebase. ## Instructions for Claude When helping with an htmx 2 -> 4 upgrade: 1. Search the codebase for all htmx usage: `hx-` attributes, `data-hx-` attributes, `htmx:` event listeners, `htmx.` API calls, and server-side `HX-` header handling 2. Identify the scope: how many files, how complex the usage 3. Work through the steps below in order, making changes file by file 4. Run the shipped `upgrade-check` tool first (Step 0) to build the worklist 5. Pay special attention to the hx-disable/hx-ignore rename order (Step 1) 6. For attribute inheritance (Step 3), use codebase analysis to find likely-inherited attributes rather than blindly adding `:inherited` everywhere -- see the detailed instructions in that step 7. Check server-side code for header handling changes (often in middleware or base controllers) 8. Check for custom extensions -- these need a full rewrite ## Step 0: Run the Shipped Upgrade Checker htmx 4 ships a command-line checker. Run it first to scope the work. It prints clickable `file:line` references with a suggested fix for each hit. ```bash npx htmx.org@4.0.0 upgrade-check -- ./path/to/project # add file extensions the scanner does not know npx htmx.org@4.0.0 upgrade-check --ext .vue ./path/to/project ``` Pin the version to the htmx 4 release you are moving to. By default it scans `.html`, `.php`, `.js`, `.ts`, `.jinja`, `.jinja2`, `.j2`, `.erb` and `.hbs`. It flags removed attributes, old event names, inheritance patterns and extension changes. It does not rewrite anything, so work through the steps below with its output as the worklist. ## Step 1: Attribute Renames Search and replace across the codebase. **Order matters for hx-disable.** ``` # IMPORTANT: Do hx-disable FIRST (it means something different in htmx 2 vs 4) # In htmx 2, hx-disable stops htmx processing. In htmx 4, hx-ignore does that. hx-disable -> hx-ignore (htmx 2's "disable htmx processing") # Now safe to rename hx-disabled-elt hx-disabled-elt -> hx-disable (htmx 2's "disable elements during request") ``` ## Step 2: Remove Deleted Attributes | Find | Replace with | |-----------------------|-------------------------------------------------------------| | `hx-vars='...'` | `hx-vals='js:...'` (wrap value in `js:` prefix) | | `hx-params="..."` | Remove; use `htmx:config:request` event to filter params | | `hx-prompt="..."` | Load the `hx-prompt` extension (same syntax as htmx 2) | | `hx-ext="..."` | Remove (just including the extension script is enough) | | `hx-disinherit="..."` | Remove (inheritance is explicit by default) | | `hx-inherit="..."` | Remove (use `:inherited` modifier on individual attributes) | | `hx-request='...'` | `hx-config='...'`. Takes HCON, which accepts the old JSON | | `hx-history="false"` | Remove (history no longer uses localStorage) | ## Step 3: Update Attribute Inheritance In htmx 2, all attributes inherited implicitly from parent elements. In htmx 4, inheritance must be explicit using the `:inherited` modifier. ### How to find inherited attributes **Do not blindly add `:inherited` to everything.** Instead, analyze the codebase to find attributes that are actually being inherited. Look for this pattern: a parent element has an htmx attribute, and child/descendant elements rely on it without declaring it themselves. Common attributes that are frequently inherited: - **`hx-target`** -- very common. Look for a container with `hx-target` and multiple child elements with `hx-get`/`hx-post`/etc. that don't have their own `hx-target` - **`hx-include`** -- common in form-heavy UIs where a parent sets a shared include - **`hx-swap`** -- when a group of elements should all swap the same way - **`hx-boost`** -- typically set on a parent `
` or `` to boost all links within - **`hx-confirm`** -- set on a container to confirm all actions within it - **`hx-headers`** -- set on a parent to attach auth tokens or CSRF headers to all requests within - **`hx-indicator`** -- set on a parent to share a loading indicator - **`hx-sync`** -- set on a parent to coordinate request timing for children - **`hx-config`** -- set on a parent to configure timeouts, etc. for children - **`hx-encoding`** -- set on a parent for multipart encoding across children - **`hx-validate`** -- set on a parent to enable validation for all children ### What to search for For each inheritable attribute, search for elements that have the attribute but **don't** have their own `hx-get`, `hx-post`, `hx-put`, `hx-patch`, or `hx-delete`. These are likely inheritance parents. Add `:inherited` to them: ```html
``` For `hx-boost`, it is almost always inherited -- a `hx-boost="true"` on a non-link, non-form element is always meant for its descendants. ## Step 4: Check `data-hx-*` Attributes No change is needed. In htmx 4 `htmx.config.prefix` defaults to `"data-hx-"`, and the prefix is additive: htmx checks both `hx-get` and `data-hx-get`. Leave `data-hx-*` markup as it is. Set `prefix` only if you want a third spelling: ```html ``` Setting `prefix` replaces `data-hx-`, so any `data-hx-*` markup stops working. `hx-*` always works. ## Step 5: Update Event Listeners htmx 2 uses camelCase event names. htmx 4 uses colon-separated names. **In JavaScript:** | Find | Replace | |-----------------------------|-----------------------------------| | `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` | | `htmx:afterSettle` | `htmx:after:settle` | | `htmx:beforeSend` | `htmx:before:request` | | `htmx:load` | `htmx:after:init` | | `htmx:beforeOnLoad` | `htmx:before:init` | | `htmx:afterOnLoad` | `htmx:after:init` | | `htmx:beforeProcessNode` | `htmx:before:process` | | `htmx:afterProcessNode` | `htmx:after:process` | | `htmx:beforeCleanupElement` | `htmx:before:cleanup` | | `htmx:beforeHistorySave` | `htmx:before:history:update` | | `htmx:beforeHistoryUpdate` | `htmx:before:history:update` | | `htmx:historyCacheMiss` | `htmx:before:history:restore` | | `htmx:historyRestore` | `htmx:before:history:restore` | | `htmx:pushedIntoHistory` | `htmx:after:history:push` | | `htmx:replacedInHistory` | `htmx:after:history:replace` | | `htmx:beforeTransition` | `htmx:before:viewTransition` | | `htmx:oobBeforeSwap` | `htmx:before:swap` | | `htmx:oobAfterSwap` | `htmx:after:swap` | | `htmx:responseError` | `htmx:response:error` | | `htmx:sendError` | `htmx:error` | | `htmx:sendAbort` | `htmx:error` | | `htmx:swapError` | `htmx:error` | | `htmx:targetError` | `htmx:error` | | `htmx:timeout` | `htmx:error` | **Removed events (no htmx 4 equivalent):** - `htmx:validation:validate`, `htmx:validation:failed`, `htmx:validation:halted` -- use native form validation - `htmx:xhr:loadstart`, `htmx:xhr:loadend`, `htmx:xhr:progress`, `htmx:xhr:abort` -- XHR is gone **In `hx-on:` attributes:** ```html
``` **In `hx-trigger` attributes referencing htmx events:** ```html
``` ## Step 6: Update Event Handler Code The event detail structure has changed. Key differences: ```js // htmx 2: event.detail contained XHR object document.addEventListener('htmx:configRequest', (evt) => { evt.detail.headers['X-Custom'] = 'value'; evt.detail.parameters['key'] = 'value'; evt.detail.path = '/modified-url'; }); // htmx 4: event.detail.ctx contains request context document.addEventListener('htmx:config:request', (evt) => { evt.detail.ctx.request.headers['X-Custom'] = 'value'; evt.detail.ctx.request.body.set('key', 'value'); // FormData evt.detail.ctx.request.action = '/modified-url'; }); ``` ## Step 7: Update Configuration | htmx 2 config | htmx 4 config | |--------------------------|-----------------------------------------| | `defaultSwapStyle` | `defaultSwap` | | `globalViewTransitions` | `transitions` | | `historyEnabled` | `history` | | `includeIndicatorStyles` | `includeIndicatorCSS` | | `timeout` | `defaultTimeout` (new default: 60000ms) | Removed configs (no equivalent): `refreshOnHistoryMiss`, `historyCacheSize`, `defaultSwapDelay`, `addedClass`, `settlingClass`, `swappingClass`, `allowEval`, `allowScriptTags`, `attributesToSettle`, `useTemplateFragments`, `wsReconnectDelay`, `wsBinaryType`, `disableSelector`, `withCredentials`, `scrollBehavior`, `getCacheBusterParam`, `methodsThatUseUrlParams`, `selfRequestsOnly`, `ignoreTitle`, `scrollIntoViewOnBoost`, `triggerSpecsCache`, `allowNestedOobSwaps`, `responseHandling`. ## Step 8: Update Server-Side Header Handling **Request headers your server reads:** | htmx 2 header | htmx 4 header | Format change | |-------------------|---------------|------------------------------------------------------| | `HX-Trigger` | `HX-Source` | Was element ID -> now `tag#id` (e.g. `button#submit`) | | `HX-Trigger-Name` | Removed | Use `HX-Source` | | `HX-Target` | `HX-Target` | Was element ID -> now `tag#id` | | `HX-Prompt` | Via extension | Load the `hx-prompt` extension to restore the header | New request header: `HX-Request-Type` (`"full"` or `"partial"`). **Response headers your server sends:** | htmx 2 header | htmx 4 status | |---------------------------|---------------------------| | `HX-Trigger-After-Swap` | Removed; use `HX-Trigger` | | `HX-Trigger-After-Settle` | Removed; use `HX-Trigger` | Still supported: `HX-Trigger`, `HX-Push-Url`, `HX-Replace-Url`, `HX-Redirect`, `HX-Location`, `HX-Refresh`, `HX-Retarget`, `HX-Reswap`, `HX-Reselect`. ## Step 9: Update JavaScript API Calls | htmx 2 | htmx 4 | |------------------------------|------------------------------------| | `htmx.defineExtension(...)` | `htmx.registerExtension(...)` | | `htmx.addClass(elt, cls)` | `elt.classList.add(cls)` | | `htmx.removeClass(elt, cls)` | `elt.classList.remove(cls)` | | `htmx.toggleClass(elt, cls)` | `elt.classList.toggle(cls)` | | `htmx.closest(elt, sel)` | `elt.closest(sel)` | | `htmx.remove(elt)` | `elt.remove()` | | `htmx.off(elt, evt, fn)` | `elt.removeEventListener(evt, fn)` | | `htmx.values(elt)` | `new FormData(elt)` | ## Step 10: Update Extensions Extensions need a full rewrite for htmx 4. The API changed from callback-based to event-based: ```js // htmx 2 htmx.defineExtension('my-ext', { onEvent: function (name, evt) { if (name === 'htmx:configRequest') { /* ... */ } }, transformResponse: function (text, xhr, elt) { /* ... */ } }); // htmx 4 htmx.registerExtension('my-ext', { init(api) { /* receive internal API */ }, htmx_config_request(elt, detail) { // detail.ctx has request context }, htmx_after_request(elt, detail) { // detail.ctx.text has response text } }); ``` To restrict which extensions can load, use the `extensions` config as a whitelist. It takes the registration name passed to `registerExtension()`, which is not always the file name. `hx-sse.js` registers as `sse`, `hx-preload.js` as `preload`, `htmx-2-compat.js` as `compat`: ```html ``` ## Step 11: Handle GET/DELETE Form Data Change In htmx 4, `hx-delete` (like `hx-get`) no longer includes the enclosing form's inputs. If your delete buttons relied on form data: ```html
``` ## Step 12: Handle OOB Swap Changes In htmx 2, OOB swaps happened before the main content swap. In htmx 4, main content swaps first, then OOB/partial elements swap after. If you have code that depends on OOB elements being present when the main content is swapped, you may need to restructure. Additionally, responses containing only OOB elements no longer perform an empty main swap by default. If your code relied on OOB-only responses clearing the main target, set `htmx.config.allowEmptySwapAfterOOB = true` or add `swapEmpty:true` to `hx-swap` on specific elements. ## Step 13: Handle Non-200 Response Swapping In htmx 2, 4xx and 5xx responses did not swap by default. In htmx 4, all responses swap except 204 (No Content) and 304 (Not Modified). If your server returns error HTML in 4xx/5xx responses and you don't want it swapped in, either: - Set `htmx.config.noSwap = [204, 304, "4xx", "5xx"]` to restore htmx 2 behavior - Use `hx-status:4xx="swap:none"` and `hx-status:5xx="swap:none"` on specific elements - Return 204 No Content when you want no swap to occur ## Compatibility Options For large codebases where a full migration isn't practical all at once, there are two options to ease the transition: **Config flags** -- add to your htmx config meta tag to restore htmx 2 defaults: ```html ``` - `implicitInheritance: true` restores automatic attribute inheritance (skipping Step 3) - `noSwap: [204, 304, "4xx", "5xx"]` restores htmx 2's 4xx/5xx no-swap behavior (skipping Step 13) **Compatibility extension** -- `htmx-2-compat.js` fires old event names alongside new ones and handles old attribute names: ```html ``` These are bridges for incremental migration, not long-term solutions.