Fix: HTMX Not Working — hx-get Request Not Firing, Swap Not Updating DOM, or Response Ignored
Part of: React & Frontend Errors
Quick Answer
How to fix HTMX issues — attribute syntax, target and swap strategies, out-of-band swaps, event handling, CSP configuration, response headers, and debugging HTMX requests.
The Problem
An hx-get attribute is set but clicking the element makes no request:
<button hx-get="/api/users" hx-target="#list">Load Users</button>
<div id="list"></div>
<!-- Click does nothing — no network request -->Or the request fires but the DOM doesn’t update:
<div hx-get="/api/users" hx-trigger="load">
<!-- Request fires, response received, but innerHTML doesn't change -->
</div>Or the server returns HTML but HTMX ignores the response:
HX-Request: true
Response: 200 OK
Body: <li>Item 1</li><li>Item 2</li>
# DOM unchanged after responseOr an out-of-band (OOB) swap doesn’t update the secondary element:
<!-- Response contains OOB element, but only the primary target updates -->
<div id="message" hx-swap-oob="true">Saved!</div>Why This Happens
HTMX extends HTML with declarative AJAX — when it fails, the cause is usually one of:
- HTMX not loaded — if the
<script>tag is missing or loads after the elements, HTMX won’t process any attributes. No JavaScript error is thrown; attributes are just ignored. - Wrong target selector —
hx-targetuses CSS selectors.#listtargetsid="list". If the element doesn’t exist or the selector is wrong, HTMX silently skips the swap. - Response isn’t HTML — HTMX swaps HTML fragments into the DOM. If the server returns JSON, a redirect, or an error status without a body, the swap produces unexpected results.
- CSP blocking inline event handlers — HTMX uses inline
styleand event dispatching that may conflict with a strict Content Security Policy. - Default trigger isn’t what you expect — different elements have different default triggers:
<form>triggers on submit,<input>on change, everything else on click.
The deeper reason HTMX feels different from React is the philosophy. HTMX treats HTML as the engine of state, not JavaScript. The server returns rendered fragments; the client splices them into the DOM. There is no virtual DOM, no client-side router, no hydration. This works beautifully for traditional CRUD apps but breaks the moment you assume React-style optimistic updates or component-local state. The most common bug is wanting client state without HTMX’s hx-on: event handlers or Alpine.js next to it — neither of which the framework provides out of the box.
A second source of confusion is HTMX 2.0 (released 2024). Version 2 dropped IE11 support, renamed hx-include semantics, and changed the default behavior of hx-boost for forms. If you copied an hx-* example from an older blog post or 1.x tutorial, some attributes behave differently. Pin a specific version in the <script src> tag ([email protected]) rather than @latest to avoid surprise behavior changes on production deploys.
How Other Tools Handle This
HTMX belongs to a family of “server-rendered HTML plus a sprinkle of JS” libraries. They share the philosophy but disagree on where the work lives.
HTMX vs Alpine.js. Alpine is the client-state counterpart to HTMX. Where HTMX swaps server-rendered HTML into the DOM, Alpine runs reactive expressions (x-data, x-show, x-model) entirely in the browser. The two are designed to compose: HTMX handles round-trips, Alpine handles the in-between interactivity (a modal opening, a dropdown filtering client-side). A bug like “I want this checkbox to toggle a class without a server round-trip” is an Alpine problem, not an HTMX problem.
HTMX vs Stimulus. Stimulus is the JavaScript framework from 37signals, built around controllers that bind to data attributes. Stimulus does not do AJAX itself — it gives you a structured way to write the JavaScript that responds to events. You can use Stimulus to dispatch a fetch and patch the DOM, but you write the code. HTMX is declarative: the attributes are the code. Teams that want explicit JS but minimal framework reach for Stimulus.
HTMX vs Turbo (Hotwire). Turbo, also from 37signals, replaces page navigation with partial swaps. Turbo Drive intercepts link clicks and form submits and replaces <body> (or <turbo-frame> blocks) with the server’s response. Turbo Streams use Server-Sent Events or WebSockets to push HTML fragments. The HTMX equivalent is hx-boost plus hx-swap-oob plus SSE extension. Turbo is more opinionated about page-level navigation; HTMX is more granular per-element.
HTMX vs Unpoly. Unpoly predates HTMX and uses similar up-* attributes. It ships layered modals, flash messages, and history management out of the box, which HTMX leaves to you. HTMX is lighter (~14 KB minified) and more modular; Unpoly is heavier but more “framework.”
Picking the right tool. Use HTMX when you want maximum control over swap targets and triggers per element. Use Turbo when your app is mostly page-level navigation and you want Rails-style conventions. Use Unpoly when you want a heavier batteries-included package. Use Stimulus when you specifically want structured JavaScript controllers without server round-trips. Most real apps end up with HTMX plus Alpine, or Turbo plus Stimulus.
Fix 1: Verify HTMX Is Loaded
<!DOCTYPE html>
<html>
<head>
<!-- Load HTMX from CDN -->
<script src="https://unpkg.com/[email protected]" integrity="..." crossorigin="anonymous"></script>
<!-- Or from your own server -->
<script src="/static/htmx.min.js"></script>
</head>
<body>
<!-- HTMX elements work after the script loads -->
<button hx-get="/api/users" hx-target="#list">Load Users</button>
<div id="list"></div>
</body>
</html>Verify HTMX is active in the browser console:
// Check if HTMX is loaded
typeof htmx // Should be 'object', not 'undefined'
// HTMX version
htmx.version // e.g., "2.0.0"
// Process new elements added dynamically
htmx.process(document.getElementById('my-new-element'));HTMX with a bundler (Vite, webpack):
// main.js
import htmx from 'htmx.org';
// HTMX auto-processes the document on DOMContentLoaded
// Elements added dynamically need manual processing:
document.addEventListener('htmx:afterSettle', (event) => {
htmx.process(event.target);
});Fix 2: Use hx-* Attributes Correctly
<!-- hx-get — GET request on click (default trigger for buttons/divs) -->
<button hx-get="/api/users" hx-target="#user-list">Load Users</button>
<!-- hx-post — POST request, sends form data -->
<form hx-post="/api/users" hx-target="#result">
<input name="name" />
<button type="submit">Create User</button>
</form>
<!-- hx-put, hx-patch, hx-delete -->
<button hx-delete="/api/users/1" hx-target="#user-1" hx-swap="outerHTML">
Delete
</button>
<!-- hx-trigger — change when the request fires -->
<input hx-get="/api/search" hx-trigger="input changed delay:300ms" hx-target="#results">
<div hx-get="/api/data" hx-trigger="load"><!-- Fires on page load --></div>
<div hx-get="/api/poll" hx-trigger="every 5s"><!-- Polls every 5 seconds --></div>
<!-- hx-target — where to put the response -->
<button hx-get="/api/users" hx-target="#list">Load</button>
<!-- Relative selectors -->
<button hx-get="/api/item" hx-target="closest .container">Load</button>
<button hx-get="/api/item" hx-target="next .result">Load</button>
<button hx-get="/api/item" hx-target="previous p">Load</button>
<button hx-get="/api/item" hx-target="this">Replace self</button>
<!-- hx-swap — how to insert the response -->
<div hx-get="/api/content" hx-swap="innerHTML">Replace content</div>
<div hx-get="/api/item" hx-swap="outerHTML">Replace entire element</div>
<div hx-get="/api/item" hx-swap="beforebegin">Insert before</div>
<div hx-get="/api/item" hx-swap="afterend">Insert after</div>
<div hx-get="/api/item" hx-swap="afterbegin">Prepend</div>
<div hx-get="/api/item" hx-swap="beforeend">Append</div>
<div hx-get="/api/item" hx-swap="none">No DOM change (for side effects)</div>Common trigger modifiers:
<!-- Delay before firing (debounce) -->
<input hx-get="/search" hx-trigger="input delay:500ms">
<!-- Only fire if value changed -->
<input hx-get="/validate" hx-trigger="change">
<!-- Throttle — fire at most every 2 seconds -->
<div hx-get="/api/stream" hx-trigger="every 2s throttle:500ms">
<!-- Intersect — fire when element enters viewport -->
<div hx-get="/api/lazy" hx-trigger="intersect once">
<!-- Filter — fire only if condition is met -->
<input hx-get="/search" hx-trigger="keyup[key=='Enter']">Fix 3: Send the Right Response from the Server
HTMX expects HTML fragments, not full pages:
# FastAPI example
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
app = FastAPI()
@app.get("/api/users")
async def get_users():
users = await db.fetch_users()
# Return an HTML fragment — NOT a full page
items = "".join(f'<li id="user-{u.id}">{u.name}</li>' for u in users)
return HTMLResponse(f'<ul>{items}</ul>')
# For HTMX-aware responses: check HX-Request header
@app.get("/users")
async def users_page(request: Request):
users = await db.fetch_users()
is_htmx = request.headers.get("HX-Request") == "true"
if is_htmx:
# Return fragment for HTMX requests
return HTMLResponse(render_user_list(users))
else:
# Return full page for regular navigation
return templates.TemplateResponse("users.html", {"users": users})// Express example
app.get('/api/users', async (req, res) => {
const users = await db.getUsers();
// Return HTML fragment
const html = users.map(u => `<li id="user-${u.id}">${u.name}</li>`).join('');
res.send(`<ul>${html}</ul>`);
});
// Handle empty results — return something (even empty element)
app.get('/api/search', async (req, res) => {
const results = await search(req.query.q);
if (!results.length) {
res.send('<p>No results found</p>'); // HTMX swaps this in
return;
}
res.send(results.map(r => `<div>${r.title}</div>`).join(''));
});Response headers for HTMX control:
// Redirect after form submission
app.post('/api/users', async (req, res) => {
await createUser(req.body);
res.setHeader('HX-Redirect', '/users'); // Client-side redirect
res.status(204).send();
});
// Refresh the whole page
res.setHeader('HX-Refresh', 'true');
// Retarget — change where the response is swapped
res.setHeader('HX-Retarget', '#notifications');
res.setHeader('HX-Reswap', 'afterbegin');
// Trigger a client-side event
res.setHeader('HX-Trigger', 'userCreated');
// or with data:
res.setHeader('HX-Trigger', JSON.stringify({ userCreated: { id: newUser.id } }));Fix 4: Use Out-of-Band Swaps
OOB swaps update multiple parts of the page in a single response:
<!-- Server response for a form submission -->
<!-- Primary response: replaces hx-target -->
<div id="user-form">
<p>User created successfully!</p>
</div>
<!-- OOB element: also updates #user-count anywhere on the page -->
<span id="user-count" hx-swap-oob="true">42 users</span>
<!-- OOB with specific swap strategy -->
<ul id="recent-users" hx-swap-oob="afterbegin">
<li>New User</li>
</ul><!-- In the page: both elements exist -->
<form hx-post="/api/users" hx-target="#form-result">
<!-- ... -->
</form>
<div id="form-result"></div>
<span id="user-count">41 users</span>
<ul id="recent-users"><!-- existing users --></ul>OOB in templates (Jinja2 example):
<!-- templates/create_user_response.html -->
<div id="form-result">
<p class="success">User "{{ user.name }}" created!</p>
</div>
<span id="user-count" hx-swap-oob="true">{{ total_count }} users</span>
{% for notification in notifications %}
<div id="notifications" hx-swap-oob="beforeend">
<div class="notification">{{ notification.message }}</div>
</div>
{% endfor %}Fix 5: Handle Events and Extensions
HTMX fires events you can listen to:
// Request lifecycle events
document.addEventListener('htmx:beforeRequest', (event) => {
const { elt, requestConfig } = event.detail;
console.log('Making request:', requestConfig.path);
// Add auth header to every request
requestConfig.headers['Authorization'] = `Bearer ${getToken()}`;
});
document.addEventListener('htmx:afterRequest', (event) => {
const { successful, failed, xhr } = event.detail;
if (failed) {
console.error('Request failed:', xhr.status);
}
});
document.addEventListener('htmx:afterSwap', (event) => {
// New content was swapped in — initialize JS components
initializeTooltips(event.target);
});
document.addEventListener('htmx:responseError', (event) => {
const { xhr } = event.detail;
if (xhr.status === 401) {
window.location.href = '/login';
}
});
// Custom events from HX-Trigger header
document.addEventListener('userCreated', (event) => {
const { id } = event.detail;
console.log('User created with ID:', id);
});Loading indicators:
<!-- Global loading indicator -->
<div id="loading" class="htmx-indicator">
<span>Loading...</span>
</div>
<style>
.htmx-indicator { display: none; }
.htmx-request .htmx-indicator { display: block; }
/* Or use htmx-request on a specific element */
</style>
<!-- Per-request indicator -->
<button
hx-get="/api/data"
hx-indicator="#my-spinner"
>
Load
</button>
<div id="my-spinner" class="htmx-indicator">⏳</div>Disable a button during request:
<button
hx-post="/api/submit"
hx-disabled-elt="this"
>
Submit
</button>Fix 6: CSP and Security Configuration
HTMX may conflict with strict Content Security Policies:
<!-- CSP that works with HTMX -->
<meta http-equiv="Content-Security-Policy"
content="
default-src 'self';
script-src 'self' 'unsafe-eval';
style-src 'self' 'unsafe-inline';
">HTMX-specific CSP considerations:
// HTMX uses eval() for some features — if you disable unsafe-eval,
// configure htmx to use a safer evaluation method
htmx.config.allowEval = false; // Disable eval-based features
// If using hx-on: attributes (inline event handlers), you need unsafe-inline for scripts
// Consider using addEventListener instead:
document.addEventListener('htmx:configRequest', (event) => {
// Handle config centrally instead of hx-on:
});CSRF protection:
// Add CSRF token to all HTMX requests
document.addEventListener('htmx:configRequest', (event) => {
event.detail.headers['X-CSRF-Token'] = getCsrfToken();
});
// Or configure globally
htmx.config.defaultHeaders = {
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content,
};Validate HTMX requests server-side:
# FastAPI — verify HX-Request header for HTMX-only endpoints
@app.post("/api/users")
async def create_user(request: Request, data: UserCreate):
if not request.headers.get("HX-Request"):
raise HTTPException(400, "Only HTMX requests accepted")
# ...Still Not Working?
Request fires but target isn’t updated — open browser DevTools → Network tab and verify: (1) the request returns 200 with an HTML body, (2) the response Content-Type is text/html, (3) hx-target selector matches an existing element. HTMX logs swap details: add htmx.logger = console.log to see what’s happening.
HTMX stops working after navigating back — if your app uses hx-boost for navigation, the browser’s back/forward cache (bfcache) may restore a page state where HTMX didn’t re-initialize. Listen for htmx:historyRestore to re-initialize components:
document.addEventListener('htmx:historyRestore', () => {
initializeComponents();
});Dynamic content added to the DOM isn’t processed by HTMX — HTMX processes elements on page load and after each swap. If you add elements via JavaScript outside HTMX (e.g., innerHTML), call htmx.process(container) on the parent to activate HTMX attributes on new content.
hx-confirm dialog doesn’t appear — hx-confirm shows a browser confirm() dialog before the request. If you’re in a frame or some browser security contexts, confirm() may be blocked. Use htmx:confirm event to customize:
document.addEventListener('htmx:confirm', (event) => {
event.preventDefault();
showCustomModal(event.detail.question).then(() => {
event.detail.issueRequest(true);
});
});Form encoding mismatch with multipart uploads — HTMX defaults to application/x-www-form-urlencoded for POST. For file uploads you need hx-encoding="multipart/form-data" on the form, otherwise the file field arrives empty on the server and you debug a phantom “no file” bug. The browser console shows the request as URL-encoded, which is the tell.
hx-swap="outerHTML" removes attached event listeners — when HTMX replaces the entire element (including itself), any non-HTMX event listeners attached via addEventListener are lost because the original DOM node is gone. Re-attach in htmx:afterSwap, or move logic into hx-on: attributes so HTMX re-binds them as part of the swap.
Browser DevTools shows duplicate requests — usually caused by both an hx-trigger and a default trigger firing. <button hx-get="..." hx-trigger="click, load"> fires once on load and once on click. Audit the trigger list and use htmx.config.defaultSettleDelay debug logs to confirm what HTMX thinks the trigger is.
For related backend patterns, see Fix: Express Middleware Not Working and Fix: Flask 404 Not Found. For server-side fragment endpoint gotchas, see Fix: Flask CORS Not Working and Fix: Django CSRF Verification Failed.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: pdf-lib Not Working — PDF Not Generating, Fonts Not Embedding, or Pages Blank
How to fix pdf-lib issues — creating PDFs from scratch, modifying existing PDFs, embedding fonts and images, form filling, merging documents, and browser and Node.js usage.
Fix: Pusher Not Working — Events Not Received, Channel Auth Failing, or Connection Dropping
How to fix Pusher real-time issues — client and server setup, channel types, presence channels, authentication endpoints, event binding, connection management, and React integration.
Fix: date-fns Not Working — Wrong Timezone Output, Invalid Date, or Locale Not Applied
How to fix date-fns issues — timezone handling with date-fns-tz, parseISO vs new Date, locale import and configuration, DST edge cases, v3 ESM migration, and common format pattern mistakes.
Fix: Nuxt Not Working — useFetch Returns Undefined, Server Route 404, or Hydration Mismatch
How to fix Nuxt 3 issues — useFetch vs $fetch, server routes in server/api, composable SSR rules, useAsyncData, hydration errors, and Nitro configuration problems.