Fix: HTMX Not Working — hx-get Request Not Firing, Swap Not Updating DOM, or Response Ignored
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.
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);
});
});For related backend patterns, see Fix: Express Middleware Not Working and Fix: Flask 404 Not Found.
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.