htmx Patterns skill for Codex
Server-rendered interactivity without a SPA. Codex builds htmx features on one rule: each URL returns a fragment for htmx requests and a full page otherwise, with Vary set so caches never mix them up. It picks targets and swaps per interaction, syncs overlapping requests, uses out-of-band swaps and HX-Trigger events instead of custom JavaScript, wires CSRF on a shared ancestor, and handles the htmx 2 default of not swapping error responses.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- htmx-patterns Installs to ~/.agents/skills/htmx-patterns/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install htmx-patterns The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project htmx-patterns Installs to .agents/skills/htmx-patterns/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/htmx-patterns.zipunzip -o htmx-patterns.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) htmx-patterns PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $htmx-patterns, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Builds hypermedia-driven UIs with htmx 2 and server-rendered templates (Django, Flask/Jinja, Rails, Laravel Blade, Go templ, Express) - partial vs full-page responses, hx-target and hx-swap choices, triggers with delay and throttle, out-of-band swaps, HX-* response headers, CSRF, validation errors, history and caching. Use when adding active search, inline edit, infinite scroll, modal forms or live validation to a server-rendered app, when an htmx swap targets the wrong element, or when back-button and caching behave oddly.
Use it when
- Adding interactivity to a server-rendered app without adopting a SPA framework
- Building active search, inline edit, infinite scroll or modal forms with htmx
- An htmx swap lands in the wrong element or errors disappear
- Upgrading an app from htmx 1.x to 2.x
Use something else when
- The app needs rich client-side state and offline behaviour: use react-19-patterns
- You are designing the copy and visuals for loading and error states: use ui-copy-states
How to check the result
curl each endpoint with and without the HX-Request header to confirm fragment versus full page, then test validation errors, back-button restore and rapid search input in the browser.
The mistake it prevents
Returning a fragment without a Vary: HX-Request header, so a reload or cached response shows a bare fragment with no layout.
Done well, every htmx endpoint returns HTML that is correct on its own (full page for a normal request, fragment for an htmx request), the app still works for a plain link or form submit where that matters, errors render somewhere visible, and the back button shows the right page.
Before writing code
- Read
AGENTS.md, find the template engine and how routes render views. - Check the htmx version (script tag,
package.json, or vendored file). This skill targets htmx 2.x. If the project is on 1.x, note the 2.0 changes below before copying examples; if on a newer major, read its migration notes first (needs network). - Find existing conventions: a base layout, partial templates directory, a helper that detects htmx requests, how CSRF tokens are exposed.
Core rule: one URL, two renderings
The server checks the HX-Request: true header and returns a fragment for htmx and
the full page otherwise:
# Flask / Jinja
@app.get("/contacts")
def contacts():
q = request.args.get("q", "")
rows = Contact.search(q)
tpl = "contacts/_rows.html" if request.headers.get("HX-Request") else "contacts/index.html"
resp = make_response(render_template(tpl, rows=rows, q=q))
resp.headers["Vary"] = "HX-Request"
return resp
index.html includes _rows.html, so there is one source for the fragment markup.
Always send Vary: HX-Request on such URLs, or a browser/CDN cache serves a fragment
when the user reloads the page. For boosted or history-restored requests, also check
HX-History-Restore-Request and return the full page.
Choosing target and swap
| Goal | Attributes |
|---|---|
| Replace an element’s contents | hx-target="#results" hx-swap="innerHTML" (default swap) |
| Replace the element itself (row edit) | hx-target="closest tr" hx-swap="outerHTML" |
| Append items (infinite list, chat) | hx-swap="beforeend" on the container |
| Remove an element after DELETE | server returns empty 200, hx-swap="outerHTML"; or hx-swap="delete" |
| Fire and forget, no DOM change | hx-swap="none", or server returns 204 |
| Take part of a full-page response | hx-select="#main" |
| Update a second region too (counter, flash) | hx-swap-oob="true" on an element with matching id in the response |
Extended selectors: this, closest <sel>, find <sel>, next <sel>, previous <sel>.
Swap modifiers: swap:200ms, settle:, scroll:top, show:window:top, focus-scroll:false,
transition:true (View Transitions).
Triggers
<input type="search" name="q"
hx-get="/contacts" hx-target="#rows"
hx-trigger="input changed delay:300ms, keyup[key=='Enter'], search"
hx-sync="this:replace"
hx-indicator="#spinner">
| Need | Trigger |
|---|---|
| Debounced typing | input changed delay:300ms |
| Rate-limit scroll/resize | throttle:500ms |
| Infinite scroll | revealed on the last item, or intersect once |
| Lazy load a panel | load (or revealed for below the fold) |
| Polling | every 5s; server returns status 286 to stop |
| Listen to a custom/server event | my-event from:body |
hx-sync="this:replace" aborts the in-flight search when a new one starts; without it,
slow responses can arrive out of order and overwrite newer results.
Patterns
Inline edit (click to edit)
<!-- _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_edit.html -->
<tr id="contact-{{ c.id }}">
<td><input name="name" value="{{ c.name }}" autofocus></td>
<td>
<button hx-put="/contacts/{{ c.id }}" hx-include="closest tr" hx-target="closest tr" hx-swap="outerHTML">Save</button>
<button hx-get="/contacts/{{ c.id }}" hx-target="closest tr" hx-swap="outerHTML">Cancel</button>
</td>
</tr>
Delete with confirmation and disabled button
<button hx-delete="/contacts/{{ c.id }}" hx-confirm="Delete {{ c.name }}?"
hx-target="closest tr" hx-swap="outerHTML swap:300ms" hx-disabled-elt="this">Delete</button>
Form with server-side validation errors: re-render the form fragment with errors
and return it. In htmx 2, 4xx/5xx responses are not swapped by default, so either return
200 with the error markup for validation failures, or configure htmx.config.responseHandling
to swap 422:
htmx.config.responseHandling = [
{ code: '204', swap: false },
{ code: '[23]..', swap: true },
{ code: '422', swap: true }, // validation errors render in place
{ code: '[45]..', swap: false, error: true },
];
Out-of-band update (add a row and bump a counter in one response):
<tr id="contact-42">...</tr>
<span id="contact-count" hx-swap-oob="true">43 contacts</span>
Modal form: hx-get the dialog fragment into a container, call showModal() from
hx-on::after-swap="this.querySelector('dialog').showModal()", and on success return
HX-Trigger: contactSaved so the list refreshes via hx-trigger="contactSaved from:body".
Response headers the server can send
| Header | Effect |
|---|---|
HX-Redirect: /login | Full client-side redirect |
HX-Location: /contacts/42 | htmx-style navigation without full reload |
HX-Refresh: true | Reload the page |
HX-Retarget: #errors / HX-Reswap: innerHTML | Change target or swap from the server |
HX-Trigger: {"showToast": {"msg": "Saved"}} | Fire client events after the response |
HX-Push-Url: /contacts?page=2 | Update the address bar |
For session expiry, return HX-Redirect rather than a 302: a 302 makes htmx swap the
login page into the target element.
Security
- CSRF: put the token on a common ancestor so every request sends it:
<body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>(Django) or the framework’s equivalent header. Non-GET requests without it should fail. - Escape everything in templates as usual; fragments are HTML injected into the page,
so a missed escape is XSS. Never return user content with
|safe/rawhelpers. - Authorisation on every fragment endpoint:
/contacts/42/editmust check ownership just like the full page. hx-onattributes and inline scripts in responses need a CSP that allows them; with a strict CSP, sethtmx.config.allowEval = falseand use event listeners in JS files.
htmx 1.x to 2.x changes that bite
hx-onuseshx-on:click/hx-on::after-requestsyntax; the oldhx-on="click: ..."form is gone.- DELETE requests send parameters in the query string, not the body.
- Extensions (SSE, WebSockets, json-enc, etc.) ship as separate packages; load them explicitly.
- Error responses (4xx/5xx) are not swapped unless configured, as above.
History and boosting
hx-boost="true"on<body>or a nav upgrades links and forms to AJAX with full-page responses; keep the server rendering full pages for those routes.- History snapshots are taken from the DOM; elements holding sensitive or stale data can
opt out with
hx-history="false"on the page. - Third-party widgets initialised on
DOMContentLoadedmust also initialise onhtmx:load(fires for new content), or they break after swaps.
Gotchas
- Swapping
innerHTMLof a container that contains the triggering element destroys the trigger mid-request; target a sibling or useouterHTMLon a wrapper. hx-targetis inherited by children; an unexpected target often comes from an ancestor. Usehx-disinheritor set the target explicitly.- Returning a full page to an htmx request nests a whole
<html>inside adiv. CheckHX-Requestin a shared helper, not per view. - Duplicate ids after swaps break OOB swaps and label associations.
- Loading state needs CSS:
.htmx-indicator { opacity: 0 } .htmx-request .htmx-indicator, .htmx-request.htmx-indicator { opacity: 1 }.
Verification
- Each endpoint returns a fragment with
HX-Requestand a full page without it (curl both) -
Vary: HX-Requestpresent; hard reload after navigation shows a full page - Validation errors, 401/403 and 500 responses render visibly, not silently ignored
- Back and forward buttons restore correct content after boosted navigation and pushes
- Rapid typing in search shows results for the final query only
- Non-GET requests fail without the CSRF token
curl -s -H 'HX-Request: true' 'http://localhost:8000/contacts?q=ann' | head -20
curl -sI 'http://localhost:8000/contacts' | grep -i vary What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "htmx Patterns"
short_description: "Server-rendered interactivity without a SPA"
default_prompt: "$htmx-patterns Add live search to our Django contacts page with htmx" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.