htmx

Build server-driven web applications with HTMX. Use when implementing dynamic UIs with server-rendered HTML, streaming responses, or adding interactivity without heavy JS frameworks. Includes guidance

SKILL.md

GitHub

HTMX Development

Build server-driven web applications using HTMX. This guide covers best practices, accessibility requirements, and honest guidance on when HTMX is—and isn't—the right choice.

When to Use HTMX

HTMX excels when your application is primarily server-rendered and you want to add interactivity without adopting a full JavaScript framework.

Good Fit ✓

Use Case Why HTMX Works
Server-rendered apps (Django, FastAPI, Rails, Go) Natural extension of existing templates
Content-heavy websites SEO-friendly, fast initial load
CRUD applications Simple request/response patterns
Streaming LLM/chatbot responses SSE extension handles streaming well
Progressive enhancement of forms Graceful degradation possible
Admin dashboards Server handles complexity
Incremental modernization Add to existing apps without rewrite

Poor Fit ✗

Use Case Why HTMX Struggles
Offline-first applications Requires server for every interaction
Real-time collaborative editing Needs complex client-side state sync
High-frequency interactions Drawing tools, games—latency kills UX
Complex client-side state No built-in state management
Apps needing separate mobile API You'll duplicate endpoint logic
Instant feedback requirements Network round-trip adds delay

Be honest with yourself: If you need Redux/Zustand-style state management, you probably need React/Vue/Svelte instead.

Core Attributes

Request Methods

<!-- GET request -->
<button hx-get="/api/data" hx-target="#results">Load</button>

<!-- POST with form data -->
<form hx-post="/api/submit" hx-target="#response">
  <input name="email" type="email" required>
  <button type="submit">Submit</button>
</form>

<!-- Other methods -->
<button hx-put="/api/item/1">Update</button>
<button hx-patch="/api/item/1">Partial Update</button>
<button hx-delete="/api/item/1" hx-confirm="Delete this item?">Delete</button>

Targeting and Swapping

<!-- Target another element -->
<button hx-get="/content" hx-target="#container">Load</button>
<div id="container"></div>

<!-- Swap strategies -->
<div hx-get="/data" hx-swap="innerHTML">Replace contents (default)</div>
<div hx-get="/data" hx-swap="outerHTML">Replace entire element</div>
<div hx-get="/data" hx-swap="beforeend">Append inside</div>
<div hx-get="/data" hx-swap="afterend">Insert after</div>

<!-- Swap modifiers -->
<div hx-get="/data" hx-swap="innerHTML swap:300ms settle:100ms">
  <!-- Delays for animations -->
</div>

Triggers

<!-- Default triggers: click (buttons), change (inputs), submit (forms) -->
<button hx-get="/data">Click triggered</button>

<!-- Custom triggers -->
<input hx-get="/search" hx-trigger="keyup changed delay:500ms" hx-target="#results">

<!-- Trigger modifiers -->
<div hx-get="/data" hx-trigger="click once">Fire once only</div>
<div hx-get="/data" hx-trigger="every 5s">Poll every 5 seconds</div>
<div hx-get="/data" hx-trigger="intersect">When scrolled into view</div>
<div hx-get="/data" hx-trigger="load">On page load</div>

<!-- Listen to other elements -->
<div hx-get="/data" hx-trigger="click from:#other-button">
  Triggered by #other-button
</div>

Accessibility Requirements

HTMX does NOT automatically handle accessibility. You must implement these patterns.

Focus Management

When content updates, manage focus explicitly:

<!-- Server response should include autofocus or JS to manage focus -->
<div id="results" aria-live="polite">
  <!-- Updated content here -->
</div>
# FastAPI example - return focus management in response
@app.get("/search")
async def search(q: str):
    results = await do_search(q)
    return f"""
    <div id="results" aria-live="polite">
        <p class="sr-only">{len(results)} results found</p>
        {render_results(results)}
    </div>
    """

ARIA Live Regions

Announce dynamic content changes to screen readers:

<!-- For important updates -->
<div id="notifications" aria-live="assertive" aria-atomic="true"></div>

<!-- For less critical updates -->
<div id="results" aria-live="polite"></div>

<!-- Indicate loading state -->
<div id="content" aria-busy="true">
  Loading...
</div>

Loading States

<!-- Indicate busy state during requests -->
<button hx-get="/data"
        hx-target="#results"
        hx-indicator="#spinner"
        aria-describedby="loading-status">
  Load Data
