htmx
HTMX hypermedia-driven development with hx-get, hx-post, hx-swap, hx-trigger, out-of-band swaps, boosting, extensions, and minimal JS patterns
SKILL.md.claude/skills/htmx/SKILL.md
GitHubHTMX Hypermedia Skill
Purpose
Build dynamic web interfaces by returning HTML from the server instead of JSON. HTMX extends HTML with attributes for AJAX requests, CSS transitions, and WebSocket/SSE connections -- no build step, no virtual DOM, no client-side routing.
Core Philosophy
Server returns HTML partials, not JSON.
The browser is the template engine.
Hypermedia is the engine of application state (HATEOAS).
Installation
<!-- CDN (recommended for simplicity) -->
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<!-- Or npm for bundled projects -->
<!-- npm i htmx.org -->
Core Attributes
| Attribute | Purpose | Example |
|---|---|---|
hx-get |
GET request | hx-get="/api/users" |
hx-post |
POST request | hx-post="/api/users" |
hx-put |
PUT request | hx-put="/api/users/1" |
hx-delete |
DELETE request | hx-delete="/api/users/1" |
hx-swap |
How to swap content | hx-swap="innerHTML" |
hx-target |
Where to place response | hx-target="#results" |
hx-trigger |
What triggers the request | hx-trigger="click" |
hx-indicator |
Loading indicator | hx-indicator="#spinner" |
hx-push-url |
Update browser URL | hx-push-url="true" |
hx-select |
Pick fragment from response | hx-select="#content" |
hx-vals |
Include extra values | hx-vals='{"key":"val"}' |
Key Patterns
1. Click-to-Load Content
<button hx-get="/api/users"
hx-target="#user-list"
hx-swap="innerHTML"
hx-indicator="#spinner">
Load Users
</button>
<span id="spinner" class="htmx-indicator">Loading...</span>
<div id="user-list"></div>
Server returns an HTML fragment:
<!-- GET /api/users response -->
<ul>
<li>Alice <button hx-delete="/api/users/1" hx-target="closest li" hx-swap="outerHTML">Delete</button></li>
<li>Bob <button hx-delete="/api/users/2" hx-target="closest li" hx-swap="outerHTML">Delete</button></li>
</ul>
2. Live Search with Debounce
<input type="search" name="q"
hx-get="/api/search"
hx-target="#results"
hx-trigger="input changed delay:300ms"
hx-indicator="#search-spinner"
placeholder="Search..." />
<div id="results"></div>
# FastAPI server endpoint
@app.get("/api/search")
async def search(q: str = ""):
results = await db.search(q)
rows = "".join(f"<li>{r.title}</li>" for r in results)
return HTMLResponse(f"<ul>{rows}</ul>" if rows else "<p>No results found.</p>")
3. Inline Editing
<!-- Display mode -->
<div id="name-display" hx-get="/api/users/1/edit" hx-trigger="dblclick" hx-swap="outerHTML">
<span>Alice Johnson</span>
</div>
<!-- Edit mode (returned by GET /api/users/1/edit) -->
<form id="name-display" hx-put="/api/users/1" hx-swap="outerHTML">
<input name="name" value="Alice Johnson" autofocus />
<button type="submit">Save</button>
<button hx-get="/api/users/1/display" hx-swap="outerHTML" hx-target="#name-display">Cancel</button>
</form>
4. Infinite Scroll
<div id="feed">
<!-- Initial items -->
<div class="item">Post 1</div>
<div class="item">Post 2</div>
<!-- Sentinel element triggers load when visible -->
<div hx-get="/api/feed?page=2"
hx-trigger="revealed"
hx-swap="outerHTML"
hx-indicator="#load-more-spinner">
<span id="load-more-spinner" class="htmx-indicator">Loading more...</span>
</div>
</div>
Server response replaces the sentinel with new items and a new sentinel:
<div class="item">Post 3</div>
<div class="item">Post 4</div>
<div hx-get="/api/feed?page=3" hx-trigger="revealed" hx-swap="outerHTML">
<span class="htmx-indicator">Loading more...</span>
</div>
5. Out-of-Band (OOB) Swaps
Update multiple parts of the page from a single response:
<!-- Main response swaps into hx-target as usual -->
<div id="notification-count" hx-swap-oob="innerHTML">3</div>
<div id="main-content">
<p>Item created successfully!</p>
</div>
6. Boosted Navigation (SPA-like Feel)
<!-- Boost all links in nav to use AJAX instead of full page load -->
<nav hx-boost="true">
<a href="/dashboard">Dashboard</a>
<a href="/settings">Settings</a>
<a href="/profile">Profile</a>
</nav>
Swap Modes Reference
| Mode | Behavior |
|---|---|
innerHTML |
Replace inner content (default) |
outerHTML |
Replace entire target element |
beforebegin |
Insert before the target |
afterbegin |
Insert inside target, before first child |
beforeend |
Insert inside target, after last child |
afterend |
Insert after the target |
delete |
Delete the target element |
none |
No swap (fire-and-forget) |
Best Practices
- Return HTML partials, not full pages -- only the fragment that changed
- Use
hx-indicatoron every user-triggered request for perceived performance - Add
delay:300mson search inputs to avoid excessive requests - Use
hx-push-urlfor navigable pages so browser back/forward works - Prefer
outerHTMLswap for replaceable components -- avoids wrapper div pollution - Use OOB swaps to update counters, notifications, and other page regions from any response
- Progressively enhance -- forms should work without JS, then HTMX enhances them
Common Pitfalls
| Pitfall | Impact | Fix |
|---|---|---|
| Returning full HTML pages | Entire page re-rendered in target | Return only the partial fragment |
| Missing CSRF token | POST/PUT/DELETE rejected | Include token via hx-headers or meta tag |
| No loading indicators | UI feels broken during slow requests | Always use hx-indicator |
Forgetting hx-swap-oob IDs must exist |
OOB swap silently fails | Ensure target element exists in the DOM |
Not setting hx-push-url |
Users cannot bookmark or share URLs | Add on navigation-like requests |