Word count: ~2,600 | Code examples: 8 | Target: Intermediate-to-senior web developers
htmx is no longer the shiny new toy. It is four years old, shipping in production at scale, and the patterns that survive are not the ones from conference talks -- they are the ones that solve real problems without introducing new ones. This article is about those patterns: the ones that hold up under real traffic, real teams, and real deadlines.
I have spent the past two years building with htmx across a fintech dashboard, a CMS back-office, and a multi-tenant SaaS admin panel. Here is what actually works.
htmx shines when you treat the server as the source of truth and stop fighting the browser. The winning pattern is not "replace React" but "use htmx for the parts of your application where server-rendered HTML is the natural fit, and reach for JavaScript only when the interaction genuinely requires client-side state."
That means: forms, tables, dashboards, multi-step wizards, notification feeds, search results, CRUD interfaces. These are the bread and butter of web development, and htmx handles them with a fraction of the code.
The most common pattern I use. You have a page with expensive-to-render sections (analytics cards, activity feeds, recommendation panels). You do not want to slow down the initial page load.
<!-- Page shell renders instantly -->
<div class="dashboard">
<h1>Dashboard</h1>
<!-- This section loads after the page is visible -->
<div
hx-get="/dashboard/revenue-cards"
hx-trigger="intersect once delay:100ms"
hx-swap="innerHTML">
<div class="skeleton">Loading revenue data...</div>
</div>
<!-- This loads when scrolled into view -->
<div
hx-get="/dashboard/activity-feed"
hx-trigger="intersect once threshold:0.1"
hx-swap="innerHTML">
<div class="skeleton">Loading activity...</div>
</div>
</div>The backend endpoint returns pre-rendered HTML:
# FastAPI backend
@app.get("/dashboard/revenue-cards")
async def revenue_cards(request: Request, session: AsyncSession = Depends(get_session)):
metrics = await get_revenue_metrics(session, tenant_id=request.state.tenant_id)
# Render a Jinja2 partial -- no JSON serialization needed
return templates.TemplateResponse("partials/revenue_cards.html", {"metrics": metrics})Here is the Jinja2 partial:
<!-- templates/partials/revenue_cards.html -->
<div class="metrics-grid">
{% for metric in metrics %}
<div class="metric-card">
<span class="metric-label">{{ metric.label }}</span>
<span class="metric-value {% if metric.trend == 'up' %}positive{% else %}negative{% endif %}">
{{ metric.formatted_value }}
</span>
</div>
{% endfor %}
</div>Why this beats the SPA approach: no loading state management, no error boundaries, no useEffect cleanup. The server decides what to render; the browser just puts it where it belongs. If the endpoint fails, htmx swaps in nothing and you handle it with a global error listener.
Production hardening: add hx-indicator to show a spinner, and set hx-target-error on a global error handler that renders a consistent error partial:
<div id="global-error" class="toast toast-error" style="display:none"></div>
<script>
document.body.addEventListener('htmx:responseError', function(evt) {
if (evt.detail.xhr.status === 500) {
var el = document.getElementById('global-error');
el.textContent = 'Something went wrong. Our team has been notified.';
el.style.display = 'block';
setTimeout(function() { el.style.display = 'none'; }, 5000);
}
});
</script>Optimistic updates -- where the UI updates immediately before the server confirms -- are the poster child for client-side state. But htmx handles them elegantly if you structure your responses correctly.
The trick: return the same HTML partial from your POST handler that you would return from a GET. htmx swaps it in, and if the server applied the change, the new partial reflects reality. If there is an error, return the original state.
<!-- Task list with inline toggle -->
<ul id="task-list" hx-target="#task-list" hx-swap="innerHTML">
{% for task in tasks %}
<li id="task-{{ task.id }}" class="task-item">
<span class="task-title">{{ task.title }}</span>
<button
hx-post="/tasks/{{ task.id }}/toggle"
hx-swap="outerHTML"
hx-target="#task-{{ task.id }}">
{% if task.done %}Undo{% else %}Complete{% endif %}
</button>
</li>
{% endfor %}
</ul>The server returns the entire list item with the updated state. htmx swaps it instantly -- the user sees the change before the server even finishes processing:
@app.post("/tasks/{task_id}/toggle")
async def toggle_task(task_id: int, session: AsyncSession = Depends(get_session)):
task = await session.get(Task, task_id)
task.done = not task.done
await session.commit()
# Return the updated list item HTML
return templates.TemplateResponse("partials/task_item.html", {"task": task})The partial:
<!-- templates/partials/task_item.html -->
<li id="task-{{ task.id }}" class="task-item {% if task.done %}completed{% endif %}">
<span class="task-title">{{ task.title }}</span>
<button
hx-post="/tasks/{{ task.id }}/toggle"
hx-swap="outerHTML"
hx-target="#task-{{ task.id }}">
{% if task.done %}Undo{% else %}Complete{% endif %}
</button>
</li>This gives you optimistic UI for free because the POST response replaces the button and the list item in one swap. The user does not see a loading state -- they see the toggle happen. If the server fails (database error, validation), return the original HTML with an error class and htmx preserves the original button text.
Production hardening: add hx-indicator on the button to show a subtle loading state if the network is slow:
<button
hx-post="/tasks/{{ task.id }}/toggle"
hx-swap="outerHTML"
hx-target="#task-{{ task.id }}"
hx-indicator="#toggle-spinner-{{ task.id }}">
{% if task.done %}Undo{% else %}Complete{% endif %}
<span id="toggle-spinner-{{ task.id }}" class="htmx-indicator spinner-mini"></span>
</button>Real-time updates without WebSockets. SSE is a one-way stream from server to browser, and htmx has native support. Perfect for notification feeds, live dashboards, and activity logs.
<!-- Live notification feed -->
<div
hx-ext="sse"
sse-connect="/events/stream?tenant={{ tenant_id }}"
sse-swap="notification"
hx-swap="afterbegin">
<!-- New notifications appear at the top -->
</div>The server endpoint streams HTML over SSE:
import asyncio
from fastapi.responses import StreamingResponse
@app.get("/events/stream")
async def event_stream(request: Request, tenant_id: str):
async def generate():
last_id = 0
while True:
# Check if client disconnected
if await request.is_disconnected():
break
notifications = await get_notifications_since(tenant_id, last_id)
for notification in notifications:
html = templates.get_template("partials/notification_item.html").render({
"notification": notification
})
yield f"event: notification\ndata: {html}\nid: {notification.id}\n\n"
last_id = max(last_id, notification.id)
await asyncio.sleep(2) # Poll every 2 seconds
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no", # Disable nginx buffering
}
)The notification partial:
<!-- templates/partials/notification_item.html -->
<div class="notification notification-{{ notification.type }}" id="notif-{{ notification.id }}">
<span class="notif-time">{{ notification.timestamp.strftime('%H:%M') }}</span>
<span class="notif-body">{{ notification.message }}</span>
</div>Make this pattern production-grade by reconnecting after disconnection and using Last-Event-ID for catch-up:
<div
hx-ext="sse"
sse-connect="/events/stream?tenant={{ tenant_id }}"
sse-swap="notification"
sse-close="close"
hx-swap="afterbegin">
</div>
<script>
// Reconnect on SSE close
document.body.addEventListener('sse:close', function(evt) {
var el = evt.detail.elt;
setTimeout(function() {
el.setAttribute('sse-connect', el.getAttribute('sse-connect'));
}, 3000);
});
</script>On the server side, read the Last-Event-ID header to resume from where the client left off:
last_event_id = request.headers.get("Last-Event-ID", "0")
last_id = int(last_event_id)You update a cart and need to change three things: the cart count in the header, the item row in the table, and the total at the bottom. Without htmx 2.0's multi-swap, you would need three separate requests. With hx-swap-oob, a single response updates multiple targets.
<!-- Header has a cart badge -->
<span id="cart-count" class="badge">{{ cart.item_count }}</span>
<!-- Cart table -->
<tbody id="cart-body" hx-target="#cart-body" hx-swap="innerHTML">
{% for item in cart.items %}
<tr id="cart-item-{{ item.id }}">
<td>{{ item.name }}</td>
<td>
<button hx-post="/cart/{{ item.id }}/remove" hx-swap="none">
Remove
</button>
</td>
</tr>
{% endfor %}
</tbody>
<!-- Total -->
<div id="cart-total">${{ cart.total }}</div>The server response for removing an item returns multiple HTML fragments, each targeted to a specific element:
@app.post("/cart/{item_id}/remove")
async def remove_cart_item(item_id: int, session: AsyncSession = Depends(get_session)):
cart = await get_or_create_cart(session)
await cart.remove_item(item_id)
await session.commit()
# Build response with out-of-band swaps
response = ""
# Replace the cart count in the header
response += templates.get_template("partials/cart_count.html").render({
"count": cart.item_count
})
# Replace the entire cart body
response += templates.get_template("partials/cart_body.html").render({
"cart": cart
})
# Replace the total
response += templates.get_template("partials/cart_total.html").render({
"total": cart.total
})
return HTMLResponse(response)Each partial targets its element with hx-swap-oob:
<!-- partials/cart_count.html -->
<span id="cart-count" class="badge" hx-swap-oob="true">{{ count }}</span>
<!-- partials/cart_body.html -->
<tbody id="cart-body" hx-swap-oob="true" hx-target="#cart-body" hx-swap="innerHTML">
{% for item in cart.items %}
<tr id="cart-item-{{ item.id }}">
<td>{{ item.name }}</td>
<td><button hx-post="/cart/{{ item.id }}/remove" hx-swap="none">Remove</button></td>
</tr>
{% endfor %}
</tbody>
<!-- partials/cart_total.html -->
<div id="cart-total" hx-swap-oob="true">${{ total }}</div>Production note: when the cart is empty after removing the last item, render an empty state instead of an empty table:
{% if cart.items %}
<tbody id="cart-body" hx-swap-oob="true">
{% for item in cart.items %}
<tr>...</tr>
{% endfor %}
</tbody>
{% else %}
<tbody id="cart-body" hx-swap-oob="true">
<tr><td colspan="3" class="empty-state">Your cart is empty</td></tr>
</tbody>
{% endif %}A pattern that looks like a React-controlled input but is pure HTML. Click on a value, it becomes an input. Submit saves to the server. The server returns the updated display.
<div id="user-name-{{ user.id }}">
<span
hx-get="/users/{{ user.id }}/name/edit"
hx-trigger="click"
hx-swap="outerHTML">
{{ user.name }}
</span>
</div>When clicked, the server returns the edit form:
<form
hx-put="/users/{{ user.id }}/name"
hx-target="#user-name-{{ user.id }}"
hx-swap="outerHTML">
<input
type="text"
name="name"
value="{{ user.name }}"
hx-get="/users/{{ user.id }}/name"
hx-trigger="keyup[key=='Escape']"
hx-swap="outerHTML"
hx-target="#user-name-{{ user.id }}"
autofocus>
<button type="submit">Save</button>
</form>On save, the server returns the new display span:
@app.put("/users/{user_id}/name")
async def update_user_name(
user_id: int,
name: str = Form(...),
session: AsyncSession = Depends(get_session)
):
user = await session.get(User, user_id)
user.name = name.strip()
await session.commit()
return templates.TemplateResponse("partials/user_name_display.html", {"user": user})The display partial:
<span
hx-get="/users/{{ user.id }}/name/edit"
hx-trigger="click"
hx-swap="outerHTML">
{{ user.name }}
</span>Pressing Escape triggers a GET to the display endpoint, reverting without saving. This is pure htmx -- no JavaScript state machine, no useState, no onBlur handler.
Production hardening: add hx-validate and server-side validation:
@app.put("/users/{user_id}/name")
async def update_user_name(user_id: int, name: str = Form(...), ...):
if not name.strip():
return HTMLResponse(
templates.get_template("partials/inline_error.html").render({
"message": "Name cannot be empty"
}),
status_code=422
)
...htmx handles debounce natively. No lodash, no useMemo, no useEffect. Just attributes.
<input
type="search"
name="q"
placeholder="Search users..."
hx-get="/users/search"
hx-trigger="keyup changed delay:300ms"
hx-target="#search-results"
hx-swap="innerHTML"
hx-indicator="#search-spinner">
<div id="search-results">
<!-- Results appear here -->
</div>
<img id="search-spinner" class="htmx-indicator" src="/static/spinner.svg">The delay:300ms modifier debounces requests. The changed modifier only fires when the value actually changes (not when the user presses arrow keys). This is the same pattern you write in React, but it is three attributes instead of a half-dozen hooks.
The search endpoint:
@app.get("/users/search")
async def search_users(q: str = "", session: AsyncSession = Depends(get_session)):
if len(q) < 2:
return "" # Don't search on short queries
users = await session.execute(
select(User).where(User.name.ilike(f"%{q}%")).limit(10)
)
return templates.TemplateResponse("partials/user_search_results.html", {
"users": users.scalars().all(),
"query": q
})Production hardening: the sync:true modifier on rapidly-updating inputs and hx-history="false" if the search should not pollute the browser back button:
<input
type="search"
name="q"
hx-get="/users/search"
hx-trigger="keyup changed delay:300ms"
hx-target="#search-results"
hx-history="false">Multi-step forms are the canonical "you need a framework" example. But htmx handles them cleanly by treating each step as a server-rendered partial. The server owns the wizard state.
<!-- Step container -->
<div id="wizard">
<div
hx-get="/wizard/onboarding/step-1"
hx-trigger="load"
hx-swap="innerHTML">
</div>
</div>Each step is a self-contained partial with navigation:
<!-- partials/wizard_step_1.html -->
<form
hx-post="/wizard/onboarding/step-1"
hx-target="#wizard"
hx-swap="innerHTML">
<h2>Step 1: Company Info</h2>
<input type="text" name="company_name" required value="{{ data.company_name or '' }}">
<div class="wizard-actions">
<button type="submit" class="btn-primary">Next</button>
</div>
</form>The server validates and advances:
wizard_state = {} # In production, store in session or Redis
@app.post("/wizard/onboarding/step-1")
async def wizard_step1(
request: Request,
company_name: str = Form(...),
):
if not company_name.strip():
return templates.TemplateResponse("partials/wizard_step_1.html", {
"data": {"company_name": company_name},
"error": "Company name is required"
})
request.session["wizard_data"] = {**request.session.get("wizard_data", {}), "company_name": company_name}
return templates.TemplateResponse("partials/wizard_step_2.html", {
"data": request.session["wizard_data"]
})The browser back button works naturally because htmx pushes each step onto the history stack. You can add hx-push-url="true" on each step to make the URL reflect the wizard state:
<form
hx-post="/wizard/onboarding/step-1"
hx-target="#wizard"
hx-swap="innerHTML"
hx-push-url="/onboarding/company-info">Production hardening: use hx-preserve to keep the wizard container across steps, preventing re-initialization of any JavaScript-enhanced inputs:
<div id="wizard" hx-preserve="true">
...
</div>Another pattern that is trivial with htmx and needlessly complex with SPAs.
<div id="feed">
{% for post in posts %}
{% include "partials/post_card.html" %}
{% endfor %}
{% if has_more %}
<div
hx-get="/feed?page={{ next_page }}"
hx-trigger="intersect once"
hx-swap="outerHTML"
hx-target="this">
<div class="loading-more">Loading more posts...</div>
</div>
{% endif %}
</div>When the loading div scrolls into view, htmx fires a GET to the next page. The server returns the next batch of posts plus a new sentinel div:
@app.get("/feed")
async def feed(page: int = 1, session: AsyncSession = Depends(get_session)):
per_page = 20
posts = await get_posts_page(session, page=page, per_page=per_page)
has_more = len(posts) == per_page
return templates.TemplateResponse("partials/feed_page.html", {
"posts": posts,
"has_more": has_more,
"next_page": page + 1
})Being honest about where htmx does not fit is as important as knowing where it does. Do not use htmx for:
-
Collaborative real-time editing (Google Docs-style). You need CRDTs and a WebSocket sync engine. htmx can handle the non-editing UI around it, but not the editor itself.
-
Heavily interactive canvases (Figma, Excalidraw). These are genuine client-side applications. Use htmx for the surrounding chrome, a canvas library for the canvas.
-
Offline-first applications. htmx assumes a server. If your app must work fully offline with local mutations that sync later, you need a client-side state layer.
-
Animations between route transitions. htmx swaps DOM elements. If you need FLIP animations or shared element transitions, use the View Transitions API alongside htmx (which works well), but htmx alone does not animate.
After deploying htmx to production multiple times, here is what needs attention:
- CSRF tokens: add
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'to the body tag so every htmx request includes it. - History management:
hx-push-urlmakes URLs shareable and the back button work. Use it on navigation and multi-step flows. - Error handling: register a global
htmx:responseErrorlistener that shows a toast for 500s and inline errors for 422s. - Indicator styling: htmx adds
.htmx-requestto the triggering element and.htmx-indicatoris shown/hidden automatically. Use these for loading states. - Response headers: set
HX-Triggerto fire client-side events after a swap. For example,HX-Trigger: {"refreshNotifications": ""}lets other parts of the page react to a mutation. - Nginx buffering: disable proxy buffering for SSE endpoints (
proxy_buffering off;) or the stream will hang. - Database session management: in async Python, htmx can make many concurrent requests. Use a connection pool large enough for your expected concurrent request count.
htmx is not a React killer and it does not need to be. It is the right tool for the 80% of web interactions that are fundamentally request-response: forms, lists, searches, wizards, and dashboards. The patterns above are not theoretical -- they run in production, they are easy to debug, and they let you ship features with a fraction of the code.
The most important shift is not technical. It is philosophical. Stop treating the server as a dumb JSON pipe and start treating it as the thing that knows what the user should see next. htmx just gives you the attributes to make that happen.