Tech Guides
01

Quick Reference

The core attributes that make htmx work. Add these to any HTML element to give it AJAX superpowers without writing JavaScript.

Core Attributes

Attribute Description Example
hx-get Issues a GET request to the given URL hx-get="/api/users"
hx-post Issues a POST request to the given URL hx-post="/api/users"
hx-put Issues a PUT request to the given URL hx-put="/api/users/1"
hx-patch Issues a PATCH request to the given URL hx-patch="/api/users/1"
hx-delete Issues a DELETE request to the given URL hx-delete="/api/users/1"

Targeting & Swapping

Attribute Description Example
hx-target CSS selector for the element to swap content into hx-target="#results"
hx-swap How the response will be swapped into the target hx-swap="outerHTML"
hx-select Select a subset of the response to swap in hx-select="#content"
hx-select-oob Select content for out-of-band swaps from response hx-select-oob="#nav"

Triggering & Behavior

Attribute Description Example
hx-trigger What event triggers the request hx-trigger="click"
hx-indicator Element to show during the request (loading state) hx-indicator="#spinner"
hx-confirm Shows a confirm dialog before issuing the request hx-confirm="Are you sure?"
hx-disable Disables htmx processing on the element hx-disable
hx-vals Adds values to the request parameters (JSON) hx-vals='{"key":"val"}'
hx-headers Adds headers to the request (JSON) hx-headers='{"X-Custom":"1"}'

Minimal Example

<!-- Button that loads content from server -->
<button hx-get="/api/greeting"
        hx-target="#output"
        hx-swap="innerHTML">
  Say Hello
</button>

<div id="output">Click the button...</div>

<!-- Server returns plain HTML: -->
<!-- <p>Hello, World!</p> -->
02

Installation

htmx is a single JavaScript file with zero dependencies. Drop it in and go.

CDN (Recommended)

<!-- unpkg -->
<script src="https://unpkg.com/htmx.org@2.0.4"></script>

<!-- jsdelivr -->
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.4"></script>

<!-- With SRI hash for integrity -->
<script src="https://unpkg.com/htmx.org@2.0.4"
        integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+"
        crossorigin="anonymous"></script>

npm / Package Manager

# npm
npm install htmx.org

# yarn
yarn add htmx.org

# pnpm
pnpm add htmx.org
// ES module import
import 'htmx.org';

// Or require (CommonJS)
require('htmx.org');

Self-Hosted

# Download and serve from your own static files
curl -o static/htmx.min.js \
  https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js
<script src="/static/htmx.min.js"></script>

Verify It Works

<!DOCTYPE html>
<html>
<head>
  <script src="https://unpkg.com/htmx.org@2.0.4"></script>
</head>
<body>
  <div hx-get="/test" hx-trigger="load">
    Loading...
  </div>
</body>
</html>
Note

htmx 2.x requires responses from your own domain by default (no CORS). Use htmx.config.selfRequestsOnly = false to disable this.

03

Core Patterns

Common UI patterns that htmx handles with simple HTML attributes. Each replaces dozens of lines of JavaScript.

Click to Load

Load more items by clicking a button. The button replaces itself with new content.

<div id="item-list">
  <div class="item">Item 1</div>
  <div class="item">Item 2</div>
  <div class="item">Item 3</div>

  <button hx-get="/items?page=2"
          hx-target="#item-list"
          hx-swap="beforeend"
          hx-select=".item">
    Load More
  </button>
</div>

<!-- Server returns more .item elements -->
<!-- The button can be included in the response -->
<!-- to chain pagination endlessly -->

Infinite Scroll

Automatically loads content when the user scrolls to the bottom.

<div id="feed">
  <div class="post">Post 1</div>
  <div class="post">Post 2</div>

  <!-- Sentinel element triggers on reveal -->
  <div hx-get="/feed?page=2"
       hx-trigger="revealed"
       hx-target="#feed"
       hx-swap="beforeend"
       hx-select=".post">
    <span class="htmx-indicator">Loading...</span>
  </div>
</div>
How It Works

The revealed trigger fires when an element enters the viewport. Include a new sentinel in each response to keep the chain going. Omit it to stop.

Active Search

Live search that updates results as the user types, with debouncing.

<input type="search"
       name="q"
       placeholder="Search users..."
       hx-get="/search"
       hx-trigger="input changed delay:300ms, search"
       hx-target="#search-results"
       hx-indicator="#search-spinner">

<span id="search-spinner" class="htmx-indicator">
  Searching...
</span>

<div id="search-results"></div>

The delay:300ms modifier debounces the request. The changed modifier ensures it only fires when the value actually changes.

Inline Editing

Click on content to edit it in place. The view swaps to a form and back.

<!-- View mode -->
<div hx-get="/contact/1/edit"
     hx-trigger="click"
     hx-swap="outerHTML">
  <span>John Doe</span>
  <span>john@example.com</span>
</div>

<!-- Server returns edit form: -->
<form hx-put="/contact/1"
      hx-target="this"
      hx-swap="outerHTML">
  <input name="name" value="John Doe">
  <input name="email" value="john@example.com">
  <button type="submit">Save</button>
  <button hx-get="/contact/1"
          hx-swap="outerHTML">Cancel</button>
</form>

Delete with Confirmation

<button hx-delete="/items/42"
        hx-confirm="Delete this item?"
        hx-target="closest tr"
        hx-swap="outerHTML swap:500ms">
  Delete
</button>

<!-- Server returns 200 with empty body -->
<!-- The row disappears after a 500ms settle -->

Tabs / Navigation

<div class="tabs" hx-target="#tab-content">
  <button hx-get="/tabs/profile"
          class="active">Profile</button>
  <button hx-get="/tabs/settings">Settings</button>
  <button hx-get="/tabs/billing">Billing</button>
</div>

<div id="tab-content">
  <!-- Tab content loaded here -->
</div>
04

Swap Strategies

The hx-swap attribute controls exactly how the server response replaces content in the DOM.

Swap Values

Value Behavior Use Case
innerHTML Replace inner content of target (default) Update a container's contents
outerHTML Replace the entire target element Swap a component entirely
beforebegin Insert before the target element Add items before a list
afterbegin Insert inside target, before first child Prepend to a list
beforeend Insert inside target, after last child Append to a list (load more)
afterend Insert after the target element Add sibling content
delete Remove the target element entirely Delete a row or card
none No swapping (fire events only) Trigger side effects, OOB only

Swap Modifiers

Add modifiers after the swap value to control timing and behavior.

<!-- Delay the swap by 500ms -->
hx-swap="innerHTML swap:500ms"

<!-- Settle delay (time before CSS transitions) -->
hx-swap="innerHTML settle:200ms"

<!-- Scroll target into view after swap -->
hx-swap="innerHTML scroll:top"
hx-swap="innerHTML scroll:bottom"

<!-- Show a specific element after swap -->
hx-swap="innerHTML show:#element:top"

<!-- Focus scroll — scroll into view smoothly -->
hx-swap="innerHTML focus-scroll:true"

<!-- Combine modifiers -->
hx-swap="beforeend swap:100ms settle:200ms scroll:bottom"

Transition Classes

htmx adds CSS classes during the swap lifecycle for animations.

Class When Applied
htmx-request On the element making the request (during flight)
htmx-settling On the target during the settle phase
htmx-swapping On the target during the swap phase
htmx-added On new content just after it is added to the DOM
/* Fade-in animation for new content */
.htmx-added {
  opacity: 0;
}
.item {
  transition: opacity 0.3s ease-in;
  opacity: 1;
}

/* Loading indicator */
.htmx-indicator {
  display: none;
}
.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator {
  display: inline;
}

View Transitions

<!-- Enable the View Transitions API -->
<meta name="htmx-config"
      content='{"globalViewTransitions": true}'>

<!-- Or per-element -->
hx-swap="innerHTML transition:true"
Browser Support

