Skip to content

Fix: HTMX Not Working — hx-get Request Not Firing, Swap Not Updating DOM, or Response Ignored

FixDevs ·

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 response

Or 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 selectorhx-target uses CSS selectors. #list targets id="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 style and 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 appearhx-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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles