htmx: The Complete Guide for 2026
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.
Table of Contents
- What Is htmx? Why Hypermedia-Driven Development
- Installation & Setup
- Core Attributes: hx-get, hx-post, hx-put, hx-patch, hx-delete
- Targeting Elements: hx-target & hx-swap
- Swap Strategies
- Triggering Requests: hx-trigger
- CSS Transitions
- Loading Indicators
- Form Handling & Validation
- Infinite Scroll & Pagination
- Active Search / Live Search
- Boosting with hx-boost
- WebSocket & SSE Support
- Request Headers & hx-headers
- htmx Extensions
- htmx with Django, Flask, Express, Go
- htmx vs React/Vue/Svelte
- Testing htmx Applications
- Best Practices & Common Patterns
- FAQ
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
- ~14 KB gzipped — no dependency tree, no bundler needed
- No build step — add a script tag and start using attributes
- Backend-agnostic — works with any language or framework that returns HTML
- Progressive enhancement — pages work without JavaScript, htmx enhances them
- SEO-friendly — server-rendered HTML is immediately indexable
- Simpler mental model — HTML in, HTML out
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:
| Attribute | HTTP Method | Use Case |
|---|---|---|
hx-get | GET | Fetch and display data |
hx-post | POST | Submit forms, create resources |
hx-put | PUT | Replace a resource entirely |
hx-patch | PATCH | Partially update a resource |
hx-delete | DELETE | Remove 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.
| Strategy | Behavior |
|---|---|
innerHTML | Replace the target's children (default) |
outerHTML | Replace the entire target element |
beforebegin | Insert before the target |
afterbegin | Insert as first child |
beforeend | Insert as last child |
afterend | Insert after the target |
delete | Delete the target element |
none | Do 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
| Modifier | Effect |
|---|---|
once | Only fire the first time |
changed | Only 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)
11. Active Search / Live Search
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
| Factor | htmx | React / Vue / Svelte |
|---|---|---|
| Bundle size | ~14 KB | 40-120+ KB |
| Build step | None | Webpack / Vite / etc. |
| State | Server-side | Client-side (Redux, Pinia) |
| SEO | Built-in | Requires SSR setup |
| Offline | Limited | Full PWA support |
| Learning curve | Low | Moderate 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
- Return only what changes — keep HTML fragments small
- Debounce input with
delay:to reduce server load - Use
preloadextension for likely navigation targets - Set
Cache-Controlheaders on partial responses - Use
hx-syncto prevent duplicate in-flight requests
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".