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?

i
<!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?

i
<!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?

i
<!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?

i
<!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?

i
<!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?

i
<!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?

i
<!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?

i
<!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?

i
<!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?

i
<!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>
    &bull;
    <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?

i
<!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?

i
<!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.