htmx: The Complete Guide for 2026

February 12, 202625 min read

htmx lets you build modern, dynamic web interfaces using HTML attributes instead of JavaScript. No build step. No virtual DOM. No state management library. You add a few attributes to your HTML, and your server returns HTML fragments that htmx swaps into the page. It is the return of hypermedia-driven development — and it is gaining massive traction because it dramatically simplifies web architecture.

This guide covers everything from installation to production patterns. Every section includes working code examples you can copy into your project today.

⚙ Related resources: Learn DOM manipulation in our JavaScript DOM Guide, design clean endpoints with the REST API Design Guide, master server-side rendering with the Flask Guide, and build forms with the HTML Forms Guide.

1. What Is htmx? Why Hypermedia-Driven Development

htmx extends HTML as a hypertext by giving any element the ability to make HTTP requests and update the DOM. Traditional HTML limits this to anchor tags (GET) and forms (GET/POST). htmx removes those constraints. Any element can issue any HTTP method, target any part of the page, and swap content using multiple strategies.

The philosophy is called Hypermedia-Driven Application (HDA) architecture. Instead of your server returning JSON that a JavaScript framework renders into HTML, your server returns the HTML directly. The server is the source of truth for both data and presentation. This eliminates an entire layer of complexity: no client-side routing, no state synchronization, no serialization/deserialization, and no build tooling.

Key benefits

2. Installation & Setup

CDN (fastest start)

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

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

npm

npm install htmx.org
// Then in your JS entry point:
import 'htmx.org';

Minimal page template

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>My htmx App</title>
    <script src="https://unpkg.com/htmx.org@2.0.4"></script>
</head>
<body>
    <button hx-get="/api/greeting" hx-target="#output">Say Hello</button>
    <div id="output"></div>
</body>
</html>

Click the button, htmx sends a GET to /api/greeting, and swaps the response into #output. That is the core pattern.

3. Core Attributes

htmx provides one attribute per HTTP method:

AttributeHTTP MethodUse Case
hx-getGETFetch and display data
hx-postPOSTSubmit forms, create resources
hx-putPUTReplace a resource entirely
hx-patchPATCHPartially update a resource
hx-deleteDELETERemove a resource
<!-- GET: load user profile on page load -->
<div hx-get="/users/42" hx-trigger="load">Loading profile...</div>

<!-- POST: create a new comment, append to list -->
<form hx-post="/comments" hx-target="#comment-list" hx-swap="beforeend">
    <textarea name="body"></textarea>
    <button type="submit">Add Comment</button>
</form>

<!-- PUT: replace a todo item in place -->
<form hx-put="/todos/7" hx-target="this" hx-swap="outerHTML">
    <input name="title" value="Buy groceries">
    <button type="submit">Save</button>
</form>

<!-- DELETE: remove with confirmation -->
<button hx-delete="/todos/7" hx-target="closest tr"
        hx-swap="outerHTML" hx-confirm="Delete this item?">Remove</button>

4. Targeting Elements: hx-target & hx-swap

hx-target tells htmx where to place the response. It accepts any CSS selector, plus special htmx values:

<button hx-get="/stats" hx-target="#dashboard">Refresh</button>         <!-- by ID -->
<div hx-get="/widget" hx-target="this">Loading...</div>                 <!-- self -->
<button hx-delete="/items/5" hx-target="closest .item-row">Delete</button> <!-- parent -->
<button hx-get="/preview" hx-target="next .preview-panel">Preview</button> <!-- sibling -->
<div hx-get="/content" hx-target="find .content-area">Load</div>       <!-- descendant -->

5. Swap Strategies

hx-swap controls how the response is inserted. Default is innerHTML.

StrategyBehavior
innerHTMLReplace the target's children (default)
outerHTMLReplace the entire target element
beforebeginInsert before the target
afterbeginInsert as first child
beforeendInsert as last child
afterendInsert after the target
deleteDelete the target element
noneDo not swap (useful for side effects)
<!-- Append new items to a list -->
<form hx-post="/items" hx-target="#item-list" hx-swap="beforeend">
    <input name="title" placeholder="New item"><button>Add</button>
</form>
<ul id="item-list"><li>Existing item</li></ul>

Swap modifiers

hx-swap="outerHTML swap:500ms"      <!-- delay swap for CSS transitions -->
hx-swap="innerHTML settle:300ms"    <!-- settle delay for class changes -->
hx-swap="innerHTML scroll:top"      <!-- scroll to top after swap -->
hx-swap="innerHTML show:#results:top" <!-- scroll element into viewport -->

6. Triggering Requests: hx-trigger

By default, htmx triggers on the "natural" event: click for buttons, submit for forms, change for inputs. hx-trigger overrides this.

<div hx-get="/tooltip" hx-trigger="mouseenter">Hover me</div>
<div hx-get="/notifications" hx-trigger="load">Loading...</div>
<div hx-get="/refresh" hx-trigger="myCustomEvent from:body"></div>
<input hx-get="/search" hx-trigger="keyup changed delay:300ms, search">

Trigger modifiers

ModifierEffect
onceOnly fire the first time
changedOnly fire if the value changed
delay:<time>Debounce — wait before firing
throttle:<time>Fire at most every N ms
from:<selector>Listen on a different element
every <time>Poll at a regular interval
<!-- Poll every 5 seconds -->
<div hx-get="/live-scores" hx-trigger="every 5s">Scores loading...</div>

<!-- Debounced search -->
<input type="search" name="q" hx-get="/search"
       hx-trigger="keyup changed delay:300ms" hx-target="#results">

7. CSS Transitions

htmx works with CSS transitions out of the box. When content is swapped, htmx manages htmx-added, htmx-settling, and htmx-swapping classes that you can target with CSS.

<style>
.fade-me-in.htmx-added { opacity: 0; }
.fade-me-in { opacity: 1; transition: opacity 300ms ease-in; }
.htmx-swapping { opacity: 0; transition: opacity 200ms; }
</style>

<button hx-get="/new-content" hx-target="#container"
        hx-swap="innerHTML settle:300ms">Load Content</button>
<div id="container"></div>

View Transitions API

<!-- Enable globally -->
<meta name="htmx-config" content='{"globalViewTransitions": true}'>

<!-- Or per element -->
<a hx-get="/page2" hx-target="body" hx-swap="innerHTML transition:true">Navigate</a>

8. Loading Indicators

htmx adds htmx-request to the triggering element while a request is in flight. Use hx-indicator to designate a different element.

