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.

  • Frontend
  • $htmx-patterns
  • 201 lines
  • 2 files
  • ~2,150 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- htmx-patterns

Installs to ~/.agents/skills/htmx-patterns/ for every project on this machine.

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.

~/.agents/skills/htmx-patterns/SKILL.md

--- name: htmx-patterns description: Builds hypermedia-driven UIs with htmx 2 and server-rendered templates (Django, Flask/Jinja, Rails, Laravel Blade, Go templ, Express) - partial vs… license: MIT ---

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

  1. Read AGENTS.md, find the template engine and how routes render views.
  2. 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).
  3. 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

GoalAttributes
Replace an element’s contentshx-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 DELETEserver returns empty 200, hx-swap="outerHTML"; or hx-swap="delete"
Fire and forget, no DOM changehx-swap="none", or server returns 204
Take part of a full-page responsehx-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">
NeedTrigger
Debounced typinginput changed delay:300ms
Rate-limit scroll/resizethrottle:500ms
Infinite scrollrevealed on the last item, or intersect once
Lazy load a panelload (or revealed for below the fold)
Pollingevery 5s; server returns status 286 to stop
Listen to a custom/server eventmy-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

HeaderEffect
HX-Redirect: /loginFull client-side redirect
HX-Location: /contacts/42htmx-style navigation without full reload
HX-Refresh: trueReload the page
HX-Retarget: #errors / HX-Reswap: innerHTMLChange target or swap from the server
HX-Trigger: {"showToast": {"msg": "Saved"}}Fire client events after the response
HX-Push-Url: /contacts?page=2Update 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/raw helpers.
  • Authorisation on every fragment endpoint: /contacts/42/edit must check ownership just like the full page.
  • hx-on attributes and inline scripts in responses need a CSP that allows them; with a strict CSP, set htmx.config.allowEval = false and use event listeners in JS files.

htmx 1.x to 2.x changes that bite

  • hx-on uses hx-on:click / hx-on::after-request syntax; the old hx-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 DOMContentLoaded must also initialise on htmx:load (fires for new content), or they break after swaps.

Gotchas

  • Swapping innerHTML of a container that contains the triggering element destroys the trigger mid-request; target a sibling or use outerHTML on a wrapper.
  • hx-target is inherited by children; an unexpected target often comes from an ancestor. Use hx-disinherit or set the target explicitly.
  • Returning a full page to an htmx request nests a whole <html> inside a div. Check HX-Request in 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-Request and a full page without it (curl both)
  • Vary: HX-Request present; 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.yamlgenerated from this page

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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem