htmx
Purpose
Build hypermedia-driven web applications where the server sends HTML fragments in response to AJAX requests triggered by HTML attributes — no JavaScript required.
Agent Protocol
Trigger
Exact user phrases: "htmx setup", "htmx project", "htmx hx-get", "htmx hx-post", "htmx HATEOAS", "htmx hypermedia", "htmx server", "htmx ajax", "hypermedia app".
Input Context
Before activating, verify:
- htmx is included (CDN script or npm).
- What backend framework is used (Django, Rails, Go, Laravel, etc.).
Output Artifact
No file output. Produces HTML snippets with htmx attributes as text.
Response Format
HTML with htmx attributes:
<button hx-get="/api/data" hx-target="#result" hx-swap="innerHTML">
Load Data
</button>
<div id="result"></div>
No preamble. No postamble. No explanations. Compress output — why use many token when few do trick.
Completion Criteria
- AJAX requests use hx-get, hx-post, hx-put, hx-patch, or hx-delete.
- Responses are HTML fragments, not JSON.
- Target element specified with hx-target or defaults to the triggering element.
- Swap strategy chosen: innerHTML, outerHTML, beforebegin, afterbegin, beforeend, afterend.
- HATEOAS: server returns next-available actions as hypermedia links.
- History and URL updates via hx-push-url or hx-replace-url.
- Validation and feedback via hx-trigger, hx-indicator, hx-disable.
Max Response Length
~4096 tokens.
Component Architecture / Decision Trees
Architecture Options
| Approach | Trade-off | When to Use |
|---|---|---|
| hx-get fragment swap | Simple data fetch, GET semantics | Read operations, lazy load |
| hx-post form with validation | Form submission, 422 errors | Mutations with validation |
| hx-boost on links/forms | Full-page SPA-like navigation | Enhancing existing HTML |
| hx-trigger="every 10s" | Polling for updates | Real-time-ish dashboards |
| hx-trigger="revealed" | Lazy load below fold | Performance optimization |
| hx-trigger="intersect" | Viewport-based loading | Infinite scroll |
| Server-Sent Events (hx-sse) | Server push | Real-time notifications |
| WebSockets (hx-ws) | Bidirectional stream | Chat, collaborative editing |
Decision Tree: Request Trigger
What triggers the AJAX request?
Button/link click -> hx-trigger="click" (default for buttons, links, forms)
Page load -> hx-trigger="load"
Element scrolled into view -> hx-trigger="revealed"
Input change -> hx-trigger="change, keyup delay:300ms" (search-as-you-type)
Time interval -> hx-trigger="every 5s" (polling)
Intersection observer -> hx-trigger="intersect threshold:0.5"
Custom JS event -> hx-trigger="custom-event from:body"
Focus loss -> hx-trigger="focusOut delay:200ms"
Decision Tree: Swap Strategy
What should happen with the response?
Replace target's children -> hx-swap="innerHTML" (default)
Replace the whole target -> hx-swap="outerHTML"
Insert before the target -> hx-swap="beforebegin"
Insert after the target -> hx-swap="afterend"
Append inside target -> hx-swap="beforeend"
Prepend inside target -> hx-swap="afterbegin"
Execute response JS only -> hx-swap="none"
Remove the target -> hx-swap="delete"
Hypermedia Architecture Decision
How much of the page does this interaction affect?
Small widget update -> Return fragment, swap innerHTML on container
Form submission with errors -> Return updated form HTML with 422 status
Section replacement (tab) -> Return section HTML, swap outerHTML on section
Full page navigation -> Use hx-boost on links for seamless navigation
Modal/dialog -> Return dialog HTML, swap innerHTML on modal container
Infinite scroll -> Return next page items, swap beforeend on list
Component Design Patterns
Button with Loading State
<button hx-get="/api/refresh" hx-target="#data" hx-indicator="#spinner">
Refresh
<img id="spinner" class="htmx-indicator" src="/spinner.gif" />
</button>
Form with Validation
<form hx-post="/contacts" hx-target="#form-area" hx-swap="outerHTML">
<input type="text" name="email" hx-get="/contacts/check-email"
hx-trigger="change" hx-target="#email-error">
<div id="email-error"></div>
<button type="submit">Submit</button>
</form>
Server returns updated form HTML with class "error" on invalid fields and HTTP 422 status.
Infinite Scroll
<div hx-get="/posts?page=2" hx-trigger="revealed" hx-swap="beforeend" hx-target="#posts">
</div>
<div id="posts">
<!-- Existing posts rendered by server -->
</div>
Tabs via Fragment Swap
<div hx-target="#tab-content" hx-swap="innerHTML">
<a hx-get="/tabs/info">Info</a>
<a hx-get="/tabs/settings">Settings</a>
</div>
<div id="tab-content">
<!-- Server-rendered tab content -->
</div>
Inline Edit
<div hx-get="/contacts/1/edit" hx-trigger="dblclick" hx-target="this" hx-swap="outerHTML">
<span>{{ contact.name }}</span>
</div>
Server returns <form> with input filled in and hx-put to cancel/submit.
State Management Patterns
Server-Driven State (Primary)
State lives on the server. Client is a stateless HTML viewer. The server:
- Renders current state as HTML
- Returns fragments representing new state after mutations
- Sends links for available actions (HATEOAS)
URL State via hx-push-url
<div hx-get="/products?page=2" hx-push-url="true" hx-target="#products">
Next Page
</div>
Client State via Alpine.js (Companion)
For client-only UI state (modals, toggles, theme), pair htmx with Alpine.js:
<div x-data="{ modalOpen: false }">
<button hx-get="/api/data" hx-target="#result" @click.prevent>Load</button>
<div x-show="modalOpen">Modal content</div>
</div>
Performance Optimization
- htmx is ~14KB min+gzip — negligible bundle cost.
- Requests return HTML fragments (smaller than full page, larger than JSON).
- Server rendering time adds latency vs client rendering.
- Partial swaps reduce DOM diffing to target elements only.
- hx-trigger="load" defers rendering to after initial paint (improves LCP).
- Caching: traditional HTTP caching works (ETags, Last-Modified).
- Boosting enables view-transitions for smooth navigation.
- hx-trigger="every 30s" for polling — balance freshness vs server load.
- Debounce inputs with
delay:300msto avoid excess requests. - hx-preserve keeps elements untouched across swaps (useful for video players, audio).
Build & Bundle Considerations
- htmx works via CDN — no build step required.
- npm install:
npm install htmx.org, thenimport 'htmx.org'. - Extensions (
hx-ext) each add 2-5KB — only load what you need. - htmx 2.x is available both as ESM and UMD bundles.
- No tree-shaking concerns — the entire library is ~14KB.
- Compatible with any backend build pipeline (webpack, esbuild, Vite, no bundler).
- Alpine.js + htmx together is ~24KB total — still smaller than most UI frameworks alone.
Testing Strategies
Server-Side Focused Testing
Since htmx is primarily server-driven, testing focuses on the backend:
# pytest example for Django + htmx
def test_contact_list_htmx(client):
response = client.get('/contacts', HTTP_HX_REQUEST='true')
assert response.status_code == 200
assert 'partials/_contact_list.html' in response.templates
assert 'hx-get' in response.content.decode()
Browser Testing with Playwright
test('infinite scroll loads more items', async ({ page }) => {
await page.goto('/posts')
const initialCount = await page.locator('.post').count()
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight))
await page.waitForResponse(resp => resp.url().includes('/posts?page=2'))
const newCount = await page.locator('.post').count()
expect(newCount).toBeGreaterThan(initialCount)
})
Key Testing Practices
- Test server endpoints with htmx headers — assert HTML fragment correctness.
- Test swap behavior by asserting DOM structure after hx-trigger.
- Use hx-on:htmx:beforeRequest for instrumentation in E2E tests.
- Test error scenarios: 422 for form errors, 404 for missing resources.
Migration Patterns
From jQuery AJAX to htmx
| jQuery Pattern | htmx Equivalent |
|---|---|
$.get('/url', fn) |
<button hx-get="/url" hx-target="#result"> |
$.post('/url', data, fn) |
<form hx-post="/url" hx-target="#result"> |
$.ajax({error: fn}) |
Server returns error HTML, CSS on .htmx-request errors |
| Manual loading spinner | hx-indicator="#spinner" |
$(el).html(html) |
hx-swap="innerHTML" |
$(el).replaceWith(html) |
hx-swap="outerHTML" |
Migration strategy: Wrap each AJAX interaction with htmx attributes. Remove jQuery AJAX calls as you go.
From React to htmx
Fundamental architecture change (client to server rendering). Best for content-heavy apps:
- Replace useState/xhr with server state rendered as HTML.
- Replace React Router with hx-boost for navigation.
- Replace form libraries with hx-post + server validation (422).
- Keep Alpine.js for client-only UI state (modals, toggles).
Anti-Patterns
- Returning JSON instead of HTML: htmx expects HTML fragments. JSON responses are not processed.
- Full page reloads: If server returns a full page instead of fragment, content doubles.
- Missing hx-target: Defaults to swapping the triggering element, not the container.
- Forgetting 422 for validation errors: Form validation errors must return 422 status.
- Not handling swap strategy: innerHTML vs outerHTML selection changes behavior.
- Overusing hx-boost: Some links need full page loads (downloads, external).
- No loading indicators: Without hx-indicator or CSS on .htmx-request, users see no feedback.
- Missing CSRF tokens: Add hx-headers for Django/Laravel.
- Multiple hx-trigger on one element: Use a parent wrapper for multiple triggers.
- Not handling request errors: Use hx-on:htmx:responseError for error handling.
Common Pitfalls
- Returning JSON instead of HTML — htmx processes HTML fragments only.
- Full page reloads — server should return fragments, not full pages.
- Missing hx-target — defaults to triggering element, not container.
- Forgetting 422 for validation errors — forms need 422 for error state.
- No loading indicators — use hx-indicator.
Compared With
| Aspect | htmx | React | Alpine.js |
|---|---|---|---|
| Rendering | Server HTML | Client VDOM | Client DOM |
| State location | Server | Client state tree | DOM + Alpine.store |
| API responses | HTML fragments | JSON | JSON |
| Learning curve | Hours | Weeks | Days |
| Bundle size | ~14KB | ~120KB (min) | ~10KB |
| SEO | Full HTML | Needs SSR | HTML baseline |
Server Integration Patterns
Django
def contact_list(request):
if request.htmx:
return render(request, 'contacts/_list.html', {'contacts': Contact.objects.all()})
return render(request, 'contacts/index.html', {'contacts': Contact.objects.all()})
Laravel
Route::get('/contacts', function () {
if (request()->header('HX-Request')) {
return view('contacts/_list', ['contacts' => Contact::all()]);
}
return view('contacts/index', ['contacts' => Contact::all()]);
});
Node/Express
app.get('/contacts', (req, res) => {
if (req.headers['hx-request']) {
return res.render('contacts/_list', { contacts })
}
res.render('contacts/index', { contacts })
})
Advanced Patterns
Active Search
<input type="search" name="q" hx-post="/search" hx-trigger="input changed delay:500ms, search" hx-target="#search-results" hx-indicator="#spinner">
Lazy Loading with Placeholder
<div hx-get="/graphs/revenue" hx-trigger="load"><img class="htmx-indicator" src="/spinner.gif"></div>
Delete with Confirmation
<button hx-delete="/contacts/1" hx-confirm="Delete contact?" hx-target="closest tr">Delete</button>
Tooling
- htmx DevTools browser extension
hyperscript— companion language (optional)alpinejs— complementary for client interactivitydjango-htmx— Django middlewareflask-htmx— Flask extensionlaravel-htmx— Laravel packagego-htmx— Go middlewarespring-htmx— Spring Boot integration
Rules
- Server returns HTML fragments, never JSON (unless explicitly for client-side templates).
- Use HATEOAS: server responses include links/forms for next actions.
- Choose the correct hx-swap strategy: innerHTML (default), outerHTML, beforeend (append), afterend.
- Use hx-trigger for custom events: click, change, submit, load, revealed, intersect, every.
- Form validation errors return 422 status with updated form HTML.
- Use hx-indicator to show loading states.
- Keep server endpoints idempotent when possible.
References
- references/htmx-advanced.md — htmx Advanced Patterns
- references/htmx-deployment.md — htmx Deployment
- references/htmx-fundamentals.md — Htmx Fundamentals
- references/htmx-patterns.md — htmx Patterns & Best Practices
- references/htmx-setup.md — htmx Setup Guide
- references/htmx-testing.md — htmx Testing Reference
- references/htmx-advanced-patterns.md — Advanced htmx Patterns
- references/htmx-server-integration.md — htmx Server Integration Reference
Handoff
No artifact produced. Next skill: htmx-hyperscript (if client-side logic needed) or backend-htmx-integration. Carry forward: hx-trigger/hx-target/hx-swap pattern, HTML-fragment responses, HATEOAS.
Implementation Patterns
HTMX Component Pattern
<!-- Server-rendered component with HTMX -->
<div hx-target="this" hx-swap="outerHTML">
<button hx-get="/api/component/counter"
hx-trigger="click"
hx-vals='{"action": "increment"}'>
Count: {{ count }}
</button>
</div>
<!-- Inline editing pattern -->
<div hx-target="this" hx-swap="outerHTML">
<span hx-get="/api/component/edit/{{ id }}"
hx-trigger="click"
class="editable">
{{ value }}
</span>
</div>
<!-- Lazy loading pattern -->
<div hx-get="/api/component/heavy-content"
hx-trigger="load"
hx-swap="innerHTML">
<div class="spinner">Loading...</div>
</div>
<!-- Infinite scroll -->
<div hx-get="/api/items?page=2"
hx-trigger="revealed"
hx-swap="afterend"
hx-target="this">
</div>
<!-- Form validation -->
<form hx-post="/api/users"
hx-target="#form-errors"
hx-swap="innerHTML">
<input type="text" name="email"
hx-post="/api/validate/email"
hx-trigger="change"
hx-target="next .error">
<div class="error"></div>
<button type="submit">Submit</button>
</form>
<div id="form-errors"></div>
Server-Side Handler Pattern
from flask import Blueprint, request, render_template, jsonify
htmx = Blueprint('htmx', __name__)
@htmx.route('/api/component/counter')
def counter_component():
action = request.form.get('action', 'increment')
current = int(request.args.get('count', 0))
if action == 'increment':
current += 1
elif action == 'decrement':
current -= 1
return render_template('components/_counter.html', count=current)
@htmx.route('/api/component/edit/<id>')
def edit_component(id):
value = get_value(id)
return render_template('components/_edit_form.html', id=id, value=value)
@htmx.route('/api/validate/email', methods=['POST'])
def validate_email():
email = request.form.get('email', '')
if '@' not in email:
return '<span class="error">Invalid email</span>'
return '<span class="success">Valid email</span>'
Architecture Decision Trees
SPA vs HTMX Decision
What's the interactivity requirement?
├── Simple CRUD, forms, navigation
│ └── HTMX + server-side rendering
│ ├── Less JavaScript, simpler architecture
│ ├── Server handles rendering
│ └── Faster initial load
│
├── Complex real-time UIs (drag-drop, drawing)
│ └── SPA (React, Vue, Svelte)
│ ├── Rich client interactivity
│ ├── Complex state management
│ └── More JavaScript overhead
│
├── Mixed (mostly simple, some complex)
│ └── HTMX for simple parts + SPA island for complex
│ └── Best of both worlds
Anti-Patterns
| Anti-Pattern | Why It Fails | Correct Approach |
|---|---|---|
| Returning JSON from HTMX endpoints | Client has to parse and render | Return HTML fragments directly |
| Using htmx for everything | Not appropriate for rich UIs | Use SPA for complex interactive parts |
| No loading indicators | Users don't know if request is working | Always use hx-indicator |
| Wrong swap strategy | Unexpected UI behavior | Choose correct hx-swap for each use case |
| Server endpoints not idempotent | Duplicate requests cause issues | GET should be safe, POST can repeat |
Performance Optimization
- Server-Sent Events for real-time updates: Use HTMX with SSE (Server-Sent Events) for real-time push. SSE triggers HTMX requests to update specific elements. More efficient than polling.
- View caching on server: Cache rendered HTML fragments on server. Use response cache headers for HTMX responses. Avoid re-rendering unchanged components.
- Morphdom swap for minimal DOM changes: Use
hx-swap="morphdom"for fine-grained DOM diffing. Only changes the parts of the element that actually changed. Reduces layout thrash.