HTMX: Server-Rendered Interactivity with hx-get, hx-swap, Triggers and Out-of-Band Swaps
Key takeaways
HTMX brings AJAX, WebSockets, and server-sent events directly to HTML attributes — no build step, no framework. This guide covers the full HTMX toolkit with practical examples.
HTMX lets you build dynamic web applications using HTML attributes instead of JavaScript. Your server returns HTML fragments; HTMX swaps them into the page. No build step, no npm, no framework.
The idea behind it is older than SPAs: the server owns application state and sends hypermedia (HTML) that already encodes what the user can do next. In a typical React setup, the server serializes data to JSON, the client re-implements the view logic, and both sides must agree on the shape of that JSON. With HTMX, the template you already have on the server renders the fragment, and the browser just inserts it. For CRUD screens, admin panels, search boxes, and dashboards, that removes an entire layer — the client-side state store, the API schema, and the duplicated validation.
The trade-off is that every interaction is a network round trip. Anything that must respond instantly without the server — drag-and-drop reordering with live previews, a rich text editor, a canvas drawing tool, offline mode — is either awkward or impossible in pure HTMX. The usual answer is to combine HTMX with a small amount of local JavaScript (Alpine.js or plain event listeners) for the few widgets that need it, rather than choosing one approach for the whole app. Where I have seen HTMX projects struggle is when a screen gradually accumulates client-only state — multi-step wizards with undo, complex filters that must survive navigation — and the team keeps pushing it through attributes instead of admitting that component needs real client code.
Installation
No build step needed — just add the script tag:
<script src="https://unpkg.com/[email protected]"></script>
Or install via npm for bundled projects:
npm install htmx.org
Pin an exact version in the URL (as above) rather than htmx.org@2, and in production prefer self-hosting the file or adding an integrity hash: a CDN script tag without a pinned version will pick up new releases on its own, and htmx 2 changed several defaults from 1.x (for example, DELETE requests now send parameters in the query string, and cross-domain requests are disabled by default). Tutorials written for 1.x still dominate search results, so when an attribute seems to do nothing, check which major version the example targeted.
Core Attributes
HTMX adds behavior to HTML via hx-* attributes:
| Attribute | Description |
|---|---|
hx-get | Send GET request |
hx-post | Send POST request |
hx-put | Send PUT request |
hx-delete | Send DELETE request |
hx-trigger | When to fire (default: natural event) |
hx-target | Where to put the response |
hx-swap | How to insert the response |
Basic Example: Load Content
<!-- Click button → GET /api/users → swap into #user-list -->
<button hx-get="/api/users" hx-target="#user-list" hx-swap="innerHTML">
Load Users
</button>
<div id="user-list">Users will appear here</div>
Server returns an HTML fragment (not a full page):
# FastAPI example
@app.get("/api/users")
def get_users():
return HTMLResponse("""
<ul>
<li>Alice</li>
<li>Bob</li>
</ul>
""")
The request HTMX sends is an ordinary fetch-style XHR with a few extra headers, most importantly HX-Request: true, plus HX-Target and HX-Trigger identifying the elements involved. Servers use HX-Request to decide between returning a fragment and a full page from the same URL — useful when a user bookmarks or reloads a URL that was originally fetched by HTMX. If you cache these responses behind a CDN, add Vary: HX-Request, otherwise a cached fragment can be served as the full page (or the reverse) to the next visitor.
Swap Strategies
hx-swap controls how the response replaces content:
| Value | Effect |
|---|---|
innerHTML | Replace inner content (default) |
outerHTML | Replace the element itself |
beforebegin | Insert before the element |
afterbegin | Insert at the start of the element |
beforeend | Append at the end of the element |
afterend | Insert after the element |
delete | Remove the element |
none | Do nothing with the response |
<!-- Append a new item to a list -->
<form hx-post="/api/todos" hx-target="#todo-list" hx-swap="beforeend">
<input name="text" placeholder="New todo" />
<button type="submit">Add</button>
</form>
<ul id="todo-list"></ul>
The choice between innerHTML and outerHTML matters more than it looks. With innerHTML, the target element survives and keeps its own hx-* attributes, so a polling <div hx-get=... hx-trigger="every 5s"> keeps polling. With outerHTML, the element is replaced by whatever the server returns — if the response omits the hx-* attributes, the behavior silently disappears after the first swap. This is the most common “it worked once and then stopped” bug in HTMX code.
After a swap, HTMX processes the new content, so hx-* attributes inside the fragment work immediately. Third-party widgets initialized with JavaScript (date pickers, charts) do not re-initialize themselves; hook htmx:afterSwap or htmx.onLoad(fn) to run their setup on newly inserted nodes. Note also that the form above does not clear its input after a successful submit — either return a fresh form via an out-of-band swap, or reset it in an hx-on::after-request handler.
Triggers
By default, HTMX fires on the natural event (click for buttons, change for inputs). Use hx-trigger to customize:
<!-- Fire on input with 300ms debounce -->
<input
hx-get="/api/search"
hx-trigger="input delay:300ms"
hx-target="#results"
name="q"
placeholder="Search..."
/>
<!-- Fire every 5 seconds (polling) -->
<div hx-get="/api/stats" hx-trigger="every 5s" hx-target="this">
Loading stats...
</div>
<!-- Fire when element enters the viewport -->
<div hx-get="/api/more" hx-trigger="intersect once" hx-swap="afterend">
Loading more...
</div>
delay:300ms is a debounce: each new input event restarts the timer, so the request fires only after the user pauses. That alone does not stop an older, slower request from arriving after a newer one and overwriting fresh results with stale ones. Add hx-sync="this:replace" to abort the in-flight request when a new one starts. For search boxes, changed is also worth adding (hx-trigger="input changed delay:300ms") so arrow keys and other non-editing keystrokes do not trigger a request.
Polling with every 5s is simple but multiplies load by the number of open tabs. It keeps running in background tabs as well. For frequently updated data, server-sent events or WebSockets are usually the better fit; for rarely changing data, a longer interval or having the server respond with status 286 (which tells HTMX to stop polling) keeps the cost down.
Forms
HTMX serializes forms automatically:
<form hx-post="/api/login" hx-target="#result">
<input name="email" type="email" />
<input name="password" type="password" />
<button type="submit">Login</button>
</form>
<div id="result"></div>
Server validates and returns either a success fragment or an error fragment:
@app.post("/api/login")
def login(email: str = Form(), password: str = Form()):
if valid(email, password):
return HTMLResponse('<div class="success">Welcome!</div>')
return HTMLResponse('<div class="error">Invalid credentials</div>', status_code=401)
As the FAQ at the end explains, HTMX does not swap 4xx responses by default, so as written the error fragment is silently discarded. Many teams return validation errors with status 200 (or 422 plus a responseHandling rule) for exactly this reason. Two other things to watch: HTMX sends cookies with the request like any same-origin request, so Django’s CSRF check still applies — set hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' on <body> or include the token in the form — and if the login succeeds you usually want a full navigation, which is what the HX-Redirect response header is for.
Infinite Scroll
<div id="posts">
{% for post in posts %}
<article>{{ post.title }}</article>
{% endfor %}
<!-- Trigger when this element enters the viewport; the response replaces it -->
<div
hx-get="/api/posts?page={{ next_page }}"
hx-trigger="intersect once"
hx-swap="outerHTML"
>
<p>Loading more...</p>
</div>
</div>
The server response includes the next batch of items AND a new trigger element with page={{ next_page + 1 }}. Because the sentinel div targets itself (the default target) with outerHTML, it is replaced by the new articles plus a fresh sentinel, so there is always exactly one “Loading more…” element at the bottom. Appending to #posts with beforeend instead leaves every old sentinel in place — once stops them from firing again, but their “Loading more…” text accumulates down the page. On the last page, the server simply returns the items without a new sentinel and scrolling stops.
Cursor-based pagination (?after=<last_id>) is safer than page numbers here: if new posts are inserted while the user scrolls, page=3 shifts and shows duplicates.
Loading States with hx-indicator
<style>
.htmx-indicator { display: none; }
.htmx-request .htmx-indicator { display: block; }
.htmx-request.htmx-indicator { display: block; }
</style>
<button hx-get="/api/slow-data" hx-target="#output" hx-indicator="#spinner">
Load Data
</button>
<span id="spinner" class="htmx-indicator">Loading...</span>
<div id="output"></div>
During a request HTMX adds the htmx-request class. Without hx-indicator it goes on the element that issued the request, so the descendant selector .htmx-request .htmx-indicator matches a spinner nested inside the button. With hx-indicator="#spinner", the class is added to the spinner element itself, which only the compound selector .htmx-request.htmx-indicator matches — leaving that second rule out is why an external spinner never appears. HTMX also injects its own default indicator styles (an opacity transition); set htmx.config.includeIndicatorStyles = false if they fight with your CSS. To stop double submissions while a request is in flight, add hx-disabled-elt="this" to the button.
Out-of-Band Swaps
Update multiple parts of the page from a single response using hx-swap-oob. HTMX first pulls every top-level element carrying hx-swap-oob out of the response and swaps each into the page element with the same id; whatever remains is swapped into the normal target:
@app.post("/api/add-item")
def add_item():
return HTMLResponse("""
<!-- Main response: the new item -->
<li>New Item</li>
<!-- OOB: also update the counter -->
<span id="item-count" hx-swap-oob="true">5 items</span>
""")
hx-swap-oob="true" means outerHTML on the element with the matching id; you can also give a strategy (hx-swap-oob="beforeend:#notifications"). If no element with that id exists on the page, the OOB content is dropped with only an htmx:oobErrorNoTarget event, so typos in ids fail quietly. A known gotcha involves table rows: a bare <tr hx-swap-oob="true"> at the top level of a response gets mangled by the HTML parser, because a <tr> outside a <table> is not valid. Wrap such fragments in a <template> tag, which htmx 2 unwraps for exactly this purpose.
OOB swaps are the HTMX answer to “the cart badge must update when I add an item”. The alternative is the HX-Trigger response header plus an element that listens for the event and refetches (hx-trigger="itemAdded from:body"). OOB saves a request; events keep endpoints decoupled, so the add-item handler does not need to know how the cart badge is rendered.
WebSockets
<!-- htmx 2: WebSocket support lives in the ws extension -->
<script src="https://unpkg.com/[email protected]/ws.js"></script>
<div hx-ext="ws" ws-connect="/ws/chat">
<div id="messages"></div>
<form ws-send>
<input name="message" placeholder="Type a message..." />
<button type="submit">Send</button>
</form>
</div>
Server sends HTML fragments over the WebSocket connection; HTMX swaps them in automatically. Each incoming message is treated like an out-of-band response: an element such as <div id="messages" hx-swap-oob="beforeend"><p>Hi</p></div> is appended to #messages. ws-send serializes the form to JSON (including an HEADERS object) and sends it over the socket.
The older hx-ws="connect:..." attribute found in many tutorials is htmx 1.x syntax; in htmx 2 it was removed from the core, so with the 2.0 script above the attribute is simply ignored and no connection is opened. The extension also reconnects automatically with exponential backoff, but it does not replay missed messages — if the chat history matters, have the server send the latest state after reconnecting. For one-way server pushes (notifications, progress bars), the sse extension over server-sent events is simpler to operate, since it rides on ordinary HTTP.
Django Integration
# views.py
from django.http import HttpResponse
from django.utils.html import escape
def search(request):
q = request.GET.get("q", "")
results = Product.objects.filter(name__icontains=q)[:20]
html = "".join(f"<li>{escape(p.name)}</li>" for p in results)
return HttpResponse(f"<ul>{html}</ul>")
Building HTML with f-strings bypasses Django’s template auto-escaping, so the escape() call is not optional: without it, a product named <img src=x onerror=alert(1)> becomes stored XSS the moment it appears in a search result. For anything beyond a one-liner, render a partial template (render(request, "partials/results.html", {"results": results})), which escapes by default and keeps markup out of the view. The [:20] slice limits the query — a search-as-you-type box sends a request for every pause, and an empty q otherwise matches every row in the table.
<!-- template -->
<input
hx-get="{% url 'search' %}"
hx-trigger="input delay:300ms"
hx-target="#results"
name="q"
/>
<div id="results"></div>
HTMX Response Headers
Control HTMX behavior from the server via response headers:
| Header | Effect |
|---|---|
HX-Redirect: /url | Redirect the browser |
HX-Refresh: true | Full page refresh |
HX-Trigger: event-name | Trigger a client-side event |
HX-Retarget: #id | Override hx-target |
HX-Reswap: strategy | Override hx-swap |
response = HTMLResponse("<div>Done</div>")
response.headers["HX-Trigger"] = "itemAdded"
# Alternatively, send the client somewhere else instead of swapping:
# response.headers["HX-Redirect"] = "/dashboard"
A plain HTTP 302 does not do what people expect with HTMX: the browser’s XHR follows the redirect transparently, and HTMX swaps the redirected page’s full HTML into the target, so you get an entire layout nested inside a div. That is what HX-Redirect is for — it tells HTMX to perform a real client-side navigation. HX-Location does the same without a full reload (it fetches the new URL and swaps it like an hx-boost link). HX-Trigger can also carry JSON ({"showToast": {"message": "Saved"}}), which arrives as event.detail in the listener; response headers are processed only on responses HTMX handles, so they have no effect on a normal page load.
When HTMX fits and when it fights you
HTMX works best when the server already owns the truth and each interaction maps to “ask the server, replace a piece of the page”: admin screens, dashboards, CRUD forms, search-as-you-type, infinite lists. Your team keeps writing templates in Django, FastAPI or Rails, and there is no client-side state to keep in sync because there is almost no client-side state.
It starts to fight you when a lot of state lives in the browser between requests — a drag-and-drop editor, a canvas, an offline-capable app, or optimistic updates that must roll back cleanly. Each of those turns into scattered hx-* attributes plus hand-written JavaScript, and a component framework is the more honest choice. A practical test: if you find yourself returning JSON from an HTMX endpoint, or writing more htmx:afterSwap handlers than templates, the page has outgrown the model.
Frequently Asked Questions (FAQ)
Q. Why doesn’t my error fragment appear when the server responds with a 401 or 422?
A. By default HTMX swaps only successful 2xx responses; 4xx and 5xx responses fire an error event but leave the target unchanged. That affects the login example in this article, which returns its “Invalid credentials” fragment with status 401, so the message never reaches #result unless you change that default. Either return validation errors with a 200 status, or keep the error status and allow the swap, for example by setting evt.detail.shouldSwap = true in an htmx:beforeSwap listener, or through htmx.config.responseHandling in htmx 2.