Frontend No. 054

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.

  • namehtmx-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

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.

~/.claude/skills/htmx-hypermedia/SKILL.md

--- name: htmx-hypermedia 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… license: MIT ---

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

AttributePurposeCommon values
hx-get / hx-post / hx-put / hx-patch / hx-deleteIssue a requestURL
hx-targetWhere the response goesCSS selector, this, closest tr, find .result, next, previous
hx-swapHow it is insertedinnerHTML (default), outerHTML, beforeend, afterbegin, delete, none; modifiers swap:150ms, settle:, scroll:top, show:top, transition:true
hx-triggerWhenclick, submit, change, input changed delay:300ms, revealed, intersect once, load, every 5s, keyup[key=='Enter']
hx-selectPick part of the responseCSS selector
hx-push-url / hx-replace-urlUpdate the address bartrue or a URL
hx-indicatorElement that gets htmx-request class during requestselector
hx-disabled-eltDisable elements during requestthis, find button
hx-syncCoordinate overlapping requeststhis:replace, closest form:abort
hx-include / hx-valsExtra parametersselector / JSON
hx-confirmNative confirm before requestmessage
hx-boostTurn links/forms in a region into AJAX navigationstrue
hx-on:<event>Inline handlerhx-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:

StackFragment 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
RailsPartials; request.headers["HX-Request"]
LaravelBlade 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:

HeaderEffect
HX-Trigger: {"contactSaved": {"id": 7}}Fire client events (listen with hx-trigger="contactSaved from:body")
HX-Retarget / HX-ReswapChange target or swap from the server (e.g. errors into a form)
HX-Redirect: /loginFull client-side redirect
HX-Location: /contacts/7AJAX navigation without full reload
HX-Push-Url / HX-Replace-UrlUpdate URL from the server
HX-Refresh: trueFull 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 422 with 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:responseError and htmx:sendError.
  • Prevent double-submit with hx-disabled-elt="find button[type=submit]" and hx-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 with hx-disable and sanitize it.
  • htmx 2 defaults selfRequestsOnly to true; keep it. Set htmx.config.allowEval = false if you do not use hx-on or js: 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()" or autofocus in the returned fragment).
  • Keep links real URLs with hx-boost so open-in-new-tab and no-JS work.

Verification

  1. 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).
  2. Responses that vary by HX-Request include Vary: HX-Request (curl -I).
  3. Browser: back/forward after pushed URLs restores the right content; reload on a pushed URL renders the full page.
  4. Submit invalid forms: errors render in place (422 or 200 convention), focus lands on the first error.
  5. Enable htmx debug logging (htmx.logAll()) in development when a swap targets the wrong element; check the resolved target in the htmx:beforeSwap event.

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.

esc