</button>

<span id="spinner" class="htmx-indicator" aria-hidden="true">
  <span class="sr-only" id="loading-status">Loading...</span>
  <!-- spinner icon -->
</span>

Use Proper Interactive Elements

<!-- BAD - div is not keyboard accessible -->
<div hx-get="/action" hx-trigger="click">Click me</div>

<!-- GOOD - button is focusable and announces as interactive -->
<button hx-get="/action">Click me</button>

<!-- If you must use a div, add proper attributes -->
<div hx-get="/action"
     hx-trigger="click, keydown[key=='Enter']"
     tabindex="0"
     role="button"
     aria-label="Perform action">
  Click me
</div>

Screen Reader Announcements

<!-- Visually hidden but announced -->
<style>
  .sr-only {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    border: 0;
  }
</style>

<!-- In your response -->
<div id="results">
  <p class="sr-only">Search complete. 5 results found.</p>
  <!-- visible results -->
</div>

Streaming with SSE (LLM/Chatbot Pattern)

HTMX's SSE extension is well-suited for streaming LLM responses.

Setup

<head>
  <script src="https://unpkg.com/htmx.org@2"></script>
  <script src="https://unpkg.com/htmx-ext-sse@2"></script>
</head>

Client-Side Pattern

<div id="chat-container">
  <div id="messages"></div>

  <form hx-post="/chat"
        hx-target="#messages"
        hx-swap="beforeend"
        hx-on::after-request="this.reset()">
    <input name="message" placeholder="Type a message..." autocomplete="off">
    <button type="submit">Send</button>
  </form>
</div>

<!-- SSE streaming response container -->
<div hx-ext="sse"
     sse-connect="/chat/stream"
     sse-swap="message"
     hx-target="#current-response"
     hx-swap="beforeend">
</div>

<div id="current-response"></div>

Server-Side Pattern (FastAPI)

from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from sse_starlette.sse import EventSourceResponse
import asyncio

app = FastAPI()

@app.post("/chat")
async def start_chat(message: str):
    # Return placeholder that will receive streamed content
    return f"""
    <div class="message user">{message}</div>
    <div class="message assistant" id="response-{uuid4()}">
      <div hx-ext="sse"
           sse-connect="/chat/stream?msg={quote(message)}"
           sse-swap="message"
           hx-swap="beforeend">
      </div>
    </div>
    """

@app.get("/chat/stream")
async def stream_response(msg: str):
    async def generate():
        # Stream from your LLM
        async for chunk in llm.stream(msg):
            # SSE format: data field with HTML content
            yield {
                "event": "message",
                "data": f"<span>{escape(chunk)}</span>"
            }
        # Signal completion
        yield {
            "event": "message",
            "data": '<span hx-swap-oob="true" id="status">Complete</span>'
        }

    return EventSourceResponse(generate())

SSE Reconnection

HTMX SSE extension handles reconnection automatically with exponential backoff:

<!-- Configure reconnection -->
<div hx-ext="sse"
     sse-connect="/events"
     sse-reconnect="5">  <!-- Retry up to 5 times -->
</div>

Performance Pitfalls

The "Pop-In" Problem

Over-fragmenting a page causes jarring visual updates:

<!-- BAD - Too many separate requests -->
<div hx-get="/header" hx-trigger="load"></div>
<div hx-get="/sidebar" hx-trigger="load"></div>
<div hx-get="/content" hx-trigger="load"></div>
<div hx-get="/footer" hx-trigger="load"></div>
<!-- User sees parts "pop in" sequentially -->

<!-- GOOD - Single request, or initial server render -->
<div hx-get="/page-content" hx-trigger="load">
  <!-- Or better: render on initial page load -->
</div>

Request Flooding

<!-- BAD - Fires on every keystroke -->
<input hx-get="/search" hx-trigger="keyup">

<!-- GOOD - Debounce with delay -->
<input hx-get="/search" hx-trigger="keyup changed delay:300ms">

<!-- GOOD - Throttle for continuous events -->
<div hx-get="/position" hx-trigger="mousemove throttle:100ms">

Request Cancellation Gotcha

By default, HTMX cancels in-flight requests when a new request is triggered on the same element:

<!-- This can lose user input if they type quickly -->
<input hx-get="/search" hx-trigger="keyup delay:200ms">

