# Upgrade an existing website to htmx, one page at a time
November 1, 2026. Companion to [htmx for small and medium businesses](https://jwatte.com/blog/htmx-small-business-guide/).
Use this playbook when an existing website already handles records and permissions on the server, but a few screens need better search, editing or navigation. Bring the developer responsible for that application into the work. Keep the current page available until its replacement passes the same business tests.
This is an adoption plan, not a request to rebuild the whole site. The [deploy and use kit](https://jwatte.com/downloads/htmx-small-business-kit.md) contains installation, CSP, CSRF and tested interaction examples. The [version upgrade guide](https://jwatte.com/downloads/htmx-upgrade-guide.md) covers the differences between 1.x, 2.x and 4.x.
## Inventory before choosing a page
Work from a branch. List each interactive screen, who uses it, where its data comes from, and who owns its permissions. Note full page form submissions, jQuery event handlers, React or Vue roots, widgets supplied by plugins, and analytics that assume a full navigation. Check actual runtime behavior as well as source files.
These commands identify candidates. Replace the directory names with the ones in your project. Do not search dependency directories unless you are investigating a specific dependency.
```sh
rg -n '\$\.ajax|\$\.get|\$\.post|\.load\(' templates assets public
rg -n '
Enter the contact name.
```
With 2.x, configure the selected validation status for swapping or return a deliberately handled form response. By default, 2.x skips 4xx and 5xx bodies; 4.x swaps them except 204 and 304. The deployment kit shows the tested policy. Keep `action` and `method` meaningful: a normal form post needs a full response or redirect, while the htmx request needs the intended fragment.
### 3. Click to show more becomes a replacement control
| Before | After | What stays on the server |
|---|---|---|
| JavaScript maintains a page counter and appends JSON rows | Next-page URL lives in the returned button | Cursor validation, stable ordering and next-page selection |
```html
Order 101 is ready.
```
One possible response appends rows out of band and replaces the next-page control:
```html
Order 102 is ready.
```
At the end, return a plain completion message without another button. Ensure main and OOB swaps are independent: 4.x changes their order. An ordinary pagination link is often simpler and should remain available when users need shareable page URLs.
### 4. Modal content loading becomes a fragment plus native dialog behavior
| Before | After | What stays on the server |
|---|---|---|
| Modal plugin fetches JSON and builds its body | htmx fetches content into a dialog; a small external script manages opening and closing | Access check and detail template |
```html
```
Example server fragment:
```html
Order 101 is ready for collection.
Open the full order
```
Put this handler in an external script allowed by your CSP. Use the event name for the pinned release:
```js
const isVersion4 = htmx.version.startsWith('4.');
const swapEvent = isVersion4 ? 'htmx:after:swap' : 'htmx:afterSwap';
document.addEventListener(swapEvent, event => {
const target = isVersion4 ? event.detail.ctx.target : event.target;
if (target?.id === 'order-dialog-body') {
document.getElementById('order-dialog').showModal();
}
});
```
The 4.x event is dispatched from the requesting element, so this handler reads the target from its request context. Verify this when adapting the example, especially if the response retargets the swap. Test Escape, initial focus, tab order and returning focus to the opener. Do not replace a working accessible modal library until the replacement meets its behavior. If a plain detail page serves the task better, keep that page.
### 5. Infinite scroll becomes an observed next-page trigger
| Before | After | What stays on the server |
|---|---|---|
| IntersectionObserver code calls an API and appends cards | A sentinel requests the next fragment when revealed | Pagination cursor, result ordering and next sentinel |
```html
```
For an overflow scrolling container, use the documented `intersect` trigger with the appropriate root. Keep a reachable next-page link, stable identifiers and a clear end state. Avoid automatic loading that makes the page footer unreachable or surprises someone navigating by keyboard.
### 6. Polling becomes a server controlled status fragment
| Before | After | What stays on the server |
|---|---|---|
| `setInterval` fetches JSON and updates status text | The status region requests a fragment at an agreed interval | Job ownership, status lookup and stopping condition |
```html
Preparing your export.
```
A running response retains the polling attributes. A finished response removes them:
```html
Your export is ready.
Download the export
```
Authorize both status and download requests. Choose the interval from the job's need and server capacity; two seconds is an example, not a requirement. Test multiple open tabs and network failure. The region should announce a meaningful state change rather than repeat the same message forever.
## Keep full pages and fragments consistent
Use one partial template for the repeated section. The full page includes that partial, and the fragment endpoint renders it directly. Do not maintain a second handcrafted HTML version inside JavaScript. Template escaping applies to both paths.
The following server decision is pseudocode, independent of a framework:
```text
Authenticate and authorize the requested records.
Build the template context with the same service/query code.
If HX-History-Restore-Request is true: render the full page.
Else if HX-Request-Type is full: render the full page.
Else if HX-Request is true and HX-Boosted is not true: render the partial.
Else: render the full page containing that partial.
```
For 2.x, set `historyRestoreAsHxRequest` to false when `HX-Request` selects fragments. This is the documentation's recommendation for avoiding cache confusion. For 4.x, also account for `HX-Request-Type: full`, including body-level and `hx-select` requests. A simpler alternative is a dedicated fragment URL that never appears in browser history, with normal page URLs serving full documents.
When one public URL varies on these headers, include all headers used by your decision in `Vary`, merged with existing values:
```http
Vary: HX-Request, HX-Request-Type, HX-History-Restore-Request, HX-Boosted
```
Use `Cache-Control: private, no-store` for sensitive authenticated responses where appropriate. `Vary` alone does not make a shared cache safe for customer records. Test full and fragment responses through the actual cache, in both request orders. A warm fragment cache must never turn a copied page URL into a bare table.
`HX-Request` is client supplied and can be forged. It chooses a representation, not permission to read or edit anything. Continue checking authorization, CSRF and validation inside the same server actions. Return HTML with the right content type and escape user controlled values.
## Notes for the stack you already have
| Stack | Start here | Practical implementation and caution |
|---|---|---|
| WordPress | [Official plugin AJAX handbook](https://developer.wordpress.org/plugins/javascript/ajax/) | A plugin handler can return rendered HTML. Use WordPress's generated AJAX URL, action routing, nonces and capability checks. A nonce is not permission. Do not edit core or return a JSON wrapper when htmx expects HTML. Preserve a normal page/form route for fallback. |
| Laravel | [Blade templates](https://laravel.com/framework/docs/13.x/blade) | Put the section in a Blade partial and include it in the full page. Retain route middleware, policies, validation and CSRF protection. Use escaped Blade output for user data. If Livewire already owns the region, do not let two libraries swap it. |
| Django | [Template documentation](https://docs.djangoproject.com/en/5.2/topics/templates/) | Render the same context into a full template or an included partial. Retain CSRF middleware and token handling. Use autoescaping, check object permissions and avoid marking untrusted strings safe. |
| Rails | [Layouts and rendering](https://guides.rubyonrails.org/layouts_and_rendering.html) | Render a partial for the htmx path and a layout for normal navigation. Preserve authenticity-token checks. If Turbo already intercepts the form or link, assign one library to that region and disable the other deliberately. |
| ASP.NET Core | [Partial views](https://learn.microsoft.com/en-us/aspnet/core/mvc/views/partial?view=aspnetcore-10.0) | Return a partial view for the fragment path. Retain antiforgery validation and authorization. Keep the full view able to include the same partial; verify any layout-dependent scripts are loaded only once. |
These are native framework rendering facilities, not claims of official htmx integrations. The deployment kit supplies concrete CSRF examples. Keep framework major upgrades separate from the first htmx page conversion where possible.
## Measure the same task before and after
Capture an agreed task on the original page: open it, filter results, edit one record and return. Repeat after conversion with the same data, device profile, browser version and network settings. Use the browser Network panel to record transferred bytes and request count. Record server response timing separately from browser processing.
Run Lighthouse or PageSpeed Insights for a reproducible page load if useful. Label those scores as lab measurements. They are not field Core Web Vitals and do not establish how much staff time you saved. A faster initial page can still make repeated searches feel slower if each search waits on a distant server.
| Measure | Before | After | Conditions and notes |
|---|---|---|---|
| Page transfer bytes, cold cache | Fill after measuring | Fill after measuring | Same page/data/compression |
| JavaScript transfer bytes | Fill after measuring | Fill after measuring | Include widget and extension files |
| Request count for complete task | Fill after measuring | Fill after measuring | Define task and include polling |
| Search response and update time | Fill after measuring | Fill after measuring | Test slow network and rapid typing |
| Server render/database time | Fill after measuring | Fill after measuring | Same query and dataset |
| Lighthouse performance score, lab | Fill after measuring | Fill after measuring | Tool version, device and throttling |
| Keyboard/screen reader outcome | Describe | Describe | Manual check; not a numeric score |
| Staff completion time and mistakes | Fill after observing | Fill after observing | Same task; explain small sample limits |
No page-performance numbers are supplied here because this playbook has not measured your site. The article's library byte measurements and local interaction tests answer narrower questions. Set an acceptance target with staff before collecting results, and retain failed trials instead of selecting only the fastest run.
## When to stop and keep the framework
Keep React or Vue when the page needs extensive local state, an offline workflow, an editor that responds to every pointer movement, or a tested component system your team maintains well. The [project's own fit discussion](https://htmx.org/essays/when-to-use-hypermedia/) acknowledges these tradeoffs. Removing a small widget is only worthwhile when the surrounding system becomes easier to own.
Stop if every action now needs awkward client patches, network latency makes the job worse, or maintaining the old and new models costs more than finishing the existing approach. A small local JavaScript behavior can complement htmx; that does not justify rebuilding a complete client application through hundreds of fragment endpoints.
## Rollout and rollback
1. Finish the first page on a branch. Test permissions, token failures, validation, slow requests, retries, history and normal full-page navigation.
2. Release to a limited staff group or a canary page. Use a server-side feature flag or route choice that can return users to the original page. Do not expose the testing flag as an authorization bypass.
3. Assign someone to watch errors, duplicate submissions, polling volume and staff reports. Agree the observation period and rollback triggers in advance.
4. Expand only after the canary works. Remove the old page's unused handlers and dependencies when no other page needs them; a successful new screen does not prove jQuery is unused sitewide.
5. Keep the original route, assets and templates available until the rollout is accepted. Keep database changes compatible with both paths.
To roll back, switch the route or feature flag to the known working page, restore its matching assets, invalidate only the relevant cache entries and test a normal read plus a reversible test update. Preserve the failure report. Roll back immediately for incorrect saves, unauthorized data, missing CSRF checks or unusable keyboard navigation. A small visual defect can be triaged separately; lost records cannot.
## Sources
Read November 1, 2026, 17:54 to 18:01 UTC. Conversion sketches are guidance for adaptation, not a claim that your application has been tested.
1. [htmx 2.x documentation: targets, requests, history and caching](https://htmx.org/docs/).
2. [htmx 4.x documentation](https://four.htmx.org/docs).
3. [Official 4.x migration catalog](https://four.htmx.org/docs/whats-new-in-htmx-4).
4. [Template fragments essay](https://htmx.org/essays/template-fragments/).
5. [When to use hypermedia](https://htmx.org/essays/when-to-use-hypermedia/).
6. [WordPress plugin AJAX handbook](https://developer.wordpress.org/plugins/javascript/ajax/).
7. [Laravel Blade templates](https://laravel.com/framework/docs/13.x/blade).
8. [Django template documentation](https://docs.djangoproject.com/en/5.2/topics/templates/).
9. [Rails layouts and rendering](https://guides.rubyonrails.org/layouts_and_rendering.html).
10. [ASP.NET Core partial views](https://learn.microsoft.com/en-us/aspnet/core/mvc/views/partial?view=aspnetcore-10.0).