Skip to content
Unverified — AI-generated content. Help verify this page

htmx

htmx is a JavaScript library that gives HTML the ability to make HTTP requests, handle server responses, and update the DOM — all through HTML attributes, with no JavaScript required on your part. Instead of fetching JSON and rendering it client-side, htmx fetches HTML from the server and swaps it directly into the page.

This is not a step backward. It is a return to the hypermedia architecture that the web was built on, with modern capabilities (AJAX, WebSockets, SSE, CSS transitions) layered on top. The result is dramatically simpler applications: no client-side state management, no JSON serialization/deserialization, no component lifecycle, no build step.

htmx matters because it challenges the assumption that every web application needs a JavaScript framework. For many applications — admin panels, dashboards, CRUD apps, content sites with interactive elements — htmx with a server-side language produces the same UX with a fraction of the complexity.

Related: Rendering Strategies | Web Performance | State Management


The Hypermedia Approach

How Traditional SPAs Work

How htmx Works

The key insight: the server returns HTML, not JSON. The server does the rendering. The client does the swapping. There is no client-side application state to synchronize.

Why This Matters

SPA Approachhtmx Approach
Server returns JSONServer returns HTML
Client renders UIServer renders UI
Client manages stateServer is the source of truth
Requires framework (React, Vue, etc.)Requires HTML attributes
JSON serialization/deserializationNo serialization
Complex build pipelineNo build step (or optional)
Full page data in memoryOnly visible DOM in memory
50-500KB JS framework14KB htmx library

Getting Started

Installation

html
<!-- CDN (simplest) -->
<script src="https://unpkg.com/htmx.org@2.0.4"></script>

<!-- Or download and self-host -->
<script src="/static/htmx.min.js"></script>

No build step. No npm install. No webpack config. One script tag.

Your First htmx Interaction

html
<!-- A button that loads content from the server -->
<button hx-get="/greeting" hx-target="#output" hx-swap="innerHTML">
  Click me
</button>
<div id="output"></div>

When clicked, htmx:

  1. Makes a GET request to /greeting
  2. Takes the HTML response
  3. Swaps it into #output as innerHTML

Server response for /greeting:

html
<p>Hello! The time is 14:32:07</p>

That is the entire implementation. No JavaScript written.


Core Attributes

HTTP Method Attributes

html
<!-- GET — fetch and display data -->
<button hx-get="/users">Load Users</button>

<!-- POST — create data -->
<form hx-post="/users">
  <input name="name" type="text" />
  <button type="submit">Create User</button>
</form>

<!-- PUT — update data -->
<form hx-put="/users/1">
  <input name="name" type="text" value="Alice" />
  <button type="submit">Update</button>
</form>

<!-- DELETE — remove data -->
<button hx-delete="/users/1">Delete User</button>

<!-- PATCH — partial update -->
<form hx-patch="/users/1">
  <input name="email" type="email" />
  <button type="submit">Update Email</button>
</form>

hx-target — Where to Put the Response

html
<!-- Default: replace the element that triggered the request -->
<button hx-get="/data">Replaces this button</button>

<!-- Target a specific element by CSS selector -->
<button hx-get="/users" hx-target="#user-list">Load Users</button>
<div id="user-list"></div>

<!-- Target the closest ancestor matching a selector -->
<button hx-get="/row" hx-target="closest tr">Update Row</button>

<!-- Target the next sibling -->
<button hx-get="/details" hx-target="next .details">Show Details</button>

<!-- Target the parent -->
<button hx-get="/content" hx-target="closest div">Replace Parent</button>

<!-- Target the body (full page swap) -->
<a hx-get="/page" hx-target="body">Navigate</a>

hx-swap — How to Swap the Response

html
<!-- innerHTML (default) — replace children -->
<div hx-get="/content" hx-swap="innerHTML">
  <!-- Response replaces everything inside this div -->
</div>

<!-- outerHTML — replace the entire element -->
<div hx-get="/content" hx-swap="outerHTML">
  <!-- This entire div is replaced by the response -->
</div>

<!-- beforebegin — insert before the element -->
<div hx-get="/item" hx-swap="beforebegin">
  <!-- Response HTML appears before this div -->
</div>

<!-- afterbegin — insert at start of children -->
<ul hx-get="/item" hx-swap="afterbegin">
  <!-- Response HTML inserted as first child -->
  <li>Existing item</li>
</ul>