<!-- Use hx-sync to control behavior -->
<input hx-get="/search"
       hx-trigger="keyup delay:200ms"
       hx-sync="this:replace">  <!-- Replace previous request -->

Disable Elements During Requests

Prevent double-submission:

<button hx-post="/submit" hx-disabled-elt="this">
  Submit
</button>

<!-- Disable multiple elements -->
<form hx-post="/submit" hx-disabled-elt="find button, find input">
  <input name="data">
  <button>Submit</button>
</form>

Security

Always Escape User Content

Server-side template escaping is critical:

# FastAPI/Jinja2 - auto-escaping is ON by default
# But be careful with |safe filter

# BAD
return f"<div>{user_input}</div>"  # XSS vulnerability

# GOOD - use template with auto-escaping
return templates.TemplateResponse("result.html", {"content": user_input})

CSRF Protection

Include CSRF tokens in all mutating requests:

<!-- Add to <body> or <html> to include in all requests -->
<body hx-headers='{"X-CSRF-Token": "{{ csrf_token }}"}'>
# FastAPI - verify token on server
@app.post("/submit")
async def submit(request: Request):
    token = request.headers.get("X-CSRF-Token")
    if not verify_csrf(token):
        raise HTTPException(403, "Invalid CSRF token")

Restrict Request Origins

// Only allow requests to same origin (default)
htmx.config.selfRequestsOnly = true;

// Disable inline script execution if serving untrusted content
htmx.config.allowScriptTags = false;
htmx.config.allowEval = false;

Sensitive Pages

Exclude from browser history cache:

<div hx-history="false">
  <!-- Sensitive content not cached in localStorage -->
</div>

Progressive Enhancement

HTMX can degrade gracefully, but it requires intentional design:

<!-- Form works with or without JavaScript -->
<form action="/search" method="GET"
      hx-get="/search"
      hx-target="#results"
      hx-push-url="true">
  <input name="q" placeholder="Search...">
  <button type="submit">Search</button>
</form>

<div id="results">
  <!-- Initial content or server-rendered results -->
</div>
# Server detects HTMX requests and responds appropriately
@app.get("/search")
async def search(request: Request, q: str):
    results = await do_search(q)

    # HTMX request - return partial
    if request.headers.get("HX-Request"):
        return templates.TemplateResponse(
            "partials/results.html",
            {"results": results}
        )

    # Regular request - return full page
    return templates.TemplateResponse(
        "search.html",
        {"results": results, "query": q}
    )

Common Patterns

Infinite Scroll

<div id="items">
  {% for item in items %}
    <div class="item">{{ item.name }}</div>
  {% endfor %}

  <div hx-get="/items?page={{ next_page }}"
       hx-trigger="intersect once"
       hx-swap="outerHTML">
    Loading more...
  </div>
</div>

Inline Editing

<div id="field-1">
  <span>{{ value }}</span>
  <button hx-get="/edit/1" hx-target="#field-1">Edit</button>
</div>

<!-- Response replaces with form -->
<form id="field-1" hx-put="/save/1" hx-target="this" hx-swap="outerHTML">
  <input name="value" value="{{ value }}">
  <button type="submit">Save</button>
  <button hx-get="/view/1" hx-target="#field-1">Cancel</button>
</form>

Confirmation Dialogs

<button hx-delete="/item/1"
        hx-confirm="Are you sure you want to delete this item?"
        hx-target="closest .item"
        hx-swap="outerHTML">
  Delete
</button>

Out-of-Band Updates

Update multiple page sections from one response:

<!-- Server response -->
<div id="main-content">
  Updated main content
</div>

<div id="notification" hx-swap-oob="true">
  Item saved successfully!
</div>

<div id="item-count" hx-swap-oob="innerHTML">
  42 items
</div>

Anti-Patterns to Avoid

Anti-Pattern Problem Better Approach
Using <div> for clickable elements Not keyboard accessible Use <button> or <a>
Omitting aria-live on dynamic regions Screen readers miss updates Add appropriate ARIA attributes
Loading every section via HTMX Pop-in effect, slow perceived load Server-render initial content
No debounce on text inputs Request flooding Use delay:Xms modifier
Ignoring hx-disabled-elt Double submissions Disable during requests
Raw string interpolation XSS vulnerabilities Use template auto-escaping
Missing CSRF tokens Security vulnerability Include in hx-headers
Fighting React/Vue for DOM control Conflicts and bugs Choose one approach per page section