View Transitions work in Chrome 111+ and other Chromium browsers. htmx gracefully falls back to instant swaps in unsupported browsers.

05

Triggers & Modifiers

The hx-trigger attribute controls when a request is issued. It supports standard DOM events, custom events, and a rich modifier syntax.

Standard Triggers

<!-- Default triggers (implicit, no hx-trigger needed) -->
<!-- input, textarea, select: "change" -->
<!-- form: "submit" -->
<!-- everything else: "click" -->

<!-- Explicit trigger -->
<div hx-get="/data" hx-trigger="click">...</div>

<!-- Multiple triggers (comma-separated) -->
<div hx-get="/data"
     hx-trigger="click, keyup[key=='Enter']">
  ...
</div>

Trigger Modifiers

Modifier Effect Example
once Only fire the first time hx-trigger="click once"
changed Only fire if the value actually changed hx-trigger="input changed"
delay:<time> Debounce — wait before firing hx-trigger="input delay:500ms"
throttle:<time> Throttle — fire at most once per interval hx-trigger="scroll throttle:200ms"
from:<selector> Listen for event on another element hx-trigger="click from:#btn"
target:<selector> Only fire when event target matches hx-trigger="click target:.item"
consume Prevent the event from propagating hx-trigger="click consume"
queue:<strategy> Queue behavior: first, last, all, none hx-trigger="click queue:last"

Special Triggers

<!-- Fire on page load -->
<div hx-get="/init" hx-trigger="load">...</div>

<!-- Fire when element scrolls into view -->
<div hx-get="/lazy" hx-trigger="revealed">...</div>

<!-- Fire when intersecting viewport (IntersectionObserver) -->
<div hx-get="/data"
     hx-trigger="intersect threshold:0.5">
  ...
</div>

<!-- Poll every 2 seconds -->
<div hx-get="/status"
     hx-trigger="every 2s">
  ...
</div>

<!-- Conditional polling (stop when condition met) -->
<div hx-get="/job/status"
     hx-trigger="every 1s [!document.querySelector('.complete')]">
  ...
</div>

Event Filters

Use bracket syntax to filter events with JavaScript expressions.

<!-- Only on Enter key -->
<input hx-get="/search"
       hx-trigger="keyup[key=='Enter']">

<!-- Only with Ctrl held down -->
<div hx-post="/save"
     hx-trigger="keydown[ctrlKey&&key=='s']
                 from:body">
  ...
</div>

<!-- Only when a checkbox is checked -->
<input type="checkbox"
       hx-get="/toggle"
       hx-trigger="change[this.checked]">
06

Events & Lifecycle

htmx fires a rich set of events throughout the request lifecycle. Use them for custom behavior, validation, and coordination.

Key Events

Event When It Fires
htmx:configRequest Before the request is sent. Modify params, headers, URL.
htmx:beforeRequest Just before the AJAX call. Cancel with preventDefault().
htmx:afterRequest After the request completes (success or error).
htmx:beforeSwap Before the DOM swap. Override swap behavior here.
htmx:afterSwap After the new content is in the DOM.
htmx:afterSettle After settling (CSS transitions complete).
htmx:responseError When the server returns a non-2xx status.
htmx:sendError When the request fails entirely (network error).
htmx:load When new content is loaded into the DOM (like DOMContentLoaded).

Listening in HTML (hx-on)

<!-- Inline event handler via hx-on -->
<form hx-post="/submit"
      hx-on::after-request="alert('Saved!')">
  ...
</form>

<!-- Modify request configuration -->
<button hx-get="/api"
        hx-on::config-request="event.detail.headers['X-Token'] = getToken()">
  Fetch
</button>

<!-- Cancel a request conditionally -->
<form hx-post="/submit"
      hx-on::before-request="if(!validate()) event.preventDefault()">
  ...
</form>
hx-on Syntax

In htmx 2.x, use hx-on::event-name (double colon, kebab-case). The old hx-on="htmx:eventName: ..." syntax is deprecated.

