HTMX and Alpine.js represent a different philosophy than SPA frameworks: server-rendered HTML with targeted dynamic updates, rather than full client-side rendering. HTMX makes any element issue HTTP requests and swap HTML fragments into the DOM. Alpine.js handles local component state without a build step. Together, they produce interactive UIs with dramatically less JavaScript — appropriate for teams wanting to keep the stack simple or incrementally enhance multi-page apps. Claude Code writes HTMX attribute patterns, Alpine.js components, and the server-side handlers that return HTML fragments.
CLAUDE.md for HTMX/Alpine Projects
## Frontend Stack
- HTMX 2.x for server-driven interactivity
- Alpine.js 3.x for client-side local state
- Server: Django, FastAPI, or Express returning HTML fragments (not JSON)
- Templating: Jinja2 (Django/FastAPI) or Handlebars (Node)
- CSS: Tailwind
- No build step: HTMX and Alpine loaded from CDN or vendored JS file
- Philosophy: server owns state, client enhances presentation
- Each HTMX endpoint returns an HTML fragment, not a full page
HTMX Fundamentals
<!-- Basic HTMX: GET on click, swap result into element -->
<button
hx-get="/api/orders/status"
hx-target="#order-status"
hx-swap="innerHTML"
hx-trigger="click"
>
Refresh Status
</button>
<div id="order-status">Loading...</div>
<!-- POST form without page reload -->
<form
hx-post="/api/orders/create"
hx-target="#order-result"
hx-swap="outerHTML"
hx-indicator="#spinner"
>
<input type="text" name="customer_id" required />
<button type="submit">Place Order</button>
</form>
<!-- Loading indicator: shown during request -->
<div id="spinner" class="htmx-indicator">Processing...</div>
<!-- Infinite scroll: load next page when last item enters viewport -->
<div
hx-get="/orders?page=2"
hx-trigger="revealed"
hx-swap="afterend"
hx-target="this"
>
<div class="order-item"><!-- last item --></div>
</div>
<!-- Live search with debounce -->
<input
type="search"
name="q"
hx-get="/api/orders/search"
hx-target="#results"
hx-trigger="keyup changed delay:300ms"
hx-indicator="#search-spinner"
placeholder="Search orders..."
/>
Server-Side Fragment Handlers (FastAPI + Jinja2)
# routes/orders.py
from fastapi import APIRouter, Request, Depends, Form
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse
router = APIRouter()
templates = Jinja2Templates(directory="templates")
@router.get("/api/orders/search", response_class=HTMLResponse)
async def search_orders(request: Request, q: str = ""):
orders = await db.search_orders(q) if q else []
# Return only the fragment — HTMX replaces #results with this
return templates.TemplateResponse("fragments/order_list.html", {
"request": request,
"orders": orders,
"query": q,
})
@router.post("/api/orders/create", response_class=HTMLResponse)
async def create_order(
request: Request,
customer_id: str = Form(...),
current_user: User = Depends(get_current_user),
):
result = await place_order_service(customer_id, current_user)
if result.success:
# HX-Trigger: tell HTMX to trigger a custom event (for out-of-band updates)
response = templates.TemplateResponse("fragments/order_created.html", {
"request": request,
"order": result.order,
})
response.headers["HX-Trigger"] = "orderCreated"
return response
else:
# Return error fragment with 422 to replace the form
response = templates.TemplateResponse("fragments/order_error.html", {
"request": request,
"error": result.error,
})
response.status_code = 422
return response
<!-- templates/fragments/order_list.html -->
{% if orders %}
<ul class="order-list">
{% for order in orders %}
<li class="order-item">
<span class="order-id">#{{ order.id[-6:] }}</span>
<span class="order-status badge badge-{{ order.status|lower }}">{{ order.status }}</span>
<span class="order-total">${{ "%.2f"|format(order.total_cents / 100) }}</span>
</li>
{% endfor %}
</ul>
{% else %}
<p class="empty-state">No orders found{% if query %} for "{{ query }}"{% endif %}.</p>
{% endif %}
Out-of-Band Swaps
<!-- Update multiple parts of the page from a single response -->
<!-- The server response can include hx-swap-oob elements -->
<!-- Main swap target: #order-form -->
<div id="new-order-form">
<!-- Replaced with success message -->
<p>Order placed successfully!</p>
</div>
<!-- Out-of-band update: sidebar counter and notification badge both update automatically -->
<span id="order-count" hx-swap-oob="true">42</span>
<span id="notification-badge" hx-swap-oob="true" class="badge">1</span>
# Server returns both the main fragment AND out-of-band updates
@router.post("/api/orders/create", response_class=HTMLResponse)
async def create_order(request: Request, ...):
order = await place_order(...)
new_count = await db.count_orders()
# Render main content + OOB updates in one response
main = render_template("fragments/order_success.html", order=order)
oob_count = f'<span id="order-count" hx-swap-oob="true">{new_count}</span>'
return HTMLResponse(main + oob_count)
Server-Sent Events for Real-Time
<!-- HTMX SSE extension: live order status updates -->
<div
hx-ext="sse"
sse-connect="/api/orders/123/events"
sse-swap="order-status-update"
hx-target="#order-status-content"
hx-swap="innerHTML"
>
<div id="order-status-content">
<p>Status: Pending</p>
</div>
</div>
from fastapi.responses import StreamingResponse
import asyncio
@router.get("/api/orders/{order_id}/events")
async def order_events(order_id: str):
async def event_generator():
previous_status = None
while True:
order = await db.get_order(order_id)
if order.status != previous_status:
previous_status = order.status
html_fragment = render_template("fragments/order_status.html", order=order)
# SSE format: event name must match sse-swap attribute
yield f"event: order-status-update\n"
yield f"data: {html_fragment}\n\n"
if order.status in ("DELIVERED", "CANCELLED"):
break # Stop streaming when terminal state reached
await asyncio.sleep(2)
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
Alpine.js for Local State
<!-- Alpine.js: client-side state without server round-trips -->
<!-- Cart quantity selector -->
<div x-data="{ quantity: 1 }">
<button @click="quantity = Math.max(1, quantity - 1)">−</button>
<input
type="number"
x-model="quantity"
min="1"
max="99"
class="quantity-input"
/>
<button @click="quantity = Math.min(99, quantity + 1)">+</button>
<!-- Display computed value -->
<span x-text="`Total: $${(quantity * 19.99).toFixed(2)}`"></span>
<!-- Pass to HTMX POST via hidden input -->
<input type="hidden" name="quantity" :value="quantity" />
</div>
<!-- Dropdown with click-outside-to-close -->
<div
x-data="{ open: false }"
@keydown.escape.window="open = false"
@click.outside="open = false"
class="dropdown"
>
<button @click="open = !open" :aria-expanded="open">
Filter <span x-text="open ? '▲' : '▼'"></span>
</button>
<div x-show="open" x-transition class="dropdown-menu">
<a href="?status=PENDING">Pending</a>
<a href="?status=SHIPPED">Shipped</a>
</div>
</div>
<!-- Form validation with Alpine -->
<form
x-data="{
email: '',
get isValidEmail() { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.email) },
submitted: false
}"
hx-post="/api/newsletter/subscribe"
@submit="submitted = true"
>
<input
type="email"
x-model="email"
:class="{ 'border-red-500': submitted && !isValidEmail }"
name="email"
placeholder="[email protected]"
/>
<span x-show="submitted && !isValidEmail" class="text-red-500">
Please enter a valid email
</span>
<button type="submit" :disabled="submitted && !isValidEmail">Subscribe</button>
</form>
HX-Boost: Progressive Enhancement
<!-- hx-boost: turns regular links and forms into HTMX-enhanced requests
The whole page still works without JavaScript -->
<body hx-boost="true">
<!-- These links now load without full page reload -->
<nav>
<a href="/orders">Orders</a>
<a href="/products">Products</a>
<a href="/settings">Settings</a>
</nav>
<!-- Title updates automatically from <title> in response -->
<!-- Browser history updates via pushState -->
</body>
For the Django templates that HTMX fragments integrate with, see the Django REST Framework guide for backend patterns. For comparing with full React SPAs, the React guide covers when a SPA is worth the complexity. The Claude Skills 360 bundle includes HTMX/Alpine skill sets covering fragment patterns, SSE, and out-of-band updates. Start with the free tier to try HTMX endpoint generation.