Implements HTMX interactions, configures swap behaviors, debugs hx-* requests, and builds hypermedia-driven UI components. Use when tasks involve hx-* attributes, HTMX AJAX requests, swap strategies, server-sent events, WebSockets, or hypermedia-driven UIs.
95
95%
Does it follow best practices?
Impact
Pending
No eval scenarios have been run
Passed
No known issues
Hard-won lessons from production HTMX projects. Use this reference to avoid common pitfalls and make informed architectural decisions.
HTMX is at its best for:
The sweet spot is enhancing parts of a page (form submissions, table updates, search) rather than building a full SPA.
HTMX fails silently in several common scenarios. These are the most frequent sources of "nothing happened" bugs.
If hx-target references an ID that doesn't exist, HTMX fires an htmx:targetError event but produces no visible feedback — the response is silently dropped.
<!-- BUG: #resutls is a typo — HTMX silently drops the response -->
<button hx-get="/search" hx-target="#resutls">Search</button>
<div id="results"></div>Fix: Use htmx.logAll() during development to see all events. Add an event listener to surface target errors:
document.addEventListener('htmx:targetError', function(event) {
console.error('HTMX target not found:', event.detail.target);
});In htmx 2.x, when a server returns a 4xx/5xx error, the response is not swapped — the user sees no indication that their action failed. (Note: htmx 4.0 reverses this — all responses swap by default except 204 and 304.)
// Add global error handling
document.addEventListener('htmx:responseError', function(event) {
var status = event.detail.xhr.status;
var elt = event.detail.elt;
if (status >= 500) {
elt.innerHTML = '<div class="error">Something went wrong. Please try again.</div>';
}
});Or use the response-targets extension to route errors to specific elements:
<div hx-ext="response-targets">
<button hx-get="/data"
hx-target="#content"
hx-target-500="#error-message">
Load
</button>
<div id="content"></div>
<div id="error-message"></div>
</div>HTML minifiers or template engines sometimes strip type="submit" from buttons. HTMX may not detect the form submission correctly without it.
Fix: Always explicitly set type="submit" on form submit buttons, and test after enabling minification.
HTMX's default request queuing can surprise users — by default, if a request is in-flight, new triggering events are dropped (ignored). Only the last queued event is retained. Use hx-sync to customize this behavior.
<button hx-post="/submit"
hx-indicator="#spinner"
hx-disabled-elt="this">
Submit
<span id="spinner" class="htmx-indicator">...</span>
</button>document.addEventListener('htmx:beforeSwap', function(event) {
// Allow 422 responses to swap (for validation errors)
if (event.detail.xhr.status === 422) {
event.detail.shouldSwap = true;
event.detail.isError = false;
}
});HTMX does not automatically manage accessibility. Dynamic content swaps can break screen reader announcements, focus management, and keyboard navigation.
A Wagtail CMS analysis of HTTP Archive data found that since November 2024, htmx-powered sites score below the cross-technology average on Lighthouse accessibility checks. The most common failures on htmx sites:
link-name — links lacking descriptive text (significantly more prevalent on htmx sites)heading-order — headings not in logical sequencearia-allowed-role — HTML elements assigned incompatible ARIA rolesEven official htmx examples contribute: the Progress Bar example uses <h3 role="status"> — an h3 cannot hold the status role, and the heading level may be out of sequence.
This isn't inherent to htmx — it's a gap in documentation and examples. The fix is to follow WAI-ARIA Authoring Practices when implementing interactive patterns.
<!-- BAD: not focusable, not announced by screen readers -->
<div hx-get="/next-page">Next</div>
<!-- BAD: anchor without href is not focusable -->
<a hx-get="/next-page">Next</a>
<!-- GOOD: proper button element -->
<button hx-get="/next-page" hx-target="#content">Next</button>
<!-- GOOD: anchor with href for progressive enhancement -->
<a href="/next-page" hx-get="/next-page" hx-target="#content">Next</a>Anchors (<a>) are semantically for navigation (GET). Using hx-post or hx-delete on them confuses assistive technologies. Use <button> for actions.
When content is swapped, the focused element may be removed from the DOM, leaving focus in limbo.
document.addEventListener('htmx:afterSwap', function(event) {
// Focus the first focusable element in the swapped content
var focusable = event.detail.target.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusable) focusable.focus();
});<!-- Add aria-live to regions that update dynamically -->
<div id="search-results" aria-live="polite" aria-atomic="false">
<!-- HTMX swaps content here -->
</div>Most hx-* attributes inherit from parent to child elements. This is powerful but can cause unexpected behavior.
<!-- Parent sets target for its own button -->
<div hx-target="#main-content">
<button hx-get="/page">Load Page</button>
<!-- BUG: this nested component inherits hx-target="#main-content" -->
<div class="widget">
<button hx-get="/widget-data">Refresh Widget</button>
</div>
</div>Fix: Use hx-target explicitly on the child, or use hx-disinherit to block inheritance:
<div class="widget" hx-disinherit="hx-target">
<button hx-get="/widget-data" hx-target="#widget-content">Refresh Widget</button>
</div>These are safe from inheritance surprises: hx-trigger, hx-on*, hx-swap-oob, hx-preserve, hx-history-elt, hx-validate.
hx-boost converts standard links and forms into AJAX requests. It's great for progressive enhancement but causes problems when overused.
<!-- RISKY: boosting everything causes hard-to-debug issues -->
<body hx-boost="true">
<!-- Every link and form is now AJAX — including ones that shouldn't be -->
</body>Problems with global boost:
Fix: Boost specific containers:
<nav hx-boost="true">
<a href="/dashboard">Dashboard</a>
<a href="/settings">Settings</a>
</nav>
<!-- External links, downloads, etc. stay outside boosted containers -->
<a href="https://external-site.com">External Link</a>
<a href="/files/report.pdf" download>Download Report</a>Without an explicit target, boosted requests replace the entire <body> innerHTML (the default). For partial page updates, set an explicit target:
<nav hx-boost="true" hx-target="#main-content" hx-select="#main-content">
<a href="/dashboard">Dashboard</a>
</nav># Server-side: detect HTMX requests
if request.headers.get('HX-Request'):
return render_template('dashboard_fragment.html')
else:
return render_template('dashboard_full.html')Not every interaction needs a server round-trip. Toggling visibility, showing/hiding elements, and updating counters should happen client-side.
<!-- BAD: server round-trip just to toggle a menu -->
<button hx-get="/toggle-menu" hx-target="#menu">Toggle</button>
<!-- GOOD: client-side toggle with Alpine.js -->
<div x-data="{ open: false }">
<button @click="open = !open">Toggle</button>
<nav x-show="open">Menu items...</nav>
</div>
<!-- GOOD: client-side toggle with vanilla JS -->
<button onclick="document.getElementById('menu').toggleAttribute('hidden')">
Toggle
</button>
<nav id="menu" hidden>Menu items...</nav>Common examples: accordions, tabs (when content is already loaded), tooltips, dropdowns, form field show/hide.
HTMX projects tend to need more server endpoints than traditional MPAs because each dynamic region may need its own fragment endpoint.
Use the HX-Request header to detect HTMX requests:
@app.route('/contacts')
def contacts():
contacts = get_contacts()
if request.headers.get('HX-Request'):
return render_template('contacts_table.html', contacts=contacts)
return render_template('contacts_page.html', contacts=contacts)If the same URL returns different content based on HX-Request, caches must know:
Vary: HX-RequestWithout this, a CDN or browser cache may serve an HTML fragment as a full page (or vice versa).
# BAD: endpoint knows which sidebar element to update
@app.route('/add-item', methods=['POST'])
def add_item():
item = create_item(request.form)
# This endpoint "knows" about #sidebar-count — fragile
return render_template('item.html', item=item), 200, {
'HX-Trigger': 'itemAdded'
}Prefer using HX-Trigger response headers to decouple — let the client-side markup decide what to refresh when an event fires.
HTMX behavior is difficult to unit test because logic is split between server-rendered markup and client-side attribute-driven behavior.
hx-target values and verify the referenced IDs exist in the corresponding pages# Quick audit: find all hx-target values and check they have matching IDs
grep -roh 'hx-target="[^"]*"' templates/ | sort -u
grep -roh 'id="[^"]*"' templates/ | sort -uHTML minifiers may strip attributes that HTMX depends on (like type="submit"). Always test after enabling minification.
For rapid interactions (typing, dragging, real-time updates), the network latency can make the UI feel sluggish. HTMX is not the right tool for:
delay:500ms or validate client-side)HTMX pages typically have faster First Contentful Paint and Largest Contentful Paint than client-side rendered SPAs because meaningful HTML arrives in the initial response. This is a genuine architectural advantage for content-heavy sites.
<!-- Use delay and changed modifier to avoid excessive requests -->
<input hx-get="/search"
hx-trigger="input changed delay:300ms"
hx-target="#results"
name="q" />Avoid combining HTMX with React, Vue, or other frameworks that manage their own DOM.
Why it breaks:
If you must integrate: Keep HTMX and the SPA framework in completely separate DOM regions that never overlap.
hx-* attributes are HTMX-specific. Adopting HTMX means your markup depends on the library.
data-hx-* attribute format for HTML validation compliancehx-boost with href fallbacks) so pages work without HTMXKey changes in htmx 4.0 (official changelog):
fetch() replaces XMLHttpRequest — file upload progress events (htmx:xhr:progress) will no longer work the same way:inherited modifier (hx-target:inherited="#output") or set htmx.config.implicitInheritance = true to restore old behaviorhtmx.config.noSwap = [204, 304, '4xx', '5xx']htmx:afterSwap → htmx:after:swap, htmx:configRequest → htmx:config:request. Multiple error events consolidated into htmx:errormorphInner and morphOuter swap strategies available without extensionsHTMX 2.x will continue to be supported. No rush to migrate, but be aware when starting new projects.