<!-- beforeend — insert at end of children -->
<ul hx-get="/item" hx-swap="beforeend">
  <li>Existing item</li>
  <!-- Response HTML inserted as last child -->
</ul>

<!-- afterend — insert after the element -->
<div hx-get="/item" hx-swap="afterend">
  <!-- This div stays; response HTML appears after it -->
</div>

<!-- delete — remove the target element -->
<button hx-delete="/users/1" hx-swap="delete" hx-target="closest tr">
  Delete
</button>

<!-- none — make request but do not swap anything -->
<button hx-post="/analytics/click" hx-swap="none">
  Track Click
</button>

Swap Modifiers

html
<!-- Swap with transition (CSS animation) -->
<div hx-get="/content" hx-swap="innerHTML transition:true"></div>

<!-- Swap after a delay -->
<div hx-get="/content" hx-swap="innerHTML swap:300ms"></div>

<!-- Settle delay (time between swap and settling CSS classes) -->
<div hx-get="/content" hx-swap="innerHTML settle:500ms"></div>

<!-- Scroll behavior after swap -->
<div hx-get="/content" hx-swap="innerHTML scroll:top"></div>
<div hx-get="/content" hx-swap="innerHTML show:#target:top"></div>

<!-- Focus scroll — scroll to the focused element -->
<div hx-get="/content" hx-swap="innerHTML focus-scroll:true"></div>

hx-trigger — When to Make the Request

html
<!-- Default triggers: click for buttons/links, submit for forms, change for inputs -->
<button hx-get="/data">Click triggers GET</button>
<input hx-get="/search" hx-trigger="keyup" name="q" />

<!-- Custom events -->
<div hx-get="/data" hx-trigger="myEvent">Waiting for custom event</div>

<!-- Modifier: delay (debounce) -->
<input hx-get="/search" hx-trigger="keyup changed delay:300ms" name="q" />

<!-- Modifier: throttle -->
<div hx-get="/position" hx-trigger="mousemove throttle:100ms"></div>

<!-- Modifier: once (fire only once) -->
<button hx-get="/init" hx-trigger="click once">Initialize</button>

<!-- Multiple triggers -->
<input hx-get="/validate"
       hx-trigger="keyup changed delay:500ms, blur" name="email" />

<!-- Load trigger — fire on page load -->
<div hx-get="/dashboard-data" hx-trigger="load">
  Loading...
</div>

<!-- Revealed trigger — fire when element enters viewport -->
<div hx-get="/lazy-content" hx-trigger="revealed">
  <!-- Loads when scrolled into view -->
</div>

<!-- Intersect trigger — IntersectionObserver-based -->
<div hx-get="/content" hx-trigger="intersect threshold:0.5">
  <!-- Loads when 50% visible -->
</div>

<!-- Every N seconds (polling) -->
<div hx-get="/notifications" hx-trigger="every 5s">
  <!-- Polls every 5 seconds -->
</div>

<!-- From another element -->
<input id="search" name="q" type="text"
       hx-get="/search" hx-trigger="keyup changed delay:300ms"
       hx-target="#results" />
<div id="results"></div>

Common Patterns

Live search that updates results as the user types:

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

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

<table>
  <thead>
    <tr><th>Name</th><th>Email</th><th>Role</th></tr>
  </thead>
  <tbody id="results">
    <!-- Server returns <tr> rows here -->
  </tbody>
</table>

Server (Python/Flask example):

python
@app.route("/search")
def search():
    q = request.args.get("q", "")
    users = User.query.filter(User.name.ilike(f"%{q}%")).limit(20).all()
    return render_template("partials/user_rows.html", users=users)
html
<!-- partials/user_rows.html -->
{% for user in users %}
<tr>
  <td>{{ user.name }}</td>
  <td>{{ user.email }}</td>
  <td>{{ user.role }}</td>
</tr>
{% endfor %}

Infinite Scroll

html
<table>
  <tbody id="user-list">
    <!-- Initial rows loaded normally -->
    <tr><td>User 1</td></tr>
    <tr><td>User 2</td></tr>
    <!-- ... -->

    <!-- Sentinel row: triggers load when scrolled into view -->
    <tr hx-get="/users?page=2"
        hx-trigger="revealed"
        hx-swap="afterend"
        hx-target="this">
      <td>Loading more...</td>
    </tr>
  </tbody>
</table>

The server returns the next page of rows plus a new sentinel:

