Server-Driven UI with htmx

Engineering
Share on LinkedIn Share on X Share on Reddit Share on HN Share on Bluesky

title: "Server-Driven UI with htmx" slug: "web-htmx-server-driven-ui" description: "Build interactive web apps with htmx: HTML-over-the-wire, hx-get and hx-post attributes, partial page updates, and when server-driven UI beats SPA complexity." datePublished: "2026-05-05" dateModified: "2026-07-17" tags:



title: "web-htmx-server-driven-ui" slug: "web-htmx-server-driven-ui" description: "" datePublished: "2026-07-17" dateModified: "2026-07-17" tags:



title: "web-htmx-server-driven-ui" slug: "web-htmx-server-driven-ui" description: "" datePublished: "2026-07-17" dateModified: "2026-07-17" tags:



title: "web-htmx-server-driven-ui" slug: "web-htmx-server-driven-ui" description: "" datePublished: "2026-07-17" dateModified: "2026-07-17" tags:



title: "web-htmx-server-driven-ui" slug: "web-htmx-server-driven-ui" description: "" datePublished: "2026-07-17" dateModified: "2026-07-17" tags:



title: "web-htmx-server-driven-ui" slug: "web-htmx-server-driven-ui" description: "" datePublished: "2026-07-17" dateModified: "2026-07-17" tags:



title: "Server-Driven UI with htmx" slug: "web-htmx-server-driven-ui" description: "Build interactive web apps with htmx: HTML-over-the-wire, hx-get and hx-post attributes, partial page updates, and when server-driven UI beats SPA complexity." datePublished: "2026-05-05" dateModified: "2026-07-17" tags:



Our admin dashboard was a React SPA with 200 API endpoints returning JSON that the client re-rendered into HTML. Adding a filter dropdown required a new API route, a TypeScript type, a React component, and a loading state. We rebuilt one section with htmx: the server returned an HTML table fragment, the client swapped it in. The feature shipped in an afternoon.

Core attributes

<!-- Load content on click -->
<button hx-get="/stats" hx-target="#stats-panel" hx-swap="innerHTML">
  Load stats
</button>
<div id="stats-panel"></div>

<!-- Submit form, replace table body -->
<form hx-post="/users/search" hx-target="#user-table" hx-swap="outerHTML">
  <input name="q" type="search" placeholder="Search users" />
  <button type="submit">Search</button>
</form>
<table id="user-table">...</table>

<!-- Auto-refresh every 30 seconds -->
<div hx-get="/notifications/count" hx-trigger="every 30s" hx-swap="innerHTML">
  3 new
</div>
Attribute Purpose
hx-get, hx-post, hx-put, hx-delete HTTP method and URL
hx-target CSS selector for swap target
hx-swap How to insert response (innerHTML, outerHTML, beforeend, delete)
hx-trigger Event that fires request (click, submit, every 2s, revealed)
hx-indicator Loading spinner element

Server-side partial rendering

Detect htmx requests and return fragments:

# Django view
def user_list(request):
    users = User.objects.filter(name__icontains=request.GET.get('q', ''))
    template = 'users/_table.html' if request.headers.get('HX-Request') else 'users/list.html'
    return render(request, template, {'users': users})
<!-- templates/users/_table.html -->
<table id="user-table">
  {% for user in users %}
  <tr>
    <td>{{ user.name }}</td>
    <td>{{ user.email }}</td>
  </tr>
  {% endfor %}
</table>

Return only the fragment when HX-Request: true is in the request header.

Out-of-band swaps

Update multiple page regions from one response:

<!-- Response includes -->
<div id="cart-count" hx-swap-oob="true">5 items</div>
<div id="cart-items" hx-swap-oob="true">
  <!-- updated cart list -->
</div>

The primary swap target gets the main response body. Elements with hx-swap-oob="true" update their matching IDs elsewhere on the page.

Loading and error states

<button
  hx-delete="/items/42"
  hx-target="#item-42"
  hx-swap="outerHTML swap:1s"
  hx-confirm="Delete this item?"
  hx-indicator="#spinner"
>
  Delete
</button>
<span id="spinner" class="htmx-indicator">Deleting...</span>

CSS hides indicators by default:

.htmx-indicator { display: none; }
.htmx-request .htmx-indicator { display: inline; }
.htmx-request.htmx-indicator { display: inline; }

Handle errors with the htmx:responseError event:

document.body.addEventListener('htmx:responseError', (e) => {
  const target = e.detail.target;
  target.innerHTML = '<p class="error">Something went wrong. Try again.</p>';
});

When to use htmx vs. SPA

Use htmx Use SPA framework
CRUD admin panels Real-time collaborative editing
Search/filter tables Complex client-side state
Multi-step forms Offline-first apps
Dashboards with periodic refresh Heavy animation and transitions
Internal tools Public consumer apps with SEO needs

htmx excels where server rendering already exists and interactivity needs are moderate.

Progressive enhancement

Without JavaScript, forms submit normally and links navigate to full pages. htmx enhances progressively:

