HTMX
HTMX lets you add dynamic behavior to HTML pages by putting attributes directly on elements rather than writing JavaScript. Each attribute tells the browser when to make a request, where to send it, and which part of the page to update with the response. Before trying any of these examples, start the server:
uv run server.py
Then open http://localhost:8000 in a browser to see the list of examples,
or navigate directly to http://localhost:8000/<slug> for the one you want.
Load data into a display panel
Start the server, open the page, and click Load twice. Does it keep working after the first click?
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Wrong Swap Mode</title>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
</head>
<body>
<h1>Load data into a display panel</h1>
<p>Start <code>server.py</code>, then click Load twice.
Does it keep working after the first click?</p>
<button hx-get="/api/data"
hx-target="#panel"
hx-swap="outerHTML">Load</button>
<div id="panel">Waiting for data...</div>
</body>
</html>
Show explanation
The bug is hx-swap="outerHTML" combined with hx-target="#panel". The first click
replaces the entire <div id="panel"> element with the server's response <p>Result: 42</p>.
That <p> has no id attribute, so after the swap #panel no longer exists in the DOM.
The second click finds no target and silently does nothing.
Shows: the difference between innerHTML (replace the contents of the target, leaving
the element itself in place) and outerHTML (replace the target element entirely).
To find it: open DevTools, click Load once, then inspect the DOM. If the <div id="panel">
is gone and a bare <p> has taken its place, outerHTML swapped out the target itself.
Change hx-swap="outerHTML" to hx-swap="innerHTML" so the element stays in the DOM
and the next click can still find it.
Show search results on a page
Start the server, open the page, and click Search. Where do the results appear?
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Missing Target</title>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
</head>
<body>
<h1>Show search results on a page</h1>
<p>Start <code>server.py</code>, then click Search.
Where do the results appear?</p>
<button hx-post="/api/search" hx-swap="innerHTML">Search</button>
<div id="results">Results will appear here.</div>
</body>
</html>
Show explanation
The bug is the missing hx-target attribute. When no target is specified, HTMX defaults
to swapping the response into the element that triggered the request — the button itself.
The button's label is replaced by the results HTML, and the <div id="results"> below
it stays empty.
Shows: HTMX's default target behavior and how to use hx-target to direct a response
to the correct element.
To find it: inspect the button in DevTools after clicking. If its content has changed to
a list of results, the response was swapped into the wrong element. Add
hx-target="#results" to the button so the response lands in the dedicated container.
Display a loading message during a slow fetch
Start the server, click Fetch, and watch the page during the two-second delay. Does any loading message appear?
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Loading Indicator</title>
<style>
.htmx-indicator { display: none; }
.htmx-request .htmx-indicator { display: inline; }
.htmx-request.htmx-indicator { display: inline; }
</style>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
</head>
<body>
<h1>Display a loading message during a slow fetch</h1>
<p>Start <code>server.py</code>, then click Fetch.
Does a loading message appear while the two-second request is in progress?</p>
<button hx-get="/api/slow"
hx-target="#output"
hx-indicator="#spinner">Fetch</button>
<span id="loading" class="htmx-indicator">Loading, please wait...</span>
<div id="output"></div>
</body>
</html>
Show explanation
The bug is hx-indicator="#spinner" pointing to an element that does not exist.
The <span> on the page has id="loading", not id="spinner". HTMX looks for
#spinner at request time, finds nothing, and never toggles visibility.
Shows: how hx-indicator works (HTMX adds htmx-request to the element during the
request, which the CSS rule converts to display: inline) and why element IDs must match
exactly.
To find it: open DevTools and search the DOM for an element with id="spinner". If none
exists, the indicator selector is wrong. Change hx-indicator="#spinner" to
hx-indicator="#loading" to match the actual element.
Filter a list as the user types
Start the server and watch its terminal output while you type a five-letter word one character at a time. How many requests arrive for each character?
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Trigger Too Broad</title>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
</head>
<body>
<h1>Filter a list as the user types</h1>
<p>Start <code>server.py</code> and watch its output while you type
a five-letter fruit name one key at a time.
How many requests does the server receive?</p>
<input type="text"
name="q"
hx-get="/api/suggest"
hx-target="#suggestions"
hx-trigger="input"
placeholder="Type a fruit name">
<div id="suggestions"></div>
</body>
</html>
Show explanation
The bug is hx-trigger="input", which fires a new request on every single keystroke.
Typing "apple" sends five requests in rapid succession. Each one may cancel the previous
before the user finishes typing, and each one adds server load for a query that will
be superseded immediately.
Shows: HTMX trigger modifiers and why a debounce delay matters for search-as-you-type.
To find it: watch the server log. If a request fires for every character, the trigger
has no debounce. Change hx-trigger="input" to
hx-trigger="input changed delay:500ms" to wait until the user pauses before sending.
Search using a text input and a button
Start the server. Type a word and press Enter. Then type the same word and click Search. Do both produce the same result?
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Submit Key</title>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
</head>
<body>
<h1>Search using a text input and a button</h1>
<p>Start <code>server.py</code>. Type a word and press Enter.
Then type the same word and click Search.
Do both produce the same result?</p>
<form action="/submit" method="get">
<input type="text" name="query" placeholder="Enter a search term">
<button hx-get="/api/results"
hx-target="#output"
hx-include="closest form">Search</button>
</form>
<div id="output">Results appear here.</div>
</body>
</html>
Show explanation
The bug is placing hx-get on the button rather than on the <form> element.
Clicking the button triggers HTMX and does a partial-page update. Pressing Enter in
the text field activates the form's native submit action (because no HTMX attribute
intercepts it), which sends a GET request to /submit and reloads the whole page.
Shows: where HTMX attributes belong when a form is involved, and how the browser's native form submission can bypass HTMX.
To find it: press Enter in the input and observe whether the page reloads. If it does,
the form's native action attribute is firing. Move hx-get, hx-target, and
hx-include from the button to the <form> element so that both Enter and button-click
go through HTMX.
Remove a record with a button click
Start the server and watch its terminal output while clicking Delete. Which HTTP method does the server log for each request?
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>GET for Deletion</title>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
</head>
<body>
<h1>Remove a record with a button click</h1>
<p>Start <code>server.py</code> and watch its output while clicking Delete.
Which HTTP method does the server log for each click?</p>
<ul>
<li>
Widget #1
<button hx-get="/api/delete?id=1" hx-target="#status">Delete</button>
</li>
<li>
Widget #2
<button hx-get="/api/delete?id=2" hx-target="#status">Delete</button>
</li>
</ul>
<div id="status"></div>
</body>
</html>
Show explanation
The bug is using hx-get for an operation that deletes data. The HTTP specification
defines GET as safe and idempotent: it must not change server state, and anything that
caches or prefetches URLs (a browser, a CDN, a search engine crawler) may issue it
without the user's knowledge. Using GET for deletion means a prefetcher could
silently delete records.
Shows: HTTP method semantics — GET for reading, DELETE (or POST) for removing — and why
HTMX provides hx-delete, hx-post, and hx-patch as distinct attributes.
To find it: check the server log after clicking Delete. If it shows GET instead of
DELETE, the wrong attribute is being used. Replace hx-get="/api/delete?id=1" with
hx-delete="/api/items/1" and update the server to handle the DELETE method.
Send additional parameters with a request
Start the server, click Subscribe, and read the server output.
Does the server report receiving a plan field?
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Invalid JSON in hx-vals</title>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
</head>
<body>
<h1>Send additional parameters with a request</h1>
<p>Start <code>server.py</code>, click Subscribe, and read the server output.
Did the server receive a <code>plan</code> field?</p>
<button hx-post="/api/vals"
hx-target="#output"
hx-vals="{'plan': 'premium', 'version': 2}">Subscribe</button>
<div id="output"></div>
</body>
</html>
Show explanation
The bug is using single quotes inside hx-vals. The hx-vals attribute must contain
valid JSON, and JSON requires double quotes for both keys and string values. HTMX
calls JSON.parse on the attribute value; when that fails it discards the extra fields
and sends the request without them. No error appears in the page.
Shows: JSON syntax (double quotes only) and how to use hx-vals to attach literal
values to a request.
To find it: open the browser console and look for a JSON parse error after clicking.
Change the attribute to use double quotes and single-quote the outer HTML attribute:
hx-vals='{"plan": "premium", "version": 2}'.
Update two page areas from a single request
Start the server, click the button, and watch the notification area at the bottom. Does it update?
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OOB ID Mismatch</title>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
</head>
<body>
<h1>Update two page areas from a single request</h1>
<p>Start <code>server.py</code>, click the button.
Does the notification area update?</p>
<button hx-post="/api/action" hx-target="#main-area">Do action</button>
<div id="main-area">Action result appears here.</div>
<div id="notifications">Notifications: (none)</div>
</body>
</html>
Show explanation
The bug is an ID mismatch between the server's out-of-band fragment and the element
on the page. The server returns <div id="notification" hx-swap-oob="true">, but
the page has <div id="notifications"> (plural). HTMX looks for an element whose
id matches exactly, finds none, and silently discards the OOB fragment.
Shows: how out-of-band swaps work (hx-swap-oob targets an existing page element by
id) and why typos in IDs fail without any visible error.
To find it: search the server response in DevTools' Network tab for the OOB fragment.
Compare its id attribute to the id on the element you expect to update. Fix
whichever is wrong so the two match.
Keep the URL bar in sync with displayed content
Start the server, click View Items, then reload the page. What does the browser display after the reload?
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Push URL</title>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
</head>
<body>
<h1>Keep the URL bar in sync with displayed content</h1>
<p>Start <code>server.py</code>, click View Items, then reload the page.
What does the browser show after the reload?</p>
<button hx-get="/api/items"
hx-target="#content"
hx-push-url="true">View Items</button>
<div id="content">Click the button to load items.</div>
</body>
</html>
Show explanation
The bug is hx-push-url="true" on a request that targets an API endpoint. After the
click the URL bar shows /api/items. Reloading the page sends a fresh GET request to
/api/items, which returns a bare <ul> fragment — no <html>, <head>, or <body>.
The browser displays raw HTML tags or a blank page.
Shows: what hx-push-url does (it changes the browser's URL history entry) and why the
pushed URL must correspond to a real page that returns a full HTML document.
To find it: after clicking, copy the URL from the address bar and paste it into a new
tab. If you see a fragment instead of a page, the URL should not have been pushed.
Use hx-push-url="/items" where /items returns a complete page, or remove
hx-push-url entirely if history support is not needed.
Add HTMX to a page with existing links
Start the server, open http://localhost:8000/boost, and click "Download CSV".
Does the file download, or does something else happen?
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>hx-boost and Downloads</title>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
</head>
<body>
<h1>Add HTMX to a page with existing links</h1>
<p>Start <code>server.py</code>, open <code>http://localhost:8000/boost</code>,
then click "Download CSV".
Does the file download, or does something else happen?</p>
<nav hx-boost="true">
<a href="/wrongswap">Example page</a>
•
<a href="/download">Download CSV</a>
</nav>
<p>Main page content.</p>
</body>
</html>
Show explanation
The bug is hx-boost="true" on a <nav> that contains a download link. hx-boost
converts every link click in the container into an HTMX GET request that swaps the
response body into the current page. When the download link is clicked, HTMX fetches
/download via XHR. The browser's built-in download behavior only triggers during
direct navigation, not XHR, so Content-Disposition: attachment is ignored and the
raw CSV text is swapped into the page instead of saved as a file.
Shows: what hx-boost does to link clicks and that some links must remain as plain
navigation to work correctly.
To find it: add hx-boost="false" to the download link itself, which opts that
element out of the boosted container. HTMX respects this override and lets the browser
handle the click normally.
Search using fields from a form
Start the server, type a category, click Search, and read the server output. Which fields did the server receive?
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>hx-include Too Broad</title>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
</head>
<body>
<h1>Search using fields from a form</h1>
<p>Start <code>server.py</code>, type a category and click Search.
Read the server output. Which fields did it receive?</p>
<form id="search-form">
<label>Category: <input type="text" name="category" value="books"></label>
</form>
<button hx-post="/api/search-fields"
hx-target="#output"
hx-include="input">Search</button>
<div id="output"></div>
<form id="admin-form" style="display:none">
<input type="hidden" name="admin_token" value="secret-abc123">
</form>
</body>
</html>
Show explanation
The bug is hx-include="input", which is a CSS selector matching every <input>
element on the entire page. The hidden admin_token field in the second form is
included and sent to the server alongside category, even though it belongs to a
completely different form that should never be submitted here.
Shows: how hx-include selectors work and why an overly broad selector can leak
data you did not intend to send.
To find it: look at the server log. If it shows fields you did not expect, the
selector is too broad. Replace hx-include="input" with
hx-include="#search-form input" (or hx-include="closest form" if the button
is inside the form) to include only the intended fields.
Refresh a list when a new item is added
Start the server, add several items using the form, and watch the list below the form. Does it update after each addition?
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Missing HX-Trigger Header</title>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
</head>
<body>
<h1>Refresh a list when a new item is added</h1>
<p>Start <code>server.py</code>, add a few items using the form.
Does the list below update automatically after each addition?</p>
<form hx-post="/api/add-item" hx-target="#add-status" hx-swap="innerHTML">
<input type="text" name="name" placeholder="Item name">
<button type="submit">Add Item</button>
</form>
<div id="add-status"></div>
<h2>Current items</h2>
<ul id="item-list"
hx-get="/api/items-list"
hx-trigger="load, itemAdded from:body">
</ul>
</body>
</html>
Show explanation
The bug is on the server side. The <ul> listens for an itemAdded event dispatched
from the body (hx-trigger="load, itemAdded from:body"). HTMX fires that event when
it receives a response that includes the header HX-Trigger: itemAdded. The server's
POST /api/add-item handler does not send that header, so the event never fires and
the list only loads once (on page load) and never again.
Shows: the HX-Trigger response header as a server-to-client signaling mechanism,
and how HTMX can coordinate multiple page elements without JavaScript.
To find it: inspect the response headers for POST /api/add-item in DevTools' Network
tab. If HX-Trigger is absent, the server is not sending the signal. Add the header
to the response:
self.send_header("HX-Trigger", "itemAdded")
The list will then re-fetch itself automatically after every successful addition.