html
<!-- Response from /users?page=2 -->
<tr><td>User 21</td></tr>
<tr><td>User 22</td></tr>
<!-- ... -->
<tr hx-get="/users?page=3"
    hx-trigger="revealed"
    hx-swap="afterend"
    hx-target="this">
  <td>Loading more...</td>
</tr>

When there are no more results, the server returns the last rows without a new sentinel, and scrolling stops.

Click-to-Edit

html
<!-- Display mode -->
<div id="user-1" hx-target="this" hx-swap="outerHTML">
  <p><strong>Alice Johnson</strong></p>
  <p>alice@example.com</p>
  <button hx-get="/users/1/edit">Edit</button>
</div>

When "Edit" is clicked, the server returns the edit form:

html
<!-- Response from GET /users/1/edit -->
<form id="user-1" hx-put="/users/1" hx-target="this" hx-swap="outerHTML">
  <input name="name" value="Alice Johnson" />
  <input name="email" value="alice@example.com" />
  <button type="submit">Save</button>
  <button hx-get="/users/1" type="button">Cancel</button>
</form>

On save, the server validates and returns the display mode again:

html
<!-- Response from PUT /users/1 -->
<div id="user-1" hx-target="this" hx-swap="outerHTML">
  <p><strong>Alice Johnson</strong></p>
  <p>alice@newmail.com</p>
  <button hx-get="/users/1/edit">Edit</button>
</div>
html
<!-- Trigger -->
<button hx-get="/modals/confirm-delete"
        hx-target="#modal-container"
        hx-swap="innerHTML">
  Delete Account
</button>

<div id="modal-container"></div>

Server returns the modal HTML:

html
<!-- Response from GET /modals/confirm-delete -->
<div class="modal-backdrop" _="on click remove me">
  <div class="modal" _="on click halt">
    <h2>Confirm Deletion</h2>
    <p>This action cannot be undone.</p>
    <button hx-delete="/users/current"
            hx-target="#modal-container"
            hx-swap="innerHTML">
      Confirm Delete
    </button>
    <button _="on click remove closest .modal-backdrop">
      Cancel
    </button>
  </div>
</div>

The underscore attributes

The _ attributes above use Hyperscript, an optional companion to htmx for small client-side behaviors. You can also use vanilla JavaScript event listeners or Alpine.js instead.

Inline Validation

html
<form hx-post="/register">
  <label>
    Username
    <input name="username" type="text"
           hx-get="/validate/username"
           hx-trigger="blur changed"
           hx-target="next .error"
           hx-swap="innerHTML" />
    <span class="error"></span>
  </label>

  <label>
    Email
    <input name="email" type="email"
           hx-get="/validate/email"
           hx-trigger="blur changed"
           hx-target="next .error"
           hx-swap="innerHTML" />
    <span class="error"></span>
  </label>

  <button type="submit">Register</button>
</form>

Server validation endpoint:

python
@app.route("/validate/username")
def validate_username():
    username = request.args.get("username", "")
    if len(username) < 3:
        return '<span class="error text-red">Username must be at least 3 characters</span>'
    if User.query.filter_by(username=username).first():
        return '<span class="error text-red">Username already taken</span>'
    return '<span class="error text-green">Username available</span>'

Bulk Operations

html
<form hx-delete="/users/bulk"
      hx-target="#user-list"
      hx-swap="innerHTML"
      hx-confirm="Delete selected users?">

  <button type="submit">Delete Selected</button>

  <table>
    <thead>
      <tr>
        <th><input type="checkbox" onclick="toggleAll(this)" /></th>
        <th>Name</th>
        <th>Email</th>
      </tr>
    </thead>
    <tbody id="user-list">
      <tr>
        <td><input type="checkbox" name="ids" value="1" /></td>
        <td>Alice</td>
        <td>alice@example.com</td>
      </tr>
      <tr>
        <td><input type="checkbox" name="ids" value="2" /></td>
        <td>Bob</td>
        <td>bob@example.com</td>
      </tr>
    </tbody>
  </table>
</form>

Tabs

html
<div class="tabs" hx-target="#tab-content" hx-swap="innerHTML">
  <button hx-get="/tabs/overview" class="active">Overview</button>
  <button hx-get="/tabs/settings">Settings</button>
  <button hx-get="/tabs/billing">Billing</button>
</div>

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

Request and Response Headers

Request Headers (sent by htmx)

htmx adds headers so the server knows it is an htmx request:

HeaderValue
HX-Requesttrue (always present on htmx requests)
HX-TargetThe id of the target element
HX-TriggerThe id of the triggered element
HX-Trigger-NameThe name of the triggered element
HX-Current-URLThe current browser URL
HX-PromptThe user's response to hx-prompt
HX-Boostedtrue if the request is via a boosted element
python
# Server-side: detect htmx requests
@app.route("/users")
def users():
    if request.headers.get("HX-Request"):
        # htmx request — return partial HTML
        return render_template("partials/user_list.html", users=get_users())
    else:
        # Regular request — return full page
        return render_template("users.html", users=get_users())

Response Headers (sent by server)

The server can control htmx behavior through response headers:

python
from flask import make_response

@app.route("/users", methods=["POST"])
def create_user():
    user = create_user_from_form(request.form)
    response = make_response(render_template("partials/user_row.html", user=user))

    # Trigger a client-side event
    response.headers["HX-Trigger"] = "userCreated"

    # Redirect (htmx will follow it)
    # response.headers["HX-Redirect"] = "/users"

    # Push a new URL to the browser history
    response.headers["HX-Push-Url"] = f"/users/{user.id}"

    # Retarget the response to a different element
    # response.headers["HX-Retarget"] = "#notification-area"

    # Reswap — change the swap method
    # response.headers["HX-Reswap"] = "afterbegin"

    return response
Response HeaderEffect
HX-TriggerTrigger a client-side event (e.g., to update other parts of the page)
HX-RedirectRedirect to a new URL (full page navigation)
HX-Push-UrlPush a URL into the browser history
HX-Replace-UrlReplace the current URL in history (no new entry)
HX-RetargetChange the swap target
HX-ReswapChange the swap strategy
HX-RefreshRefresh the full page

htmx with Server-Side Frameworks

Python / Flask

python
from flask import Flask, render_template, request

app = Flask(__name__)

@app.route("/")
def index():
    return render_template("index.html")

@app.route("/contacts")
def contacts():
    q = request.args.get("q", "")
    contacts = Contact.query.filter(Contact.name.ilike(f"%{q}%")).all()

    # Return partial for htmx, full page for normal requests
    if request.headers.get("HX-Request"):
        return render_template("partials/contact_list.html", contacts=contacts)
    return render_template("contacts.html", contacts=contacts)

@app.route("/contacts", methods=["POST"])
def create_contact():
    contact = Contact(
        name=request.form["name"],
        email=request.form["email"],
    )
    db.session.add(contact)
    db.session.commit()

    response = make_response(
        render_template("partials/contact_row.html", contact=contact)
    )
    response.headers["HX-Trigger"] = "contactCreated"
    return response

Go / net/http

go
package main

import (
    "html/template"
    "net/http"
)

var tmpl = template.Must(template.ParseGlob("templates/*.html"))

func usersHandler(w http.ResponseWriter, r *http.Request) {
    users := getUsers()

    // Check if htmx request
    if r.Header.Get("HX-Request") == "true" {
        tmpl.ExecuteTemplate(w, "user_list_partial", users)
        return
    }
    tmpl.ExecuteTemplate(w, "users_page", users)
}

func createUserHandler(w http.ResponseWriter, r *http.Request) {
    r.ParseForm()
    user := createUser(r.Form.Get("name"), r.Form.Get("email"))

    w.Header().Set("HX-Trigger", "userCreated")
    tmpl.ExecuteTemplate(w, "user_row", user)
}

func main() {
    http.HandleFunc("/users", usersHandler)
    http.HandleFunc("/users/create", createUserHandler)
    http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
    http.ListenAndServe(":8080", nil)
}

Node.js / Express

javascript
import express from "express";
import nunjucks from "nunjucks";

const app = express();
app.use(express.urlencoded({ extended: true }));

nunjucks.configure("views", { autoescape: true, express: app });

// Middleware to detect htmx requests
app.use((req, res, next) => {
  req.isHtmx = req.headers["hx-request"] === "true";
  next();
});

app.get("/todos", (req, res) => {
  const todos = getTodos();
  if (req.isHtmx) {
    return res.render("partials/todo_list.njk", { todos });
  }
  res.render("todos.njk", { todos });
});

app.post("/todos", (req, res) => {
  const todo = createTodo(req.body.title);
  res.set("HX-Trigger", "todoCreated");
  res.render("partials/todo_item.njk", { todo });
});

app.delete("/todos/:id", (req, res) => {
  deleteTodo(req.params.id);
  res.send(""); // Empty response — htmx swap:delete handles removal
});

app.listen(3000);

WebSocket and SSE Support

Server-Sent Events (SSE)

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

<div hx-ext="sse" sse-connect="/events">
  <!-- This div updates when the server sends a "notification" event -->
  <div sse-swap="notification"></div>

  <!-- Multiple event types -->
  <div sse-swap="user-update"></div>
  <div sse-swap="system-alert"></div>
</div>

Server (Node.js):

javascript
app.get("/events", (req, res) => {
  res.setHeader("Content-Type", "text/event-stream");
  res.setHeader("Cache-Control", "no-cache");
  res.setHeader("Connection", "keep-alive");

  // Send notifications
  const interval = setInterval(() => {
    const count = getUnreadCount();
    res.write(`event: notification\n`);
    res.write(`data: <span class="badge">${count}</span>\n\n`);
  }, 5000);

  req.on("close", () => clearInterval(interval));
});

WebSocket

html
<!-- htmx WebSocket extension -->
<script src="https://unpkg.com/htmx-ext-ws@2.0.1/ws.js"></script>

<div hx-ext="ws" ws-connect="/chat">
  <!-- Messages appear here -->
  <div id="messages"></div>

  <!-- This form sends via WebSocket instead of HTTP -->
  <form ws-send>
    <input name="message" type="text" />
    <button type="submit">Send</button>
  </form>
</div>

Server response (sent via WebSocket) targets #messages:

html
<div id="messages" hx-swap-oob="beforeend">
  <p><strong>Alice:</strong> Hello, world!</p>
</div>

Out-of-Band Swaps

Out-of-band (OOB) swaps let a single response update multiple parts of the page:

html
<!-- Main response (swapped into hx-target as usual) -->
<tr id="user-row-1">
  <td>Alice (updated)</td>
  <td>alice@newmail.com</td>
</tr>

<!-- OOB swap — also update the notification count -->
<span id="notification-count" hx-swap-oob="true">3</span>

<!-- OOB swap — also update the last-modified timestamp -->
<span id="last-updated" hx-swap-oob="true">Just now</span>

A single server response can update the main target PLUS any number of OOB elements. This is how you keep multiple page regions in sync without client-side state.


hx-boost turns regular links and forms into AJAX requests, giving SPA-like navigation without writing any JavaScript:

html
<!-- Boost all links and forms within this container -->
<body hx-boost="true">
  <!-- These links now use AJAX + history push instead of full page loads -->
  <nav>
    <a href="/dashboard">Dashboard</a>
    <a href="/settings">Settings</a>
    <a href="/profile">Profile</a>
  </nav>

  <main id="content">
    <!-- Page content swapped here -->
  </main>

  <!-- This form submits via AJAX too -->
  <form action="/search" method="get">
    <input name="q" type="text" />
    <button type="submit">Search</button>
  </form>
</body>

Boosted links:

  • Make an AJAX request to the link's href
  • Swap the <body> content (or a custom target)
  • Push the URL to browser history
  • Merge <head> elements (CSS, meta tags)
  • Fall back to full page navigation if JavaScript is disabled

This is progressive enhancement — the page works without htmx as a normal multi-page site, and htmx adds SPA-like transitions on top.


CSS Transitions

htmx integrates with CSS transitions through class swapping:

html
<style>
  .fade-in {
    opacity: 0;
    transition: opacity 0.3s ease-in;
  }
  .fade-in.htmx-settling {
    opacity: 1;
  }
</style>

<div hx-get="/content"
     hx-swap="innerHTML transition:true"
     hx-target="#content">
  Load Content
</div>

htmx CSS Classes

ClassWhen Applied
htmx-requestDuring the AJAX request
htmx-settlingAfter the content is swapped, during the settle period
htmx-swappingDuring the swap
htmx-addedApplied to new elements added to the DOM
htmx-indicatorShown during requests (use with hx-indicator)

Loading Indicators

html
<button hx-get="/slow-data"
        hx-indicator="#loading">
  Load Data
</button>

<!-- Hidden by default, shown during request -->
<span id="loading" class="htmx-indicator">
  <svg class="spinner" viewBox="0 0 50 50">
    <circle cx="25" cy="25" r="20" fill="none" stroke-width="5" />
  </svg>
  Loading...
</span>

<style>
  .htmx-indicator {
    display: none;
  }
  .htmx-request .htmx-indicator,
  .htmx-request.htmx-indicator {
    display: inline-block;
  }
