Use when adding interactivity to a server-rendered app (FastAPI/Jinja, Django, Rails, Laravel, Go templates, Express) without adopting a JS framework — hx-get/post/put/delete with hx-target, hx-swap and hx-trigger, returning HTML fragments instead of JSON, out-of-band swaps, active search, infinite scroll, inline edit and polling. NOT a client-state SPA with routing and a store (that is `react` or `nextjs`).
npx skills add https://github.com/ericrisco/rsc-harness --skill htmx
The server owns all application state and renders HTML. The client is dumb: it swaps
server-rendered fragments into the DOM. There is no JSON API for the UI, no client store,
no virtual DOM, no client router. If you find yourself returning JSON and rendering it with
JavaScript, you have stopped doing htmx and started building an SPA — use a different tool.
The unit of work is one request, described by four attributes on one element:
hx-get / hx-post / hx-put / hx-patch / hx-delete (the URL)hx-target — which DOM node receives the response (CSS selector or this)hx-swap — how the response is placed (default innerHTML)hx-trigger — on what event (default: natural — click for buttons, submit for forms,change for inputs)
Versions (verify before pinning). htmx 2.0.x is current stable (2.0.10 latest 2.x line);
v1 (1.9.x) is legacy, kept only for IE/old-browser support. htmx v4 is in beta targeting
Summer 2026 and changes some defaults (default swap behavior, config) — do not write to v4
yet. Pin to 2.x:
<script src="https://unpkg.com/[email protected]" crossorigin="anonymous"></script>
hx-target + hx-swap + hx-trigger to update a page region.(branch on the HX-Request header).
hx-swap-oob) when one response must refresh several regions.revealed, intersect, debounced input.
HX-Trigger, HX-Redirect, …).react, vue-nuxt, svelte, solid-js, angular, or ../nextjs/SKILL.md. htmx is the
anti-SPA; do not fight it.
django, rails, laravel. This skill owns the *htmx contract* (which fragment, which
header, which swap), not framework internals. Cross-link, do not duplicate.
accessibility.testing-web / e2e-testing.renderer, which is the SPA you are trying to avoid.
HX-Request: fragment for htmx, full page otherwise. A bookmarked URL or hardrefresh must still render a whole page; the htmx call gets just the partial.
reused inside the page layout — never two copies that drift.
hx-target and hx-swap explicitly when the default is wrong. Default target is theelement itself; default swap is innerHTML. Be explicit the moment you need otherwise.
changes a list *and* a counter is one response with one OOB element.
hx-trigger, not JavaScript. Debounce, polling, reveal, intersect are alltrigger modifiers — reaching for addEventListener usually means you missed a modifier.
fire events via HX-* response headers instead of branching logic in the browser.
htmx does not add CSRF tokens; you propagate them via hx-headers or a hidden field.
<!-- Bad: JSON endpoint + hand-written DOM patching = a tiny SPA -->
<button id="like">Like</button>
<script>
document.getElementById('like').addEventListener('click', async () => {
const r = await fetch('/posts/42/like', { method: 'POST' });
const data = await r.json(); // JSON contract
document.getElementById('count').textContent = data.count; // manual render
});
</script>
<!-- Good: the server returns the new HTML; the element declares the swap -->
<button hx-post="/posts/42/like"
hx-target="#likes"
hx-swap="outerHTML">Like</button>
<span id="likes">42 likes</span>
<!-- POST /posts/42/like responds with: <span id="likes">43 likes</span> -->
The Good version has no JS, no JSON, no client state. The server computed the count and rendered
the truth; the client placed it.
Framework-agnostic rule: **if HX-Request: true, render the partial; otherwise render the page
that embeds that same partial.** Concrete FastAPI + Jinja2:
from fastapi import FastAPI, Request
from fastapi.templating import Jinja2Templates
app = FastAPI()
templates = Jinja2Templates(directory="templates")
@app.get("/contacts")
def contacts(request: Request, q: str = ""):
rows = search_contacts(q)
# htmx asked for just the table body; a browser nav gets the whole page.
template = "contacts/_rows.html" if request.headers.get("HX-Request") else "contacts/index.html"
return templates.TemplateResponse(template, {"request": request, "rows": rows, "q": q})
{# templates/contacts/index.html — the page wraps the SAME partial #}
{% extends "base.html" %}
{% block content %}
<input type="search" name="q" value="{{ q }}"
hx-get="/contacts" hx-target="#rows" hx-swap="innerHTML"
hx-trigger="keyup changed delay:500ms">
<table><tbody id="rows">{% include "contacts/_rows.html" %}</tbody></table>
{% endblock %}
{# templates/contacts/_rows.html — auto-escaped; reused by page AND fragment #}
{% for c in rows %}<tr><td>{{ c.name }}</td><td>{{ c.email }}</td></tr>{% endfor %}
Full per-framework wiring (Django django-htmx middleware, Express, CSRF per stack) is in
references/server-contract.md.
| hx-swap | Where the response goes |
|---|---|
| innerHTML | inside the target, replacing contents (default) |
| outerHTML | replaces the target element itself |
| beforebegin / afterbegin | before the target / as its first child |
| beforeend / afterend | as its last child / after the target |
| delete | deletes the target (response ignored) |
| none | does not swap (use with OOB or HX-Trigger) |
Swap modifiers: transition:true, swap:<time> (delay before swap), settle:<time>,
scroll:top|bottom, show:top|bottom, focus-scroll:false.
hx-target accepts a CSS selector, this, or an extended selector: closest <sel>,
find <sel>, next <sel>, previous <sel>. Prefer a stable id over a fragile structural
selector — a deep div > div:nth-child(3) breaks the first time markup shifts.
When one action must refresh more than the target, mark extra elements in the response with
hx-swap-oob. They are swapped into the matching live element by id, bypassing the target.
<!-- Response to POST /cart/add: swap the row in normally... -->
<tr id="row-42">2 × Widget</tr>
<!-- ...and update the cart badge out of band (default OOB swap is outerHTML) -->
<span id="cart-count" hx-swap-oob="true">3 items</span>
hx-swap-oob="true" defaults to outerHTML; you can specify a strategy
(hx-swap-oob="beforeend:#log"). Use OOB instead of firing two requests for two regions.
The server can steer htmx without any client code:
| Response header | Effect |
|---|---|
| HX-Trigger | fire client event(s); JSON value {"event": detail} passes detail |
| HX-Retarget | override hx-target with a CSS selector |
| HX-Reswap | override hx-swap for this response |
| HX-Redirect | full-page client redirect to the given URL |
| HX-Location | client-side navigation *with* an htmx request (no full reload) |
| HX-Push-Url | push a URL into history |
| HX-Refresh | true forces a full page reload |
Request headers htmx sends (read these server-side): HX-Request, HX-Target, HX-Trigger,
HX-Current-URL, HX-Boosted. Full tables in
references/server-contract.md.
hx-boost is the cheapest "SPA feel": it upgrades normal <a>/<form> to AJAX that swaps
<body> with pushState history — no SPA, no JSON.
htmx makes HTML more expressive, so injected HTML is an XSS surface. The htmx-specific rules
(generic theory lives in ../secure-coding/SKILL.md):
| safe / |safe user-controlledcontent. Manually rendering raw user HTML re-introduces XSS that escaping had closed.
hx-*/data-hx-*attributes and inline scripts. An injected hx-get would issue requests you never intended.
hx-disable halts htmx processing for a subtree as defense-in-depth — but it is bypassableby closing the tag, so it is *not* a primary control. Sanitize at the source.
htmx.config.selfRequestsOnly defaults to true in 2.x — keep it. It blocks htmx requeststo other origins.
<body hx-headers='{"X-CSRF-Token": "…"}'> or a hidden form field.
HX-Redirect / HX-Location — never build them from attacker-controlled values;a javascript: URL there is an injection.
Worked, copy-ready recipes — server fragment + client markup for each — live in
references/patterns.md:
keyup changed delay:500ms filtering a results table.hx-trigger="revealed" or a "load more" button.every 600ms polling closed by an HX-Trigger event.| hx-trigger value | Use |
|---|---|
| keyup changed delay:500ms | active search (debounced, only on real change) |
| every 2s | polling a progress/status region |
| revealed | infinite scroll — load when the sentinel scrolls into view |
| intersect once | lazy-load a region once it enters the viewport |
| load delay:1s | deferred load after the page paints |
| click[ctrlKey] | event filter — only ctrl-click |
| submit / change | natural defaults for forms / inputs |
| customEvent from:body | react to an HX-Trigger-fired event from elsewhere |
Modifiers worth knowing: throttle:<time>, queue:first|last|all|none, from:<sel>, once,
changed, delay:<time>.
| Anti-pattern | Why it is wrong | Do instead |
|---|---|---|
| Endpoint returns JSON, JS renders it | that is an SPA; you lose htmx's whole point | return the HTML fragment; the server renders |
| No HX-Request branch | the fragment leaks the full layout (nested <html>) on htmx calls, or a bookmark renders a bare partial | branch: partial vs page wrapping the same partial |
| Two copies of the fragment (page + ajax) | they drift; bug fixed in one, not the other | one partial template, {% include %}d by the page |
| Polling every 1s for a one-off event | wasteful traffic; hammers the server | poll only while pending, end it with HX-Trigger/hx-swap-oob; or use SSE |
| hx-target="div > div:nth-child(3)" | structural selectors shatter when markup shifts | target a stable id |
| {{ user_html | safe }} | unescaped user content → XSS | keep auto-escaping; whitelist-scrub if injecting 3rd-party HTML |
| Rebuilding client state in hx-on/JS | re-creates the SPA state you came here to avoid | let the server hold state; re-render from it |
| Multiple requests to update related regions | extra round-trips, races | one response + hx-swap-oob |
django, rails, laravel — other server frameworks (the htmx contract is identical).accessibility — focus and ARIA after a DOM swap.Take ericrisco/htmx from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.