A deep dive into HTMX
By Flavio Copes
Learn how HTMX turns HTML attributes into requests and DOM swaps, with forms, partials, events, history, security, debugging, and real Astro projects.
HTMX lets HTML elements make HTTP requests and replace parts of the page with HTML returned by the server.
That sounds like a small trick. It changes how you build an application.
Instead of turning your backend into a JSON API and rebuilding its data as HTML in the browser, the server returns the interface directly. The browser already knows how to parse HTML. HTMX decides when to request it and where to put it.
Here is a complete interaction:
<button
hx-get="/notifications"
hx-target="#notifications">
Refresh notifications
</button>
<div id="notifications"></div>
Clicking the button sends GET /notifications. The server returns an HTML fragment. HTMX places that fragment inside #notifications.
No component state. No client-side router. No JSON response. No code that translates objects into DOM nodes.
This is why HTMX is compelling. It gives ordinary HTML more power without replacing the web’s request-and-response model.
I use it as part of the AHA stack: Astro, HTMX, and Alpine.js. Astro renders HTML on the server. HTMX moves HTML over the wire. Alpine.js handles small pieces of browser-only state.
I have also used HTMX in larger interfaces: a task workspace, a multi-step AI audit, a PocketBase-style admin panel, and a production waiting-list form. We’ll use patterns from those projects throughout this tutorial.
Which HTMX version this tutorial uses
This tutorial uses stable HTMX 2.0.10.
HTMX 4 is currently in beta. It changes APIs and naming, so do not mix its documentation with an HTMX 2 application unless you are deliberately testing the beta.
The stable 2.x line dropped Internet Explorer support. HTMX 1.x remains available for projects that still require IE11.
The idea behind HTMX
HTML already has two elements that understand HTTP.
An anchor makes a GET request:
<a href="/projects">Projects</a>
A form makes a GET or POST request:
<form method="post" action="/projects">
<input name="name" required />
<button>Create project</button>
</form>
Both requests replace the whole page.
HTMX expands those rules:
- any element can make a request
- any browser event can trigger it
GET,POST,PUT,PATCH, andDELETEare available- any element can receive the response
- the response can be inserted in several ways
It does not invent a second application model. It extends hypertext.
This is the basic flow:
flowchart LR
A[User action] --> B[Element with hx attributes]
B --> C[HTTP request]
C --> D[Server renders HTML]
D --> E[HTML fragment]
E --> F[DOM swap]
The server remains responsible for application state. The HTML response contains the next interface state and the controls available from there.
HTML over the wire
Many frontend applications use JSON over the wire.
The server returns data:
{
"projects": [
{ "id": 1, "name": "SoloCamp" },
{ "id": 2, "name": "AI Counselor" }
]
}
JavaScript then turns that data into HTML.
With HTMX, the server can return the finished fragment:
<ul id="projects">
<li><a href="/projects/1">SoloCamp</a></li>
<li><a href="/projects/2">AI Counselor</a></li>
</ul>
The server already knows the permissions, labels, links, and presentation rules. Returning HTML avoids duplicating those decisions in a separate client renderer.
This does not mean JSON is bad. JSON is the correct representation for a public API, a mobile client, or several consumers with different interfaces.
HTMX works best when the browser interface is the main consumer and the server can render it well.
Why I like HTMX
HTMX reduces the distance between an interaction and its implementation.
Consider this button:
<button
hx-delete="/tasks/42"
hx-target="closest li"
hx-swap="outerHTML">
Delete
</button>
You can read the behavior where it happens:
- Send
DELETE /tasks/42. - Find the closest list item.
- Replace that element with the response.
This is called locality of behavior. You do not need to search for an event listener, state store, API function, and component update to understand one interaction.
The backend also becomes easier to follow. A route receives input, applies the change, and returns the HTML that should appear next.
HTMX is small, dependency-free, and backend-agnostic. It does not care whether the fragment came from Astro, Hono, Rails, Laravel, Django, Go, or a plain server function.
Install HTMX
The fastest option is the official CDN snippet:
<script
src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.10/dist/htmx.min.js"
integrity="sha384-H5SrcfygHmAuTDZphMHqBJLc3FhssKjG7w/CeCpFReSfwBWDTKpkzPP8c+cLsK+V"
crossorigin="anonymous">
</script>
For a production application, I prefer installing it locally. The application then controls the file and does not depend on a third-party CDN at runtime:
npm install htmx.org@2.0.10
Import it from your browser entry point:
import htmx from 'htmx.org'
window.htmx = htmx
In Astro, I put this in the client script loaded by the shared application layout.
Your first complete HTMX interaction
Let’s build a project list with Astro.
The page contains a button and a target:
<button
hx-get="/partials/projects"
hx-target="#projects">
Load projects
</button>
<div id="projects"></div>
Create src/pages/partials/projects.astro:
---
export const partial = true
const projects = [
{ id: 1, name: 'SoloCamp' },
{ id: 2, name: 'AI Counselor' },
]
---
<ul>
{projects.map((project) => (
<li>
<a href={`/projects/${project.id}`}>
{project.name}
</a>
</li>
))}
</ul>
Astro’s partial export tells the renderer not to add the normal document shell. The route returns only the fragment.
The interaction has three independent pieces:
hx-getchooses the requesthx-targetchooses the destination- the server chooses the HTML
That separation is the foundation of HTMX.
The request lifecycle
When the event fires, HTMX gathers values, adds request headers, starts an asynchronous request, parses the response, selects the target, performs the swap, and settles the new DOM.
sequenceDiagram
participant User
participant Element
participant HTMX
participant Server
participant DOM
User->>Element: click, submit, or another event
Element->>HTMX: trigger request
HTMX->>HTMX: collect values and headers
HTMX->>Server: XMLHttpRequest
Server-->>HTMX: HTML and response headers
HTMX->>DOM: select target
HTMX->>DOM: swap HTML
HTMX->>DOM: settle new content
HTMX adds classes during this process:
htmx-requestwhile the request is runninghtmx-swappingbefore the old content is replacedhtmx-addedon newly inserted contenthtmx-settlingduring the settle phase
You can use those classes for loading states and CSS transitions.
HTMX also emits events before and after every important step. We’ll use those later.
Make requests from HTML
The five main request attributes are:
| Attribute | Request |
|---|---|
hx-get | GET |
hx-post | POST |
hx-put | PUT |
hx-patch | PATCH |
hx-delete | DELETE |
Each one takes a URL:
<button hx-post="/projects/42/archive">
Archive project
</button>
By default, the response replaces the inner HTML of the element that made the request.
In a real application, you will usually choose a different target.
Choose the target
Use hx-target to choose which element receives the response:
<button
hx-get="/projects/42"
hx-target="#project-details">
Show details
</button>
<div id="project-details"></div>
The value can be a CSS selector. HTMX also supports useful relative selectors:
thistargets the element carrying the attributeclosest lifinds the nearest matching ancestorfind .resultfinds a matching descendantnext .resultscans forward for a matchprevious .resultscans backward for a match
Relative targets make reusable fragments possible:
<li>
<span>Write launch email</span>
<button
hx-delete="/tasks/42"
hx-target="closest li"
hx-swap="outerHTML">
Delete
</button>
</li>
The fragment does not need a unique target ID.
Choose the swap
Use hx-swap to control how the response relates to the target.
The common values are:
| Value | Result |
|---|---|
innerHTML | Replace the target’s contents |
outerHTML | Replace the target itself |
beforebegin | Insert before the target |
afterbegin | Insert as the first child |
beforeend | Insert as the last child |
afterend | Insert after the target |
delete | Delete the target |
none | Do not perform the normal swap |
innerHTML is the default.
Use outerHTML when the server returns the complete next version of a component:
<article
id="task-42"
hx-get="/tasks/42"
hx-trigger="task-changed from:body"
hx-swap="outerHTML">
<!-- current task -->
</article>
Use beforeend for an append-only list:
<button
hx-get="/activity?page=2"
hx-target="#activity"
hx-swap="beforeend">
Load more
</button>
Use none when response headers or custom events will decide what happens next. The body is not swapped, but HTMX still processes out-of-band fragments and response headers.
Trigger the request
HTMX chooses a natural default event:
- inputs, textareas, and selects use
change - forms use
submit - other elements use
click
Change it with hx-trigger:
<div
hx-get="/notifications"
hx-trigger="load">
</div>
The special load event fires when HTMX processes the element.
Live search
A live search should wait until the user pauses:
<input
type="search"
name="q"
hx-get="/search"
hx-target="#results"
hx-trigger="keyup changed delay:300ms"
placeholder="Search projects"
/>
<div id="results"></div>
changed skips requests when the value has not changed. delay:300ms resets its timer after each matching event.
throttle:300ms behaves differently. It ignores extra events during the interval instead of resetting the timer.
Polling
Use every for polling:
<div
hx-get="/report/status"
hx-trigger="every 2s"
hx-swap="outerHTML">
Building report…
</div>
The server can return status 286 to stop polling.
I often prefer load polling for finite work. The returned fragment decides whether another request should happen:
<section
id="report-status"
hx-get="/report/status"
hx-trigger="load delay:2s"
hx-swap="outerHTML">
Building report…
</section>
While the work continues, the server returns the same structure. When the work finishes, it returns a final fragment without hx-trigger.
That is how AI Counselor waits for a generated report.
Submit forms
Put hx-post on the form:
<form
method="post"
action="/projects"
hx-post="/projects"
hx-target="#project-list">
<label>
Project name
<input name="name" required />
</label>
<button>Create project</button>
</form>
HTMX collects the successful form controls and sends them using normal form encoding.
Keep method and action when the interaction can work without JavaScript. HTMX enhances the form, while the browser retains a normal fallback.
The server must validate every value. Browser validation improves the experience, but a client can bypass it.
Return validation errors
By default, HTMX does not swap 4xx and 5xx response bodies.
You have several choices:
- return the error fragment with status
200 - configure response handling for
422 - handle
htmx:beforeSwap - use the response-targets extension
In AI Counselor, I return a safe error fragment with status 200 and let headers choose its destination:
return new Response(
'<p class="form-error" role="alert">Email is required</p>',
{
status: 200,
headers: {
'Content-Type': 'text/html; charset=utf-8',
'HX-Retarget': '#form-error',
'HX-Reswap': 'innerHTML',
},
},
)
The form does not need to know every possible error target. The server decides.
Understand HTMX request headers
HTMX adds headers that help the server understand the request.
The most useful is:
HX-Request: true
Other headers include:
HX-BoostedHX-Current-URLHX-History-Restore-RequestHX-TargetHX-TriggerHX-Trigger-Name
You can use HX-Request to return a fragment for HTMX and a full document for normal navigation:
const isHtmx = request.headers.get('HX-Request') === 'true'
if (isHtmx) {
return new Response(renderProjectList(projects), {
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Vary': 'HX-Request',
},
})
}
return new Response(renderFullPage(projects), {
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Vary': 'HX-Request',
},
})
The Vary: HX-Request header matters when the same URL can return two representations. Without it, a cache might serve a fragment as a full page or a full page as a fragment.
Control the client from response headers
The server can change HTMX behavior with response headers.
Useful headers include:
| Header | Purpose |
|---|---|
HX-Redirect | Perform a full browser redirect |
HX-Location | Navigate with an HTMX request |
HX-Push-Url | Push a history entry |
HX-Replace-Url | Replace the current history URL |
HX-Retarget | Change the response target |
HX-Reswap | Change the swap strategy |
HX-Reselect | Select part of the response |
HX-Trigger | Dispatch a client-side event |
HX-Refresh | Reload the full page |
Do not put these headers on a normal 302 response and expect HTMX to see them. The browser follows the redirect internally. Return a response HTMX can process, often 200 or 204, with the HTMX header.
For example:
return new Response(null, {
status: 204,
headers: {
'HX-Redirect': '/projects',
},
})
Let server events connect page regions
HX-Trigger is one of my favorite HTMX features.
Suppose a form creates a record. The server can announce that the records changed:
return new Response(html, {
headers: {
'HX-Trigger': 'records-changed',
},
})
Another region listens for that event:
<section
hx-get="/partials/records"
hx-trigger="load, records-changed from:body">
Loading records…
</section>
The form does not need to target the list directly. It announces a domain event. Any region interested in that event can refresh itself.
I use this pattern in Backpack. Creating, updating, deleting, duplicating, or truncating records returns HX-Trigger: records-changed. The records panel listens and reloads.
flowchart LR
A[Mutation form] --> B[Server changes record]
B --> C[HX-Trigger response header]
C --> D[Custom event on body]
D --> E[Records panel reloads]
This keeps page regions loosely connected.
Swap more than one element
A request has one normal target. Sometimes the response needs to update another region too.
Use an out-of-band swap:
<li id="task-42">Updated task</li>
<span
id="open-task-count"
hx-swap-oob="true">
7
</span>
The first element goes to the normal target. HTMX finds open-task-count elsewhere in the page and replaces it separately.
Out-of-band swaps are useful for counters, flash messages, navigation badges, and summary panels.
Do not return ten unrelated out-of-band fragments from every request. That makes each endpoint responsible for too much of the page. A custom event and independent reload can be clearer.
Select part of a response
hx-select chooses a fragment from the returned HTML:
<a
href="/projects"
hx-get="/projects"
hx-select="#project-list"
hx-target="#project-list">
Refresh
</a>
The server may return a full page. HTMX extracts only #project-list.
This is useful when one route should support normal navigation and partial updates without a separate fragment endpoint.
The response header HX-Reselect can override the selector from the server.
Attribute inheritance
Many HTMX attributes are inherited.
You can put a common target on a parent:
<section hx-target="#workspace">
<button hx-get="/projects">Projects</button>
<button hx-get="/tasks">Tasks</button>
</section>
<main id="workspace"></main>
Both buttons inherit hx-target.
Inheritance can remove repetition. It can also hide behavior when the attribute is far away from the element making the request.
My advice is to inherit broad page-level behavior and keep surprising behavior local.
Use hx-disinherit when a child should not inherit a specific attribute.
Prevent request races
Fast interactions can create overlapping requests.
A live search for astro might finish before the earlier search for ast, or after it. Without coordination, an old response can replace a newer one.
HTMX offers queue modifiers on hx-trigger and the more general hx-sync attribute.
This search drops an old request when a new one starts:
<input
name="q"
hx-get="/search"
hx-trigger="keyup changed delay:300ms"
hx-target="#results"
hx-sync="this:replace"
/>
A form can abort an in-flight field validation request:
<form hx-post="/projects">
<input
name="name"
hx-post="/projects/validate-name"
hx-trigger="change"
hx-sync="closest form:abort"
/>
<button>Create</button>
</form>
Think about request ordering anywhere users can click quickly, type quickly, or submit while validation is still running.
Loading states and disabled controls
HTMX includes a default indicator class:
<button
hx-post="/reports"
hx-indicator="#report-spinner"
hx-disabled-elt="this">
Generate report
</button>
<span
id="report-spinner"
class="htmx-indicator">
Generating…
</span>
hx-disabled-elt="this" disables the button during the request. This helps prevent duplicate submissions.
You can also style the request class:
form.htmx-request {
opacity: 0.65;
pointer-events: none;
}
Keep loading text visible to assistive technology when it communicates meaningful progress. Use aria-live="polite" for results that should be announced.
Boost links and forms
hx-boost turns normal links and forms into HTMX requests:
<main hx-boost="true">
<a href="/projects">Projects</a>
<a href="/calendar">Calendar</a>
</main>
Boosted links request their normal href, target the body by default, and push the URL into browser history.
If HTMX is unavailable, the links still navigate normally. This makes boosting a good progressive-enhancement starting point.
Be careful before boosting an entire application. Full-body swaps affect focus, scripts, third-party widgets, and persistent interface state. A smaller stable target is often easier to reason about.
Browser history
Use hx-push-url="true" when a partial update represents a location users should bookmark or revisit:
<a
href="/projects/42"
hx-get="/projects/42"
hx-target="#workspace"
hx-push-url="true">
Open project
</a>
HTMX stores a snapshot in its history cache and updates the URL.
Every pushed URL must also return a complete page during normal navigation. A user can paste it into a new tab, and HTMX may request it during a history-cache miss.
History is one of the places where fragment-only architecture can become fragile. Test back, forward, reload, copy-and-paste, and cache misses.
For sensitive pages, use hx-history="false" to prevent their HTML from entering the localStorage history cache.
Use HTMX events
HTMX emits events throughout the request lifecycle.
Common events include:
htmx:configRequesthtmx:beforeRequesthtmx:beforeSwaphtmx:afterSwaphtmx:afterSettlehtmx:afterRequesthtmx:responseErrorhtmx:sendError
Add an authorization header before a request:
document.body.addEventListener(
'htmx:configRequest',
(event) => {
const token = localStorage.getItem('admin_token')
if (token) {
event.detail.headers.Authorization = token
}
},
)
Backpack uses this pattern for its admin requests.
Initialize behavior after a swap:
document.body.addEventListener(
'htmx:afterSwap',
(event) => {
initializeFilePreviews(event.target)
},
)
This matters when a JavaScript library scans the page only on initial load. Swapped elements did not exist then.
Use events to connect HTMX with focused client behavior. If every interaction needs several global listeners, you may be rebuilding a client framework around HTMX.
HTMX and Alpine.js
HTMX and Alpine solve different problems.
HTMX is good at server interactions:
- submit a form
- load a fragment
- delete a record
- refresh a list
- poll a job
Alpine is good at local interface state:
- open or close a menu
- switch tabs without a request
- manage a modal
- count characters
- drag an item before saving its new position
AI Counselor combines both on one form:
<form
hx-post="/audit/answer"
hx-swap="none"
x-data="{ loading: false }"
@htmx:before-request="loading = true"
@htmx:after-request="loading = false">
<textarea name="answer" required></textarea>
<button :disabled="loading">
Save and continue
</button>
</form>
HTMX sends the answer. Alpine controls the immediate loading state.
Keep Alpine state inside elements that survive the swap, or deliberately recreate it. Replacing an Alpine root with outerHTML destroys its old local state.
Security
HTMX does not remove normal web security responsibilities.
Escape untrusted content
The server sends HTML that HTMX inserts into the page. Escape every untrusted value before adding it to that HTML.
If an attacker can inject HTML, HTMX attributes make the injected markup more expressive. Avoid raw rendering. If raw user HTML is unavoidable, sanitize allowed tags and attributes.
Wrap untrusted HTML in hx-disable to stop HTMX from processing attributes inside it:
<div hx-disable>
<!-- sanitized user content -->
</div>
This is an extra layer, not a replacement for sanitization.
Protect state-changing requests from CSRF
Cookie-authenticated POST, PUT, PATCH, and DELETE routes need CSRF protection.
You can place a token header on a common ancestor:
<body hx-headers='{"X-CSRF-Token":"TOKEN_FROM_SERVER"}'>
The server must verify it.
Keep requests on allowed origins
HTMX defaults to same-origin requests. Keep a restrictive Content Security Policy and validate any deliberate cross-origin behavior.
You can use htmx:validateUrl to reject unexpected destinations before a request leaves the browser.
Protect history data
HTMX can save page snapshots to localStorage. Mark sensitive pages with hx-history="false", or disable the history cache globally when appropriate.
Accessibility
HTMX applications are still HTML applications. Start with semantic elements, labels, keyboard access, visible focus, and useful headings.
Partial swaps create a few extra responsibilities:
- announce important results with
aria-live - move focus after opening a dialog or replacing a form
- keep focus visible after a swap
- avoid replacing large regions when a small update works
- preserve normal
href,method, andactionfallbacks where possible
Test the application with a keyboard. Then test it with a screen reader. A fast swap is not automatically an understandable interaction.
How I use HTMX in real projects
The most useful HTMX patterns become clearer in complete applications.
Waiting-list signup
On waitinglists.dev, the signup form posts to the subscription endpoint and swaps the result into one status region:
<form
method="post"
action="/api/subscribe/waitinglists-dev"
hx-post="/api/subscribe/waitinglists-dev"
hx-target="#signup-result"
hx-swap="innerHTML"
hx-disabled-elt="find button">
<input type="email" name="email" required />
<button>Save my spot</button>
</form>
<div
id="signup-result"
role="status"
aria-live="polite">
</div>
This is an ideal HTMX interaction. It has one form, one server action, one small response, and a normal HTML fallback.
SoloCamp task mutations
SoloCamp task forms use hx-post with hx-swap="none".
The server performs the mutation and returns an HX-Trigger event describing which workspace should refresh. A shared client handler then uses htmx.ajax() to fetch the current server-rendered region.
This approach works well when several actions change the same larger workspace. Creating a task, toggling it, deleting it, or adding a list can all share one refresh path.
I also use hx-sync="this:queue" on the workspace to serialize rapid operations.
The tradeoff is visible: this is more infrastructure than swapping a returned row. I use it because the server remains the authority for task grouping, counts, navigation, and related panels.
AI Counselor’s multi-step audit
AI Counselor submits answers with HTMX, uses Alpine for loading state, returns targeted HTML errors, and redirects with HX-Redirect after successful transitions.
The generated report uses load polling. While work continues, the server returns another polling fragment. When the report becomes ready, it returns a fragment that moves the browser to the completed report.
This keeps orchestration on the server. The browser only knows how to submit the current answer and render the next state.
Backpack’s admin interface
Backpack loads record panels with hx-trigger="load". Mutation routes return rendered tables and domain events such as records-changed.
The client uses htmx:configRequest to attach an admin token and htmx:afterSwap to initialize focused JavaScript behavior on new content.
This is a good example of HTMX supporting a dense interface without pretending JavaScript is unnecessary. HTMX handles server-rendered regions. JavaScript handles file previews, bulk selection, local filtering, tabs, and modal details.
How I would choose HTMX now
I would use HTMX for an application where the server already owns most state and renders HTML well.
Good examples include:
- internal tools
- admin panels
- CRUD applications
- search and filtering interfaces
- settings pages
- forms and multi-step workflows
- background-job status pages
- dashboards whose data changes through user actions
I would begin with ordinary links and forms. Then I would add HTMX only where a full-page navigation feels unnecessarily heavy.
I would return complete fragments from small endpoints, use response headers for redirects and events, and keep a clear rule for full-page versus partial responses.
I would add Alpine only for local state. I would add custom JavaScript only when the browser behavior is clearer in JavaScript than in more HTMX attributes.
When HTMX is a poor fit
HTMX is not the best tool for every frontend.
I would hesitate when the interface has large amounts of unsaved client state, complex offline behavior, a canvas-like editor, real-time collaborative state, or many interactions that should not contact the server.
A spreadsheet, design tool, video editor, or multiplayer game needs a richer client model.
HTMX can call WebSocket and Server-Sent Events extensions, but the presence of real-time messages does not automatically make a hypermedia architecture the best fit.
I would also avoid HTMX when the same backend must primarily serve native apps and external clients. A stable JSON API may be the stronger center of the system.
Common mistakes
Returning JSON
HTMX expects HTML for normal swaps. If the endpoint returns JSON, you still need something to turn it into a user interface.
Returning a full page into a small target
Create a fragment route or use hx-select to choose the useful region.
Forgetting relative target scope
Check whether closest, find, next, or previous resolves from the element carrying the attribute.
Expecting error bodies to swap automatically
HTMX does not swap 4xx and 5xx bodies by default. Decide on a consistent validation-error strategy.
Losing behavior after a swap
New HTML needs its own attributes. Third-party JavaScript may need initialization through htmx:load or htmx:afterSwap.
Creating request races
Use trigger delays, queues, or hx-sync when requests can overlap.
Ignoring caching
If one URL returns a full page and a fragment based on HX-Request, send Vary: HX-Request.
Treating every interaction as a server request
Menus, tabs, temporary disclosure state, and drag feedback often belong in CSS, Alpine, or plain JavaScript.
Replacing too much HTML
A body swap is simple until focus, media, local state, and third-party widgets matter. Prefer the smallest stable region that represents the server state you changed.
Debug HTMX
Start with the browser’s Network panel.
Check:
- Did the expected event fire?
- Did HTMX send the expected URL and method?
- Were the form values included?
- What status and headers came back?
- Is the response valid HTML?
- Does the target selector find an element?
- Does the swap strategy match the returned wrapper?
Enable HTMX logging in the console:
htmx.logAll()
You can also inspect lifecycle events:
document.body.addEventListener(
'htmx:responseError',
(event) => {
console.error(event.detail.xhr.status)
},
)
Most HTMX bugs are a mismatch among the response shape, target, and swap.
flowchart TD
A[Unexpected UI] --> B{Request sent?}
B -- No --> C[Check trigger and validation]
B -- Yes --> D{Response correct?}
D -- No --> E[Check route, status, and HTML]
D -- Yes --> F{Target found?}
F -- No --> G[Fix selector or page structure]
F -- Yes --> H[Check swap and lifecycle events]
Test HTMX applications
Test the server routes first.
Given a form request, verify the mutation, response status, response headers, escaped content, and returned fragment. These tests are fast and cover most application logic.
Then add browser tests for the contract between HTML and HTMX:
- click the real element
- confirm the request happens
- wait for the target to change
- test loading and disabled states
- test validation errors
- test back and forward navigation
- test keyboard focus
- test a slow response and rapid repeated input
Do not mock every HTMX event. A small number of end-to-end tests catches selector and swap mistakes that route tests cannot see.
HTMX cheat sheet
| Need | HTMX feature |
|---|---|
| Send a request | hx-get, hx-post, hx-put, hx-patch, hx-delete |
| Choose when | hx-trigger |
| Choose where | hx-target |
| Choose how | hx-swap |
| Choose response content | hx-select |
| Add form values | hx-include, hx-vals |
| Disable while waiting | hx-disabled-elt |
| Show progress | hx-indicator |
| Coordinate requests | hx-sync |
| Confirm an action | hx-confirm |
| Update another region | hx-swap-oob |
| Enhance links and forms | hx-boost |
| Update browser history | hx-push-url |
| Dispatch a server event | HX-Trigger response header |
| Redirect from the server | HX-Redirect response header |
| Debug everything | htmx.logAll() |
Final thoughts
HTMX is not interesting because it removes JavaScript syntax.
It is interesting because it makes hypertext the application architecture again.
The browser sends a request. The server changes state and renders the next interface. HTMX swaps that HTML into a precise part of the page.
That model is easy to debug because you can inspect every request and response. It is easy to extend because links, forms, HTTP, and HTML remain visible. It is easy to overcomplicate if fragments, global events, and client scripts lose clear ownership.
Start with one form.
Give it hx-post, choose a small target, and return one honest HTML fragment. Once that feels natural, add server events, history, out-of-band swaps, and polling only when the interface needs them.
The goal is not to use every HTMX feature.
The goal is to let the server and the browser do the jobs they already understand.