</style>

htmx + Alpine.js

Alpine.js is the perfect companion for htmx — htmx handles server communication, Alpine handles client-side interactivity:

html
<!-- htmx for server data, Alpine for UI state -->
<div x-data="{ open: false }">
  <button @click="open = !open">Toggle Panel</button>

  <div x-show="open" x-transition>
    <!-- htmx loads content from server -->
    <div hx-get="/panel-content"
         hx-trigger="load"
         hx-target="this">
      Loading...
    </div>
  </div>
</div>

<!-- Alpine for client-only state (dropdown, accordion, etc.) -->
<!-- htmx for anything that needs server data -->

When to Use Alpine vs htmx

BehaviorTool
Toggle visibility, dropdowns, accordionsAlpine.js
Fetch data from serverhtmx
Client-side form validation (before submit)Alpine.js
Server-side form validation (on blur)htmx
Drag and drop reorderingAlpine.js (or SortableJS)
Persist reorder to databasehtmx
Animation/transitionsAlpine.js or CSS
Real-time updates (SSE/WS)htmx

htmx vs React/Vue/Svelte

Complexity Comparison

Feature-by-Feature Comparison

FeatureReact/Vue/Sveltehtmx
Bundle size40-150KB (framework) + app code14KB (htmx)
Build stepRequired (webpack/Vite)None
State managementClient-side (complex)Server is the state
SEORequires SSR/SSGServer-rendered by default
Time to interactiveHydration delayInstant (no hydration)
Offline supportService Workers + stateLimited (server required)
Real-time updatesWebSocket + state syncSSE/WS with OOB swaps
Complex UI (drag-drop, charts)Excellent ecosystemWeak (need JS libraries)
Learning curveSteep (JSX, hooks, state, effects)Shallow (HTML attributes)
Team size for maintenanceNeeds frontend specialistsFull-stack devs can handle it
TestingUnit + integration + E2EServer tests + E2E (simpler)

Not a Binary Choice

You can use htmx for 80% of your app (CRUD, forms, lists, navigation) and React/Vue for the 20% that genuinely needs rich client-side interaction (data visualization, real-time collaboration, drag-and-drop). htmx and JS frameworks can coexist on the same page.


Progressive Enhancement

htmx embraces progressive enhancement — your app should work without JavaScript, and htmx enhances the experience:

html
<!-- Without htmx: normal link navigation -->
<!-- With htmx: AJAX swap -->
<a href="/users"
   hx-get="/users"
   hx-target="#content"
   hx-push-url="true">
  Users
</a>

<!-- Without htmx: normal form submit + redirect -->
<!-- With htmx: AJAX submit + swap -->
<form action="/users" method="post"
      hx-post="/users"
      hx-target="#user-list"
      hx-swap="beforeend">
  <input name="name" required />
  <button type="submit">Add User</button>
</form>

The action and href attributes provide the fallback. If JavaScript is disabled or htmx fails to load, the page still works — just with full page navigations instead of AJAX swaps.


Configuration and Extensions

Global Configuration

html
<meta name="htmx-config" content='{
  "defaultSwapStyle": "innerHTML",
  "defaultSettleDelay": 20,
  "includeIndicatorStyles": true,
  "historyCacheSize": 10,
  "refreshOnHistoryMiss": true,
  "selfRequestsOnly": true,
  "scrollBehavior": "instant"
}' />

Useful Extensions

ExtensionPurpose
sseServer-Sent Events integration
wsWebSocket integration
json-encSend request body as JSON instead of form-encoded
loading-statesEnhanced loading state management
preloadPreload links on hover for faster navigation
head-supportMerge <head> elements on boosted navigation
response-targetsDifferent targets for different HTTP status codes
html
<!-- json-enc: send JSON bodies -->
<form hx-post="/api/users" hx-ext="json-enc">
  <input name="name" />
  <input name="email" />
  <button type="submit">Create</button>
</form>
<!-- Sends: {"name": "Alice", "email": "alice@example.com"} -->

<!-- response-targets: handle errors differently -->
<form hx-post="/api/users"
      hx-ext="response-targets"
      hx-target-422="#form-errors"
      hx-target-500="#server-error">
  <!-- ... -->
</form>
<div id="form-errors"></div>
<div id="server-error"></div>

<!-- preload: prefetch on hover -->
<div hx-ext="preload">
  <a href="/page" preload="mousedown">Fast Link</a>
</div>

Security Considerations

CSRF Protection