<form action="/search" method="get" hx-get="/search" hx-target="#results">
  <input name="q" />
  <button type="submit">Search</button>
</form>

The action and method attributes provide the fallback behavior.

hx-boost for full-page navigation

Upgrade regular links to AJAX navigation without JavaScript routers:

<body hx-boost="true">
  <a href="/about">About</a> <!-- navigates without full reload -->
</body>

The server returns a full HTML document; htmx swaps the body content. Progressive enhancement for multi-page apps.

hx-swap-oob for toasts

Return toast notifications alongside main content:

<div id="toast" hx-swap-oob="true" class="success">Saved!</div>

Update notification areas without targeting them explicitly in hx-target.

Caching partials

Fragment caches keyed by (route, user_segment, page) reduce server render cost:

@cache.memoize(timeout=60)
def render_contact_table(page, tenant_id):
    return render_template('_contact_rows.html', ...)

Invalidate on mutation via HX-Trigger calling cache bust endpoint or versioned ETags in HX-Headers.

WebSocket + htmx extension

htmx websockets extension swaps messages into DOM—useful for live dashboards without full SPA. Still sanitize HTML fragments from server; XSS in partial templates is critical severity.

Comparison with Hotwire/Turbo

Rails Turbo Drive similar to htmx philosophy—teams on Rails often use Turbo; htmx fits Python, Go, PHP, Java templates equally. Choose based on server stack, not religious preference.

Practical follow-through (1)

Ship the smallest vertical slice first — one route, one widget, one index configuration — with rollback documented before expanding scope. Baseline the user-visible metric this work protects (latency, recall, conversion, task success rate) for seven days before change and seven days after in your largest market.

Compare canary p75 to control before full rollout. Exercise edge paths manually: refresh, back navigation, double-submit, offline mode, and keyboard-only flows. When assumptions change — traffic doubles, vendor upgrades, org restructure — revisit whether the original design still fits; quiet periods hide drift until the next incident.

Practical follow-through (2)

Ship the smallest vertical slice first — one route, one widget, one index configuration — with rollback documented before expanding scope. Baseline the user-visible metric this work protects (latency, recall, conversion, task success rate) for seven days before change and seven days after in your largest market.

Compare canary p75 to control before full rollout. Exercise edge paths manually: refresh, back navigation, double-submit, offline mode, and keyboard-only flows. When assumptions change — traffic doubles, vendor upgrades, org restructure — revisit whether the original design still fits; quiet periods hide drift until the next incident.

Practical follow-through (3)

Ship the smallest vertical slice first — one route, one widget, one index configuration — with rollback documented before expanding scope. Baseline the user-visible metric this work protects (latency, recall, conversion, task success rate) for seven days before change and seven days after in your largest market.

Compare canary p75 to control before full rollout. Exercise edge paths manually: refresh, back navigation, double-submit, offline mode, and keyboard-only flows. When assumptions change — traffic doubles, vendor upgrades, org restructure — revisit whether the original design still fits; quiet periods hide drift until the next incident.

Practical follow-through (4)

Ship the smallest vertical slice first — one route, one widget, one index configuration — with rollback documented before expanding scope. Baseline the user-visible metric this work protects (latency, recall, conversion, task success rate) for seven days before change and seven days after in your largest market.

Compare canary p75 to control before full rollout. Exercise edge paths manually: refresh, back navigation, double-submit, offline mode, and keyboard-only flows. When assumptions change — traffic doubles, vendor upgrades, org restructure — revisit whether the original design still fits; quiet periods hide drift until the next incident.

Practical follow-through (5)

Ship the smallest vertical slice first — one route, one widget, one index configuration — with rollback documented before expanding scope. Baseline the user-visible metric this work protects (latency, recall, conversion, task success rate) for seven days before change and seven days after in your largest market.

Compare canary p75 to control before full rollout. Exercise edge paths manually: refresh, back navigation, double-submit, offline mode, and keyboard-only flows. When assumptions change — traffic doubles, vendor upgrades, org restructure — revisit whether the original design still fits; quiet periods hide drift until the next incident.

Resources

CSRF in partials

Include CSRF tokens in every htmx form partial — cookies send automatically on same-origin POST.

Partial template tests

Pytest-render _table.html fragments with fixture data — no browser needed for row logic. One Playwright spec per critical swap path covers integration.

hx-boost navigation

hx-boost="true" on body upgrades link navigation without full reload — server returns full HTML documents; htmx swaps body content for MPA-style speed.

Frequently asked questions

What is the main production risk with web htmx server driven ui?

Teams ship without field measurement—web htmx server driven ui failures appear as silent UX regressions, cost drift, or audit findings rather than clear errors.

When should we prioritize web htmx server driven ui?

Prioritize when user research, CrUX, support tickets, or compliance requirements show pain on critical paths—not when a checklist mentions it abstractly.

How do we validate web htmx server driven ui changes?

Baseline RUM before changes, compare p75 after deploy, and keep rollback via feature flags or cache purge documented in the PR.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →