htmx Hypermedia skill for Claude Code
Server-rendered fragments instead of a SPA. Claude builds htmx features the hypermedia way: a working full-page version first, then fragments rendered from the same template blocks, real URLs with pushed history, and server-driven events through HX-Trigger. It knows the htmx 2 changes that bite — unswapped 4xx responses, 204 meaning no swap, redirects that land inside the target — and checks every endpoint with and without HX-Request.
- name
htmx-hypermedia - size209 lines · ≈2,300 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Builds hypermedia-driven interfaces with htmx 2 — hx-get/hx-post with hx-target and hx-swap, triggers with delay and throttle, partial HTML responses from server templates, out-of-band swaps, HX-* request and response headers, history and boosting, validation errors with 422, CSRF, and caching with Vary. Use when adding htmx to a Django, Rails, Laravel, Flask, FastAPI, Go, or Express app, building active search, inline edit, infinite scroll, or modal flows without a SPA, or when swaps target the wrong element or error responses do nothing. Try asking
- Add active search and inline editing to this Django contacts table with htmx
- My htmx form shows nothing when validation fails — make errors render
- Update the cart counter in the header when an item is added from the product list
Use it when
- Adding interactivity to a server-rendered app without a SPA framework
- Building active search, inline edit, infinite scroll or modal flows
- Swaps hit the wrong element or error responses do nothing
- Upgrading from htmx 1.x to 2.x
Skip it when
- The app needs offline support or heavy client-side state — use react-patterns or sveltekit-apps
- You are building reusable encapsulated widgets — use web-components
How to check the result
Curl each endpoint with and without the HX-Request header to get a fragment and a full page, confirm Vary: HX-Request is set, and test back, forward and reload in the browser.
The common mistake it prevents
Returning the fragment and the full page from the same URL without Vary: HX-Request, so a cache later serves a bare fragment as the whole page.
A good htmx app keeps state and logic on the server, returns HTML fragments rendered from the same templates as full pages, makes every interaction a real URL that also works without htmx where it matters, and keeps client JavaScript to small sprinkles. Written for htmx 2.x; check the release notes if the project uses a newer major.
Before changing anything
grep -rn "htmx.org\|htmx.min.js\|from \"htmx.org\"" templates/ static/ src/ package.json 2>/dev/null | head
grep -rn "hx-" templates/ | wc -l
Note the version (1.x vs 2.x differ in error handling, hx-on syntax, and extensions)
and the server’s template engine — the fragment strategy depends on it.
Workflow
- [ ] 1. Build the full-page version first (plain links and forms)
- [ ] 2. Identify the region each interaction replaces
- [ ] 3. Add hx-* attributes: verb, target, swap, trigger
- [ ] 4. Server: detect HX-Request and return the fragment (same template block)
- [ ] 5. Handle errors (422 validation, 4xx/5xx), loading indicators, double-submit
- [ ] 6. Out-of-band updates for other regions affected (counters, flash messages)
- [ ] 7. History/URL behavior, caching headers (Vary), CSRF
- [ ] 8. Test with and without HX-Request
Core attributes
| Attribute | Purpose | Common values |
|---|---|---|
hx-get / hx-post / hx-put / hx-patch / hx-delete | Issue a request | URL |
hx-target | Where the response goes | CSS selector, this, closest tr, find .result, next, previous |
hx-swap | How it is inserted | innerHTML (default), outerHTML, beforeend, afterbegin, delete, none; modifiers swap:150ms, settle:, scroll:top, show:top, transition:true |
hx-trigger | When | click, submit, change, input changed delay:300ms, revealed, intersect once, load, every 5s, keyup[key=='Enter'] |
hx-select | Pick part of the response | CSS selector |
hx-push-url / hx-replace-url | Update the address bar | true or a URL |
hx-indicator | Element that gets htmx-request class during request | selector |
hx-disabled-elt | Disable elements during request | this, find button |
hx-sync | Coordinate overlapping requests | this:replace, closest form:abort |
hx-include / hx-vals | Extra parameters | selector / JSON |
hx-confirm | Native confirm before request | message |
hx-boost | Turn links/forms in a region into AJAX navigations | true |
hx-on:<event> | Inline handler | hx-on::after-request="this.reset()" |
Attributes inherit down the DOM. Put shared hx-target or hx-headers on a parent and
override on children; use hx-disinherit when inheritance surprises you.
Patterns
Active search:
<input type="search" name="q" placeholder="Search contacts"
hx-get="/contacts" hx-trigger="input changed delay:300ms, keyup[key=='Enter']"
hx-target="#results" hx-select="#results" hx-push-url="true"
hx-indicator="#search-spinner" aria-controls="results">
<span id="search-spinner" class="htmx-indicator" aria-hidden="true">Searching…</span>
<div id="results" aria-live="polite">{% include "contacts/_rows.html" %}</div>
Inline edit (click to edit, save, cancel):
<!-- _contact_row.html -->
<tr id="contact-{{ c.id }}">
<td>{{ c.name }}</td>
<td><button hx-get="/contacts/{{ c.id }}/edit" hx-target="closest tr" hx-swap="outerHTML">Edit</button></td>
</tr>
<!-- _contact_row_edit.html -->
<tr id="contact-{{ c.id }}" hx-target="this" hx-swap="outerHTML">
<td><input name="name" value="{{ c.name }}" aria-label="Name"></td>
<td>
<button hx-put="/contacts/{{ c.id }}" hx-include="closest tr">Save</button>
<button hx-get="/contacts/{{ c.id }}">Cancel</button>
</td>
</tr>
Delete with confirmation and row removal:
<button hx-delete="/contacts/{{ c.id }}" hx-confirm="Delete {{ c.name }}?"
hx-target="closest tr" hx-swap="outerHTML swap:150ms">Delete</button>
Server returns 200 with an empty body (the row is replaced with nothing). A 204
response performs no swap in htmx 2 — use 200 when you want the element removed.
Infinite scroll:
<tr hx-get="/contacts?page={{ page + 1 }}" hx-trigger="revealed" hx-swap="afterend" hx-select="tbody > tr">
Server side
Render fragments from the same templates as full pages so markup never diverges:
| Stack | Fragment technique |
|---|---|
| Django | {% partialdef %} / {% partial %} (built in from Django 6.0; django-template-partials before), or {% include %} files; django-htmx for request.htmx |
| Flask / FastAPI (Jinja2) | jinja2-fragments to render a {% block %}, or include files |
| Rails | Partials; request.headers["HX-Request"] |
| Laravel | Blade fragments (@fragment) with ->fragment() |
| Go | {{ block "rows" . }} then ExecuteTemplate(w, "rows", data) |
# Flask example
@app.get("/contacts")
def contacts():
rows = search(request.args.get("q", ""))
partial = request.headers.get("HX-Request") and not request.headers.get("HX-History-Restore-Request")
template = "contacts/_rows.html" if partial else "contacts/index.html"
resp = make_response(render_template(template, rows=rows))
resp.headers["Vary"] = "HX-Request" # same URL, two representations
return resp
Whenever the same URL can return a fragment or a full page, set Vary: HX-Request,
or a CDN/browser cache will serve a fragment as a whole page (or vice versa). When htmx
restores history after a cache miss it sends HX-History-Restore-Request: true and
expects the full page.
Useful response headers:
| Header | Effect |
|---|---|
HX-Trigger: {"contactSaved": {"id": 7}} | Fire client events (listen with hx-trigger="contactSaved from:body") |
HX-Retarget / HX-Reswap | Change target or swap from the server (e.g. errors into a form) |
HX-Redirect: /login | Full client-side redirect |
HX-Location: /contacts/7 | AJAX navigation without full reload |
HX-Push-Url / HX-Replace-Url | Update URL from the server |
HX-Refresh: true | Full page refresh |
Never send 302 to an htmx request expecting a page change — the fetch follows it and
swaps the redirected page into the target. Use HX-Redirect or HX-Location.
Out-of-band swaps
Update additional regions from one response by including elements with hx-swap-oob:
<!-- response to POST /cart/items -->
<li id="item-42">Blue mug × 1</li>
<span id="cart-count" hx-swap-oob="true">3</span>
<div id="flash" hx-swap-oob="innerHTML">Added to cart</div>
The OOB element’s id must exist on the page. For table rows or other context-sensitive
elements, wrap OOB content in <template> so the parser does not drop it.
Errors and validation
- htmx 2 does not swap 4xx/5xx responses by default. For form validation, return
422with the re-rendered form and allow swapping it:
<meta name="htmx-config" content='{"responseHandling":[{"code":"204","swap":false},{"code":"[23]..","swap":true},{"code":"422","swap":true},{"code":"[45]..","swap":false,"error":true}]}'>
Or keep 200 for invalid forms and re-render with errors — pick one convention.
- Show a global error for network failures and 5xx by listening to
htmx:responseErrorandhtmx:sendError. - Prevent double-submit with
hx-disabled-elt="find button[type=submit]"andhx-sync="this:drop"on forms.
Security
- CSRF: send the token on every non-GET request, e.g. on
<body>:hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}', or include the hidden input in forms. - Escape all user content in templates (autoescape on). Attributes are code: injected
hx-*attributes in user HTML can trigger requests. Render untrusted HTML inside an element withhx-disableand sanitize it. - htmx 2 defaults
selfRequestsOnlyto true; keep it. Sethtmx.config.allowEval = falseif you do not usehx-onorjs:expressions, especially with a strict CSP. - Authorize every fragment endpoint exactly like a full page — fragments are not hidden.
Accessibility and UX
- Use real
<button>and<a href>elements; htmx attributes on<div>are not keyboard accessible. - Announce updated regions with
aria-live="polite"and move focus deliberately after swaps that replace the focused element (hx-on::after-swap="...focus()"orautofocusin the returned fragment). - Keep links real URLs with
hx-boostso open-in-new-tab and no-JS work.
Verification
- Every endpoint returns a correct full page when requested without
HX-Request(curl -s localhost:8000/contacts | head), and a fragment with it (curl -s -H "HX-Request: true" localhost:8000/contacts | head). - Responses that vary by
HX-RequestincludeVary: HX-Request(curl -I). - Browser: back/forward after pushed URLs restores the right content; reload on a pushed URL renders the full page.
- Submit invalid forms: errors render in place (422 or 200 convention), focus lands on the first error.
- Enable htmx debug logging (
htmx.logAll()) in development when a swap targets the wrong element; check the resolved target in thehtmx:beforeSwapevent.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.