html
<!-- Include CSRF token in all htmx requests -->
<meta name="csrf-token" content="abc123xyz" />

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

Restricting Request Targets

html
<!-- Only allow htmx to make requests to your own domain -->
<meta name="htmx-config" content='{"selfRequestsOnly": true}' />

Content Security Policy

htmx does not use eval() or inline scripts, so it works with strict CSP policies:

Content-Security-Policy: default-src 'self'; script-src 'self' https://unpkg.com

Key Takeaway

  • htmx returns web development to the hypermedia architecture: the server renders HTML, the client swaps it into the DOM, and there is no client-side application state to manage.
  • For CRUD applications, admin panels, and content-driven sites, htmx + a server-side language produces equivalent UX to a React SPA with a fraction of the complexity, dependencies, and JavaScript shipped to the client.
  • htmx is not an anti-JavaScript stance — it is a recognition that most web applications do not need the complexity of a client-side rendering framework, and that the server-rendered HTML model scales remarkably well when augmented with targeted AJAX.

Common Misconceptions

  • "htmx is only for simple CRUD apps." htmx handles infinite scroll, real-time updates (SSE/WebSocket), active search, drag-and-drop (with SortableJS), modals, tabs, inline editing, and complex multi-step wizards. Most "complex" UI patterns are simpler than people assume.
  • "htmx is slow because it makes more HTTP requests." Each htmx request returns a small HTML fragment (not a full page), and the server does not need to serialize JSON + the client does not need to deserialize and render it. The total round-trip is often faster than a JSON API + client-side rendering.
  • "htmx cannot do real-time." htmx supports Server-Sent Events and WebSockets through extensions, with out-of-band swaps enabling multiple DOM updates from a single server event.
  • "You cannot build large applications with htmx." htmx is used in production at companies processing millions of requests. The constraint is not scale — it is application type. Collaborative real-time editors (Google Docs), complex data visualization dashboards, and offline-first apps are better served by client-side frameworks.
  • "htmx means writing no JavaScript at all." htmx minimizes custom JavaScript but does not eliminate it entirely. Client-side interactions (tooltips, drag-drop, character counters, animations) still benefit from small amounts of JavaScript — Alpine.js or vanilla JS fills this gap.
  • "htmx is a framework." htmx is a 14KB library with no dependencies, no build step, no CLI, no file-based routing, and no opinions about your server stack. It extends HTML's capabilities. Calling it a framework overstates its scope.

When NOT to Use htmx

  • Offline-first applications — htmx requires a server for every interaction. If your users need the app to work without network connectivity (field workers, mobile apps in poor coverage), you need client-side state and a sync mechanism.
  • Real-time collaborative editing — Google Docs-style applications need operational transforms or CRDTs with persistent WebSocket connections and complex conflict resolution. This is fundamentally a client-side problem.
  • Heavy data visualization — D3.js charts, complex dashboards with client-side filtering/sorting across thousands of data points, and interactive maps require significant client-side JavaScript regardless of your architecture.
  • Native mobile apps — If you are building for iOS/Android with React Native or Flutter, htmx is irrelevant. It is a web technology.
  • SEO is not a concern AND you need maximum interactivity — For authenticated dashboards with complex drag-drop, real-time presence, and collaborative features, a React/Vue SPA may offer a better developer experience (with the understanding that the complexity cost is justified).

In Production

  • GitHub — While not using htmx specifically, GitHub uses the same architectural pattern (server-rendered HTML fragments via Turbo/Hotwire) for their entire web application, proving that hypermedia-driven development scales to one of the largest web apps in the world.
  • Contexte — A media monitoring platform that migrated from React to htmx, reporting 67% less code, faster page loads, and reduced infrastructure costs because they no longer needed a separate API layer.
  • MotherDuck — The serverless DuckDB analytics platform uses htmx for their dashboard UI.
  • Talkiatry — A behavioral health platform serving thousands of patients with htmx + Django, choosing it for its simplicity and the ability to have full-stack developers instead of separate frontend/backend teams.
  • The OpenUniversity — Uses htmx for their learning platform, serving hundreds of thousands of students.
Quiz

1. What does htmx return from the server, and how does this differ from a typical React SPA?

Answer

htmx returns HTML fragments from the server. A typical React SPA returns JSON data from an API, which the client-side JavaScript framework then renders into DOM elements. With htmx, the server does the rendering (using server-side templates), and htmx simply swaps the HTML into the correct location in the DOM. This eliminates client-side state management, JSON serialization/deserialization, and the need for a JavaScript framework.