Listening in JavaScript

// Listen on a specific element
document.getElementById('myForm').addEventListener(
  'htmx:afterSwap', (e) => {
    console.log('Swapped!', e.detail);
  }
);

// Listen on document (catches all htmx events)
document.addEventListener('htmx:afterRequest', (e) => {
  if (!e.detail.successful) {
    showToast('Request failed!');
  }
});

// Modify request before it fires
document.addEventListener('htmx:configRequest', (e) => {
  e.detail.headers['Authorization'] = 'Bearer ' + token;
});

Handling Non-2xx Responses

// By default, htmx ignores non-2xx responses.
// Override this in htmx:beforeSwap:
document.addEventListener('htmx:beforeSwap', (e) => {
  // Show error responses in the target
  if (e.detail.xhr.status === 422) {
    e.detail.shouldSwap = true;
    e.detail.isError = false;
  }

  // Custom handling for 404
  if (e.detail.xhr.status === 404) {
    e.detail.shouldSwap = true;
    e.detail.target = document.getElementById('error-area');
  }
});
07

Server Integration

htmx servers return HTML fragments, not JSON. Here are the patterns and response headers that make it work.

The Fundamental Pattern

# Server-side pseudocode:
# 1. Receive request
# 2. Check HX-Request header to know it's htmx
# 3. Return an HTML fragment (NOT a full page)

def get_users(request):
    if request.headers.get('HX-Request'):
        # Return just the fragment
        return render('partials/user-list.html', users=users)
    else:
        # Return full page for non-htmx requests
        return render('pages/users.html', users=users)

Request Headers (Sent by htmx)

Header Value
HX-Request true — identifies htmx requests
HX-Trigger ID of the element that triggered the request
HX-Trigger-Name Name attribute of the triggering element
HX-Target ID of the target element
HX-Current-URL The current URL of the browser
HX-Prompt User response from hx-prompt
HX-Boosted true if the request is from a boosted element

Response Headers (Set by Server)

Header Effect
HX-Redirect Client-side redirect to the specified URL
HX-Refresh Set to true to do a full page refresh
HX-Retarget Override the target element with a CSS selector
HX-Reswap Override the swap strategy
HX-Trigger Trigger client-side events after the response
HX-Trigger-After-Settle Trigger events after the settle phase
HX-Trigger-After-Swap Trigger events after the swap phase
HX-Push-Url Push a URL into the browser history
HX-Replace-Url Replace the current URL in the browser history

Out-of-Band (OOB) Swaps

Update multiple parts of the page from a single response. Any element in the response with hx-swap-oob="true" will be swapped into its matching ID, independent of the main target.

<!-- Main response (swapped into target) -->
<div>
  <p>Item saved successfully!</p>
</div>

<!-- OOB: update the nav counter -->
<span id="item-count" hx-swap-oob="true">
  42 items
</span>

<!-- OOB: update a notification area -->
<div id="notifications" hx-swap-oob="afterbegin">
  <div class="toast">Item saved!</div>
</div>
OOB Swap Values

hx-swap-oob="true" replaces the element by ID (outerHTML). You can also use any swap strategy: hx-swap-oob="beforeend", hx-swap-oob="innerHTML", etc.

Triggering Client Events from Server

# Response header triggers a custom event
HX-Trigger: showToast

# With event data (JSON)
HX-Trigger: {"showToast": {"message": "Saved!", "level": "success"}}

# Multiple events
HX-Trigger: {"closeModal": "", "refreshList": ""}
// Client-side listener for server-triggered events
document.addEventListener('showToast', (e) => {
  toast(e.detail.message, e.detail.level);
});
08

Boosting & Inheritance

Convert existing sites to SPA-like navigation with zero JavaScript. htmx attributes cascade down the DOM tree.

hx-boost

Progressively enhances standard links and forms to use AJAX instead of full page loads. The response replaces the <body> and updates the URL.