<style>
.htmx-indicator { display: none; }
.htmx-request .htmx-indicator, .htmx-request.htmx-indicator { display: inline-block; }
.spinner { animation: spin 1s linear infinite; width: 1em; height: 1em;
           border: 2px solid #555; border-top-color: #3b82f6; border-radius: 50%; }
@keyframes spin { to { transform: rotate(360deg); } }
</style>

<button hx-get="/slow-endpoint" hx-target="#result">
    Load Data <span class="htmx-indicator spinner"></span>
</button>

<!-- Or use a separate indicator -->
<button hx-get="/data" hx-indicator="#loading-bar">Fetch</button>
<div id="loading-bar" class="htmx-indicator">Loading, please wait...</div>

9. Form Handling & Validation

htmx respects native HTML form behavior. HTML5 validation attributes work before htmx sends the request. You can also validate individual fields server-side in real time.

<form hx-post="/register" hx-target="#form-result" hx-swap="outerHTML">
    <label>Email
        <input type="email" name="email" required
               hx-post="/validate/email" hx-trigger="blur changed" hx-target="next .error">
        <span class="error"></span>
    </label>
    <label>Password
        <input type="password" name="password" required minlength="8">
    </label>
    <label>Username
        <input type="text" name="username" required
               hx-post="/validate/username" hx-trigger="blur changed delay:200ms" hx-target="next .error">
        <span class="error"></span>
    </label>
    <button type="submit">Register</button>
</form>

Including values from outside a form

<select id="sort-select" name="sort">
    <option value="date">Date</option><option value="name">Name</option>
</select>
<button hx-get="/items" hx-include="#sort-select" hx-target="#list">Sort</button>

10. Infinite Scroll & Pagination

The last item in a list triggers a request to load the next page when it enters the viewport.

<div id="item-list">
    <div class="item">Item 1</div>
    <!-- ... items 2-20 ... -->
    <div class="item">Item 20</div>
    <!-- Sentinel: loads next page when visible -->
    <div hx-get="/items?page=2" hx-trigger="revealed" hx-swap="outerHTML">
        <span class="htmx-indicator">Loading more...</span>
    </div>
</div>

The server returns the next batch plus a new sentinel pointing to page 3. When no more items exist, omit the sentinel.

# Flask server-side
@app.route('/items')
def items():
    page = request.args.get('page', 1, type=int)
    items = Item.query.paginate(page=page, per_page=20)
    return render_template('partials/items.html',
                           items=items.items,
                           next_page=page + 1 if items.has_next else None)

As the user types, results appear immediately with a 300ms debounce to prevent excessive requests.

<input type="search" name="q" placeholder="Search..."
       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 spinner"></span>

<table>
    <thead><tr><th>Name</th><th>Email</th><th>Role</th></tr></thead>
    <tbody id="search-results"><!-- rows appear here --></tbody>
</table>
// Express.js server-side
app.get('/search', (req, res) => {
    const q = req.query.q || '';
    const results = users.filter(u => u.name.toLowerCase().includes(q.toLowerCase()));
    const rows = results.map(u =>
        `<tr><td>${u.name}</td><td>${u.email}</td><td>${u.role}</td></tr>`
    ).join('');
    res.send(rows);
});

12. Boosting with hx-boost

hx-boost converts standard links and forms into AJAX requests, turning a multi-page app into an SPA-like experience with zero code changes. The URL updates via the History API and back/forward buttons work.

<!-- Boost all links inside nav -->
<nav hx-boost="true">
    <a href="/about">About</a>
    <a href="/contact">Contact</a>
</nav>

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

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

13. WebSocket & SSE Support

Server-Sent Events

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

<div hx-ext="sse" sse-connect="/events">
    <div sse-swap="message">Waiting for messages...</div>
    <div sse-swap="notification">No notifications yet</div>
</div>

WebSocket

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

<div hx-ext="ws" ws-connect="/chat-ws">
    <div id="chat-messages"></div>
    <form ws-send>
        <input name="message" placeholder="Type a message...">
        <button type="submit">Send</button>
    </form>
</div>

The server sends HTML fragments over the connection. htmx uses out-of-band swapping to update matching elements by ID.

14. Request Headers & hx-headers

htmx sends useful headers with every request: HX-Request (always "true"), HX-Target, HX-Trigger, HX-Trigger-Name, and HX-Current-URL. Your server can check these to return partial fragments instead of full pages.

<!-- Custom headers -->
<button hx-get="/api/data"
        hx-headers='{"X-Custom-Auth": "token-123"}'>Fetch</button>

<!-- Set headers for all children -->
<div hx-headers='{"X-CSRF-Token": "abc123"}'>
    <button hx-post="/action1">Action 1</button>
    <button hx-post="/action2">Action 2</button>
</div>
# Django: return partial for htmx, full page otherwise
def product_list(request):
    products = Product.objects.all()
    if request.headers.get('HX-Request'):
        return render(request, 'partials/product_list.html', {'products': products})
    return render(request, 'products.html', {'products': products})

15. htmx Extensions

class-tools — toggle CSS classes

<script src="https://unpkg.com/htmx-ext-class-tools@2.0.1/class-tools.js"></script>
<div hx-ext="class-tools" classes="add highlight:1s, remove highlight:3s">Watch me</div>
<div hx-ext="class-tools" classes="toggle pulse:2s">Pulsing</div>

json-enc — send JSON bodies

<script src="https://unpkg.com/htmx-ext-json-enc@2.0.1/json-enc.js"></script>
<form hx-post="/api/users" hx-ext="json-enc" hx-target="#result">
    <input name="name" value="Alice">
    <input name="email" value="alice@example.com">
    <button>Create</button>
</form>
<!-- Sends: {"name": "Alice", "email": "alice@example.com"} -->

preload — prefetch on hover

<script src="https://unpkg.com/htmx-ext-preload@2.0.1/preload.js"></script>
<div hx-ext="preload">
    <a href="/about" preload="mousedown">About</a>
    <a href="/contact" preload>Contact</a>
</div>

Other useful extensions: head-support (merge head elements), response-targets (target by status code), multi-swap (swap multiple elements), path-deps (refresh on path changes).

16. htmx with Django, Flask, Express, Go

The pattern is the same in every framework: check for the HX-Request header and return an HTML fragment instead of a full page.

Django

# views.py
def todo_list(request):
    todos = Todo.objects.all()
    if request.headers.get('HX-Request'):
        return render(request, 'partials/todo_list.html', {'todos': todos})
    return render(request, 'todos.html', {'todos': todos})

def add_todo(request):
    todo = Todo.objects.create(title=request.POST['title'])
    return render(request, 'partials/todo_item.html', {'todo': todo})

def delete_todo(request, pk):
    Todo.objects.filter(pk=pk).delete()
    return HttpResponse('')  # empty = element removed via outerHTML swap

Flask

@app.route('/contacts')
def contacts():
    q = request.args.get('q', '')
    results = search_contacts(q) if q else get_all_contacts()
    if request.headers.get('HX-Request'):
        return render_template('partials/contact_rows.html', contacts=results)
    return render_template('contacts.html', contacts=results)

Express (Node.js)

app.get('/tasks', (req, res) => {
    const tasks = getTasks();
    if (req.headers['hx-request'])
        return res.render('partials/task-list', { tasks });
    res.render('tasks', { tasks });
});

app.delete('/tasks/:id', (req, res) => {
    deleteTask(req.params.id);
    res.send('');
});

Go (net/http)

func todosHandler(w http.ResponseWriter, r *http.Request) {
    todos := getAllTodos()
    if r.Header.Get("HX-Request") == "true" {
        tmpl.ExecuteTemplate(w, "todo-list", todos)
        return
    }
    tmpl.ExecuteTemplate(w, "todos-page", todos)
}

17. htmx vs React/Vue/Svelte — When to Use What

FactorhtmxReact / Vue / Svelte
Bundle size~14 KB40-120+ KB
Build stepNoneWebpack / Vite / etc.
StateServer-sideClient-side (Redux, Pinia)
SEOBuilt-inRequires SSR setup
OfflineLimitedFull PWA support
Learning curveLowModerate to high

Choose htmx when: your app is content-driven (dashboards, admin panels, e-commerce), you already have a server-rendered app, your team is stronger in backend, SEO is critical, or you want minimal JS complexity.

Choose a JS framework when: you are building a highly interactive client-side app (collaborative editor, real-time design tool), need offline-first capability, or complex client-side state is central to your application.

18. Testing htmx Applications

Since htmx keeps logic on the server, most testing is standard server-side testing.

Server-side endpoint tests

# pytest + Flask
def test_search_returns_partial(client):
    response = client.get('/search?q=python', headers={'HX-Request': 'true'})
    assert response.status_code == 200
    assert '<tr>' in response.text       # table rows, not full page
    assert '<html>' not in response.text

def test_search_returns_full_page(client):
    response = client.get('/search?q=python')
    assert '<html>' in response.text      # full page for normal requests

Browser integration tests (Playwright)

import { test, expect } from '@playwright/test';

test('live search updates results', async ({ page }) => {
    await page.goto('/contacts');
    await page.fill('input[name="q"]', 'Alice');
    await page.waitForResponse(r => r.url().includes('/search') && r.status() === 200);
    expect(await page.locator('#search-results tr').count()).toBeGreaterThan(0);
});

test('delete removes item', async ({ page }) => {
    await page.goto('/todos');
    const count = await page.locator('.todo-item').count();
    page.on('dialog', d => d.accept());
    await page.click('.todo-item:first-child .delete-btn');
    await page.waitForTimeout(500);
    expect(await page.locator('.todo-item').count()).toBe(count - 1);
});

19. Best Practices & Common Patterns

Handle error responses

document.addEventListener('htmx:beforeSwap', function(evt) {
    if (evt.detail.xhr.status === 422) {
        evt.detail.shouldSwap = true;  // allow validation error HTML to swap
        evt.detail.isError = false;
    }
});

Out-of-band swaps (update multiple regions)

<!-- Server returns main content + OOB updates -->
<div>Item added!</div>
<span id="item-count" hx-swap-oob="true">24 items</span>
<div id="notifications" hx-swap-oob="true"><div class="toast">Created</div></div>

CSRF protection

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

More patterns

<!-- Disable button during request -->
<button hx-post="/submit" hx-disabled-elt="this">Submit</button>

<!-- Push URL to history -->
<a hx-get="/products?cat=electronics" hx-target="#main" hx-push-url="true">Electronics</a>

<!-- Extract part of a full page response -->
<button hx-get="/products" hx-target="#grid" hx-select="#grid">Refresh</button>

Pair htmx with Alpine.js

<!-- Alpine handles local UI state, htmx handles server communication -->
<div x-data="{ open: false }">
    <button @click="open = !open">Menu</button>
    <nav x-show="open" x-transition>
        <a hx-get="/page1" hx-target="#main">Page 1</a>
    </nav>
</div>

Performance tips

Frequently Asked Questions

What is htmx and how is it different from React or Vue?

htmx is a small JavaScript library (about 14 KB gzipped) that lets you make AJAX requests, trigger CSS transitions, and update parts of a page directly from HTML attributes instead of writing JavaScript. Unlike React or Vue, htmx follows a hypermedia-driven approach where the server returns HTML fragments instead of JSON. This means your server handles all the rendering logic, you do not need a build step, a bundler, or a virtual DOM.

Can I use htmx with my existing backend framework?

Yes. htmx works with any backend that can return HTML. It has been used with Django, Flask, Express, Go, Rails, Laravel, Spring Boot, ASP.NET, Phoenix, and many others. Your endpoints return HTML fragments instead of full pages or JSON. Dedicated integration libraries like django-htmx and flask-htmx add convenience helpers.

How does htmx handle form validation?

htmx supports both client-side and server-side validation. HTML5 attributes like required and pattern work before htmx sends requests. For server-side validation, your endpoint returns HTML with error messages that htmx swaps in. You can validate individual fields in real time with hx-trigger="blur changed" on each input.

Is htmx suitable for large production applications?

Yes. htmx is used in production by companies of all sizes for content-heavy apps, admin dashboards, internal tools, and e-commerce. It reduces bundle size, simplifies architecture, and improves accessibility. For highly interactive apps like collaborative editors or real-time games, a JavaScript framework may be a better choice.

How do I add loading indicators with htmx?

htmx adds the "htmx-request" class to the triggering element during requests. Use CSS to show/hide a spinner. For more control, use hx-indicator to point to a specific element. A common pattern: add a hidden spinner with class "htmx-indicator" and use CSS to display it when the parent has "htmx-request".

Related Posts

JavaScript DOM Manipulation Guide
Understand the DOM that htmx manipulates under the hood
REST API Design Guide
Design the endpoints that htmx talks to
HTML Forms Complete Guide
Master forms before enhancing them with htmx
Flask Web Framework Guide
Build htmx-powered apps with Flask on the backend