2. What is the difference between hx-swap="innerHTML" and hx-swap="outerHTML"?

Answer

innerHTML replaces only the children of the target element — the target element itself remains in the DOM. outerHTML replaces the entire target element, including itself, with the response HTML. Use innerHTML when you want to update content within a container. Use outerHTML when you want to replace the entire element (common in click-to-edit patterns where a display div is replaced by a form, and vice versa).

3. How do out-of-band (OOB) swaps work, and when would you use them?

Answer

OOB swaps let a single server response update multiple parts of the page. Elements in the response with hx-swap-oob="true" are matched by their id attribute and swapped into the corresponding element on the page, independent of the main hx-target. Use them when a single action needs to update multiple UI regions — for example, adding a todo should update the todo list (main target) AND the total count badge (OOB swap) AND the "last updated" timestamp (another OOB swap).

4. How does hx-boost provide progressive enhancement?

Answer

hx-boost="true" converts regular <a> links and <form> elements into AJAX requests. The links and forms retain their standard href and action attributes, so they work as normal full-page navigations when JavaScript is disabled. When htmx is active, it intercepts the navigation, makes an AJAX request, swaps the body content, and pushes the URL to browser history — giving SPA-like navigation without any custom JavaScript.

5. When should you choose htmx over React, and when should you choose React over htmx?

Answer

Choose htmx when: building CRUD apps, admin panels, content sites, or any server-rendered application where interactions are primarily form submissions and data display. The benefits are less code, no build step, no client-side state, and full-stack developers can own the entire feature. Choose React when: building offline-first apps, real-time collaborative tools, heavy data visualization, complex drag-and-drop interfaces, or any application where significant computation and state management must happen on the client. The two are not mutually exclusive — you can use htmx for most of the app and embed React components for the parts that genuinely need it.

:::

Exercise

Build a Contact Manager with htmx

Using htmx and any server-side language (Python/Flask, Go, Node.js/Express, Ruby/Sinatra):

  1. Create a contacts page with a searchable, sortable table of contacts
  2. Implement active search: typing in a search box filters results with 300ms debounce
  3. Add click-to-edit for each contact row (inline editing without page navigation)
  4. Implement "Add Contact" as a modal that slides in from the right
  5. Add bulk delete with checkbox selection and a confirmation dialog
  6. Use SSE to show a "Last updated: X seconds ago" live counter
  7. Ensure the app works (basic functionality) with JavaScript disabled (progressive enhancement)
Solution Outline
html
<!-- index.html -->
<body hx-boost="true">
  <h1>Contacts</h1>

  <input type="search" name="q" placeholder="Search..."
         hx-get="/contacts"
         hx-trigger="input changed delay:300ms"
         hx-target="#contact-table"
         hx-indicator="#search-spinner" />
  <span id="search-spinner" class="htmx-indicator">Searching...</span>

  <button hx-get="/contacts/new"
          hx-target="#modal"
          hx-swap="innerHTML">
    Add Contact
  </button>

  <div id="modal"></div>

  <form hx-delete="/contacts/bulk" hx-target="#contact-table"
        hx-confirm="Delete selected contacts?">
    <button type="submit">Delete Selected</button>
    <table>
      <thead>
        <tr>
          <th><input type="checkbox" onclick="toggleAll(this)" /></th>
          <th hx-get="/contacts?sort=name" hx-target="#contact-table">Name</th>
          <th hx-get="/contacts?sort=email" hx-target="#contact-table">Email</th>
          <th>Actions</th>
        </tr>
      </thead>
      <tbody id="contact-table">
        <!-- Server renders rows here -->
      </tbody>
    </table>
  </form>

  <div hx-ext="sse" sse-connect="/events">
    <span sse-swap="last-updated">Connected</span>
  </div>
</body>

Each server route returns HTML fragments. The contact row has a click-to-edit pattern: hx-get="/contacts/1/edit" returns a form, and hx-put="/contacts/1" returns the display row. The modal is an OOB-compatible HTML fragment. The SSE endpoint sends timestamped HTML every 5 seconds.

:::

One-Liner Summary: htmx returns web development to its hypermedia roots — the server renders HTML, the browser swaps it into the DOM, and the 90% of applications that do not need a client-side framework can finally stop pretending they do.

Last updated: 2026-04-04

"What I cannot create, I do not understand." — Richard Feynman