<!-- Boost all links and forms in this container -->
<div hx-boost="true">
  <a href="/about">About</a>          <!-- AJAX -->
  <a href="/contact">Contact</a>      <!-- AJAX -->
  <form action="/search">...</form>   <!-- AJAX -->
</div>

<!-- Boost the entire page -->
<body hx-boost="true">
  ...
</body>

<!-- Exclude specific links -->
<a href="/pdf-report" hx-boost="false">Download PDF</a>
Progressive Enhancement

Boosted pages work without JavaScript too. Links and forms still function as normal HTML. htmx intercepts them when present, but falls back gracefully.

History & Caching

<!-- Push URL into browser history -->
<a hx-get="/page" hx-push-url="true">Go</a>

<!-- Replace current URL (no new history entry) -->
<a hx-get="/page" hx-replace-url="true">Go</a>

<!-- Push a different URL than the request -->
<a hx-get="/api/page" hx-push-url="/page">Go</a>

<!-- Disable history snapshot caching -->
<meta name="htmx-config"
      content='{"historyCacheSize": 0}'>

Attribute Inheritance

Most htmx attributes inherit down the DOM tree. Set them on a parent to avoid repetition.

<!-- All buttons inherit target and indicator -->
<div hx-target="#results"
     hx-indicator="#spinner"
     hx-swap="innerHTML">

  <button hx-get="/search?q=a">A</button>
  <button hx-get="/search?q=b">B</button>
  <button hx-get="/search?q=c">C</button>
</div>

<!-- Override on specific elements -->
<div hx-target="#main">
  <button hx-get="/a">Uses #main</button>
  <button hx-get="/b" hx-target="#sidebar">Uses #sidebar</button>
</div>

Disabling Inheritance

<!-- Disable htmx on a subtree -->
<div hx-boost="true">
  <a href="/page">Boosted</a>

  <div hx-disable>
    <a href="/external">Normal link</a>
  </div>
</div>

<!-- Unset an inherited attribute -->
<div hx-target="unset">
  <!-- Back to default target behavior -->
</div>
09

Extensions

htmx extensions add capabilities beyond the core library. They are separate scripts loaded alongside htmx.

Loading Extensions

<!-- Load htmx core -->
<script src="https://unpkg.com/htmx.org@2.0.4"></script>

<!-- Load an extension -->
<script src="https://unpkg.com/htmx-ext-sse@2.2.2"></script>

<!-- Activate extension on an element or body -->
<div hx-ext="sse">...</div>

<!-- Multiple extensions -->
<body hx-ext="sse, ws, response-targets">

SSE (Server-Sent Events)

Real-time server push over HTTP. The server sends events, htmx swaps them into the DOM.

<script src="https://unpkg.com/htmx-ext-sse@2.2.2"></script>

<!-- Connect to SSE endpoint -->
<div hx-ext="sse"
     sse-connect="/events">

  <!-- Swap content on named events -->
  <div sse-swap="message">
    Waiting for messages...
  </div>

  <div sse-swap="notification"
       hx-swap="afterbegin">
    <!-- Notifications prepended here -->
  </div>
</div>
# Server-side (Python/Flask example):
@app.route('/events')
def events():
    def stream():
        while True:
            data = get_update()
            yield f"event: message\ndata: <p>{data}</p>\n\n"
    return Response(stream(), content_type='text/event-stream')

WebSocket

<script src="https://unpkg.com/htmx-ext-ws@2.0.1"></script>

<!-- Connect to WebSocket -->
<div hx-ext="ws"
     ws-connect="/ws/chat">

  <!-- Incoming messages swapped here -->
  <div id="chat-messages"></div>

  <!-- Form sends via WebSocket -->
  <form ws-send>
    <input name="message">
    <button type="submit">Send</button>
  </form>
</div>

Response Targets

Route responses to different targets based on HTTP status codes.

<script src="https://unpkg.com/htmx-ext-response-targets@2.0.1"></script>

<body hx-ext="response-targets">
  <form hx-post="/submit"
        hx-target="#result"
        hx-target-422="#errors"
        hx-target-5*="#server-error">
    ...
  </form>

  <div id="result"></div>
  <div id="errors"></div>
  <div id="server-error"></div>
</body>

Other Notable Extensions

head-support

Merges <head> tags from responses (CSS, title, meta).

hx-ext="head-support"

preload

Preloads linked pages on hover or viewport intersection.

hx-ext="preload"
preload="mousedown"

loading-states

Enhanced loading indicators with data attributes.

hx-ext="loading-states"
data-loading-disable

multi-swap

Swap multiple targets from a single response.

hx-ext="multi-swap"
hx-swap="multi:#a:innerHTML,#b:outerHTML"
10

Troubleshooting

Common issues and debugging techniques for htmx applications.

Enable Debug Logging

<!-- Log all htmx events to console -->
<script>
  htmx.logAll();
</script>

<!-- Log specific events only -->
<script>
  htmx.logger = function(elt, event, data) {
    if (event === 'htmx:afterSwap') {
      console.log('Swapped:', elt, data);
    }
  };
</script>

Common Gotchas

Nothing Happens on Click

Check the browser console. If you see a CORS error, htmx 2.x blocks cross-origin requests by default. Set htmx.config.selfRequestsOnly = false or use the meta tag config. Also verify htmx is loaded: type htmx.version in the console.

Response Visible But Not Swapped

Check the Content-Type header. htmx expects text/html. If your server returns application/json, htmx will not swap the content. Return text/html for htmx requests.

Form Data Not Sent

Inputs need name attributes. htmx uses the same form serialization as standard HTML forms. Also check that the hx-post or request attribute is on the <form> element (or use hx-include to pull in inputs).

Duplicate Requests

Likely a trigger issue. Buttons inside forms fire both click and submit. Use hx-trigger explicitly or put the htmx attribute on the form, not the button.

htmx Breaks After Swap

Scripts in swapped content are not re-executed by default. If you need to initialize JS on new content, listen for htmx:afterSwap or htmx:load and reinitialize.

Configuration

<!-- Configure htmx via meta tag -->
<meta name="htmx-config" content='{
  "selfRequestsOnly": false,
  "historyCacheSize": 10,
  "defaultSwapStyle": "innerHTML",
  "defaultSettleDelay": 20,
  "includeIndicatorStyles": true,
  "globalViewTransitions": false,
  "allowNestedOobSwaps": true,
  "scrollBehavior": "instant"
}'>

<!-- Or via JavaScript -->
<script>
  htmx.config.selfRequestsOnly = false;
  htmx.config.defaultSwapStyle = 'outerHTML';
  htmx.config.historyCacheSize = 0;
</script>

Useful htmx API Methods

Method Description
htmx.process(elt) Initialize htmx on dynamically added content
htmx.trigger(elt, event) Trigger an event on an element programmatically
htmx.ajax('GET', url, target) Issue an htmx-style AJAX request from JavaScript
htmx.find(selector) Shorthand for document.querySelector
htmx.findAll(selector) Shorthand for document.querySelectorAll
htmx.closest(elt, sel) Find closest ancestor matching selector
htmx.remove(elt) Remove an element from the DOM
htmx.addClass(elt, cls) Add a class (with optional delay)
htmx.version Returns the current htmx version string

Security Considerations

CSRF Protection

htmx sends the CSRF token automatically if you include it in a meta tag or cookie. For Django, include {% csrf_token %} in forms. For other frameworks, configure hx-headers or the htmx:configRequest event to add your token.

<!-- CSRF via meta tag (Rails, Django, etc.) -->
<meta name="csrf-token" content="abc123">

<script>
document.addEventListener('htmx:configRequest', (e) => {
  e.detail.headers['X-CSRF-Token'] =
    document.querySelector('meta[name="csrf-token"]').content;
});
</script>

<!-- Or set globally via hx-headers -->
<body hx-headers='{"X-CSRF-Token": "abc123"}'>