--- name: htmx description: "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`)." tags: [htmx, hypermedia, frontend, server-rendered, html] recommends: [fastapi, django, secure-coding, accessibility] origin: risco --- # htmx: hypermedia-driven UIs 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:** 1. a verb — `hx-get` / `hx-post` / `hx-put` / `hx-patch` / `hx-delete` (the URL) 2. `hx-target` — which DOM node receives the response (CSS selector or `this`) 3. `hx-swap` — how the response is placed (default `innerHTML`) 4. `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: ```html ``` ## When to use - Adding interactivity to a server-rendered app without a JS framework. - Wiring a verb + `hx-target` + `hx-swap` + `hx-trigger` to update a page region. - Returning **partials/fragments** — full page on direct navigation, fragment on htmx request (branch on the `HX-Request` header). - Out-of-band updates (`hx-swap-oob`) when one response must refresh several regions. - Trigger-driven UX: active search, infinite scroll, inline edit, click-to-load, polling, `revealed`, `intersect`, debounced input. - Server-driven control flow via response headers (`HX-Trigger`, `HX-Redirect`, …). ## When NOT to use — route elsewhere - A client-state-heavy SPA (offline, optimistic UI everywhere, complex client routing) → `react`, `vue-nuxt`, `svelte`, `solid-js`, `angular`, or ../nextjs/SKILL.md. htmx is the anti-SPA; do not fight it. - How to structure the server framework itself (routers, ORM, controllers) → ../fastapi/SKILL.md, `django`, `rails`, `laravel`. This skill owns the *htmx contract* (which fragment, which header, which swap), not framework internals. Cross-link, do not duplicate. - Generic XSS/CSP/auth theory → ../secure-coding/SKILL.md. Keep only the htmx-specific notes here. - Focus management and ARIA live regions after a swap → `accessibility`. - Browser E2E of swaps → `testing-web` / `e2e-testing`. - Purely-client state (Alpine, vanilla sprinkles) → out of scope; htmx is for server round-trips. ## Decision rules 1. **Return HTML, never JSON, for the UI.** The response *is* the new DOM. JSON forces a client renderer, which is the SPA you are trying to avoid. 2. **Branch on `HX-Request`: fragment for htmx, full page otherwise.** A bookmarked URL or hard refresh must still render a whole page; the htmx call gets just the partial. 3. **The full page is the layout wrapping the *same* partial.** One template for the fragment, reused inside the page layout — never two copies that drift. 4. **Set `hx-target` and `hx-swap` explicitly when the default is wrong.** Default target is the element itself; default swap is `innerHTML`. Be explicit the moment you need otherwise. 5. **Use out-of-band swaps for multi-region updates, not multiple requests.** One action that changes a list *and* a counter is one response with one OOB element. 6. **Drive UX with `hx-trigger`, not JavaScript.** Debounce, polling, reveal, intersect are all trigger modifiers — reaching for `addEventListener` usually means you missed a modifier. 7. **Steer the client from the server with response headers.** Redirect, retarget, reswap, and fire events via `HX-*` response headers instead of branching logic in the browser. 8. **Escape everything; add CSRF yourself.** Your template engine auto-escapes — keep it on. htmx does not add CSRF tokens; you propagate them via `hx-headers` or a hidden field. ## Request anatomy: Bad to Good ```html ``` ```html 42 likes ``` The Good version has no JS, no JSON, no client state. The server computed the count and rendered the truth; the client placed it. ## Fragment rendering: branch on HX-Request Framework-agnostic rule: **if `HX-Request: true`, render the partial; otherwise render the page that embeds that same partial.** Concrete FastAPI + Jinja2: ```python 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}) ``` ```jinja {# templates/contacts/index.html — the page wraps the SAME partial #} {% extends "base.html" %} {% block content %}