Engineering · Part 9 · rich UI, no build step
htmx without the SPA tax
Reference documentation for building a fast, live-feeling web UI from server-rendered HTML and htmx — no client framework, no build step, no JSON API for the UI to consume — and letting the effects stream from Part 1 drive live updates.
A single-page-application buys you smooth partial updates and pays for them with a build pipeline, a client-side router, a duplicated data layer (a JSON API purely to feed the front end), and a whole second place for bugs to live. For most CRUD-shaped products that is a bad trade. This document describes the alternative: render HTML on the server, swap fragments with htmx, and reach for JavaScript only where the interaction genuinely demands it. The result feels live and costs a fraction of the machinery.
Two swap strategies, one per shell
htmx has two modes, and the mistake is using one everywhere. Choose per shell — the outer template a family of pages extends — according to whether the page is a document or an application.
flowchart TB
subgraph Boosted ["Marketing / auth shell — hx-boost"]
MB["every link/form is an ajax navigation
swap the whole body, push URL"]
end
subgraph Targeted ["App shell — opt-in targeted htmx"]
TB["a link opts in with hx-get
swap only #main, OOB-update the sidebar"]
end
- Marketing and auth pages use
hx-boost. One attribute on the body turns every ordinary link and form into a background request that swaps the whole<body>and updates the URL — you get instant, no-flash navigation across a mostly-static site for free, and it degrades perfectly to full page loads if JavaScript is off. These pages are documents; boosting the whole body is exactly right. - The application shell does not boost. It uses targeted, opt-in
htmx: the shell (sidebar, top bar) is persistent, and a nav link opts in by carrying its own
hx-getthat swaps only the main content region. An application has a stable frame and a changing middle; re-rendering the frame on every click is waste and it tears down anything live inside it.
The split has one sharp edge worth naming: a link in a boosted shell that points at a
page in a different shell. Boosting swaps the <body> but never the
<head> — so a boosted link from the marketing shell to an app page lands
the new body under the marketing head, missing that shell's own stylesheet, inline
<style> and scripts. The page arrives unstyled and half-wired, and (because htmx
couldn't reconcile it) the URL may not even update. The fix is one attribute: any link that
crosses shells — the account menu's Projects/Account/Billing items, say — carries
hx-boost="false" so it does an honest full load and gets the right head. Put it on the
menu container and every item inherits it. The rule of thumb: boost stays within a shell; leaving
one is a full navigation.
The targeted swap config lives once, on the container, and stays inert until a link opts in:
<!-- the app shell: the sidebar carries the swap config; links opt in with hx-get -->
<aside id="sidebar"
hx-target="#main" hx-select="#main" hx-swap="outerHTML"
hx-push-url="true" hx-select-oob="#sidebar">
<a href="/blogs/7/posts/" hx-get="/blogs/7/posts/">Posts</a> <!-- opts in -->
<a href="/blogs/7/tags/" hx-get="/blogs/7/tags/">Tags</a>
</aside>
<main id="main">…</main>
Read the attributes: a click fetches the target page, but hx-select="#main" pulls
only that page's #main out of the full response and swaps it in, while
hx-select-oob="#sidebar" also swaps the sidebar (so the active-link highlight updates).
The crucial property: every view still returns a complete page. The same URL,
opened directly or hit by a search engine, renders in full; htmx just extracts the fragment it wants
from that full response. You are not maintaining partial endpoints — you are selecting from whole
ones. Load the URL directly and it works; that is your progressive-enhancement guarantee, for free.
More than one live region: let each trigger pick its own
A real application screen usually has more than one independently-updating region. Picture a post
editor: a tabbed main area (#main) for the draft, its metadata and its revisions — and
beside it a persistent comments panel (#comments) you can post into while you
edit. Both update live; neither may clobber the other. Switching a tab must not wipe the comment
you're half-way through typing; posting a comment must not reset the draft tab.
The same whole-page-plus-hx-select rule handles this with no new mechanism, because
which region to swap is decided on the trigger, not in the response. Every
view still returns the whole page; each control selects the one region it owns:
<!-- the tabbed main region declares the swap once; its links and forms inherit it -->
<div id="main" hx-target="#main" hx-select="#main" hx-swap="outerHTML">
<a href="/posts/7/revisions" hx-get="/posts/7/revisions">Revisions</a> <!-- swaps #main -->
<form method="post" action="/posts/7/title" hx-post="/posts/7/title">…</form> <!-- swaps #main -->
</div>
<!-- the persistent panel is a SIBLING; its form names #comments, so #main is left untouched -->
<aside id="comments">
<form method="post" action="/posts/7/comments" hx-post="/posts/7/comments"
hx-target="#comments" hx-select="#comments" hx-swap="outerHTML">…</form>
</aside>
Two things earn their keep here. First, inheritance: the swap config sits once on
#main, so every link and form inside it only writes its hx-get/hx-post
— the target, the select and the swap are inherited. It is pure de-duplication; spell it out per form
if you prefer. Second, and the real point: because each control names the region it selects, a tab
click extracts only #main and the comments panel keeps its state, while a comment post
extracts only #comments and the draft is left alone. This is why region routing
belongs on the trigger, not in the response. An out-of-band swap (hx-swap-oob,
next section) marks what the response pushes — so if you routed regions that way, every
interaction would push the same ones, and you'd have to shape the response per endpoint to vary it,
dragging per-endpoint logic back into the server you just simplified. Select on the trigger and the
server stays one uniform render; reach for hx-swap-oob only for the genuinely
cross-cutting extra — the flash banner below — never for a screen's own regions.
Flash messages survive a partial swap
A partial swap replaces one fragment and leaves the rest of the page — including the
#messages banner — untouched. So a message added during an htmx request ("Post
published") would sit unread until the next full load. Fix it globally with an out-of-band swap
appended by middleware, so no template needs to opt in:
class HtmxMessagesMiddleware:
def __call__(self, request):
response = self.get_response(request)
if request.headers.get('HX-Request') != 'true':
return response # a normal load renders #messages already
if request.headers.get('HX-Boosted') == 'true':
return response # a boosted swap re-renders the whole shell
if 300 <= response.status_code < 400 or response.has_header('HX-Redirect'):
return response # a redirect becomes a full navigation
# a true partial swap: append an OOB render of the pending messages
response.write(render_to_string('_messages_oob.html', {'messages': get_messages(request)}))
return response
# _messages_oob.html:
<div id="messages" hx-swap-oob="true">{% for m in messages %}…{% endfor %}</div>
hx-swap-oob="true" tells htmx to take that element out of the response body and swap
it into the matching #messages element on the page, in addition to the
targeted swap — so a fragment update and its flash message arrive together. Two subtleties earn
their comments: this must run after the framework's message middleware so the rendered
messages are marked used and not re-shown on the next page; and it must skip boosted swaps and
redirects, where the shell renders messages normally and an OOB append would double them. Handle
those cases and every htmx-reachable view — including the framework's own — gets working flash
messages with zero per-view code.
Live updates without polling: the effects stream
The last piece connects to Part 1. When a change happens on a channel the page is already watching — most naturally, an AI assistant acting mid-conversation over a server-sent-event stream — the mutators' effects (Part 1) tell the page precisely what changed, and a few lines of JavaScript refresh only the affected fragment:
source.addEventListener('effects', (e) => {
const effects = JSON.parse(e.data);
if (effects.some(x => ['post', 'tag'].includes(x.type))) {
htmx.trigger('#post-list', 'refresh'); // an hx-get on #post-list re-fetches that fragment
}
});
<!-- the fragment declares how to refresh itself -->
<section id="post-list" hx-get="/blogs/7/posts/list" hx-trigger="refresh">…</section>
Notice there is no new endpoint and no client-side rendering: the effect names a type, the client fires an htmx trigger, and the fragment re-fetches its own server-rendered HTML. The server remains the only thing that turns data into markup. This is deliberately the minimal rung — one event, one fragment — but it scales the same way: richer live updates are more effect types mapped to more fragment triggers, never a client-side data model. The write path already describes its consequences (Part 1); the UI just listens.
When to actually write JavaScript
The point is not "no JavaScript" — it is "JavaScript only where the interaction needs it, and kept small and local". A dropdown's keyboard handling, a canvas, a drag-to-reorder: write a small script, scoped to that component, with no framework and no build. Everything that is really "navigate, submit a form, refresh a list" — the overwhelming majority of a CRUD app — is htmx attributes on server-rendered HTML. The test to apply: if the behaviour is fundamentally request-render-swap, it is htmx; if it is fundamentally client-side interaction (input the server isn't involved in until submit), it is a small script. Most of your app is the former, which is why the SPA machinery was never paying its way.
Where htmx runs out of road
Be honest about the ceiling, because pretending htmx does everything is how teams end up hating it. htmx's model is a user action triggers a server round-trip that swaps in server-rendered HTML. That is a poor fit whenever the interesting state changes faster than a round-trip and lives in many places at once — a view where dragging one element live-updates several others, an editor with rich linked state, anything where derived values must recompute on every keystroke or drag without a network hop.
You feel the strain before you hit a wall. The most involved screen in the codebase this series draws from is a value-proposition canvas: sticky notes for a customer's jobs, pains and gains on one side; products and services on the other; and fit links drawn between them, with coverage state recomputing as you connect things. Most of it is plain htmx — adding a note, deleting a card, switching tabs are each a small targeted swap — but two interactions crossed the line: drawing a fit link (an optimistic set-change that should feel instant, not a round-trip) and dragging an importance/evidence dot (derived position recomputing live). Those two, and only those two, now run as scoped reactive islands (next section); everything else on the very same screen stays a targeted htmx swap. That mix — htmx by default, an island exactly where the interaction is dense — is the honest shape of a real application screen, and the rest of this section is how the pieces coexist without fighting.
When you reach that point, the answer is not "rewrite the app as a single-page application". It is to drop a small, local reactive layer into that one component — a lightweight signals library (Preact with signals, or similar) binding a bit of client state to the DOM — while every other page stays server-rendered htmx. You pay the client-side-state cost only where the interaction genuinely demands it, on one screen, not across the whole app. The progression is the same test as before, extended: request-render-swap → htmx; occasional client interaction → a small vanilla script; dense, interdependent, real-time client state → a scoped reactive island (Part 18 builds exactly that — Preact signals bound to server-rendered DOM, no build step). Reach for each rung only when the one below it visibly strains, and you keep the SPA tax off the 95% of the app that never needed it.
Keep even the island progressive. Build it over a real server-rendered form — the island
intercepts the change and updates optimistically, but the form still has its method,
action and a submit control, so with JavaScript off it posts and the whole page comes
back. Hide the fallback control when scripting is available (a one-line <script>
adds a js class to the root; CSS hides .fallback-only under it), so JS users
never see it and no-JS users still can act. The island is then an enhancement, not a requirement —
the same bargain as the rest of the page.
What you did not build
Tally the absent machinery, because the absence is the benefit: no bundler or transpile step, no client-side router, no component framework, no state-management library, and — critically — no JSON API built solely to feed the UI. Your JSON API (Part 1) exists for machine callers on its own merits; the human UI is served HTML directly and never round-trips through it. One rendering path (server templates), one place data becomes markup, progressive enhancement by default, and a "view source" that shows the actual page. For the shape of app this suits — anything CRUD-and-forms with occasional rich components — it is less code, fewer bugs, and a UI that already feels live.
The shape to copy
- Choose the swap strategy per shell:
hx-boostfor document-like marketing/auth pages, targeted opt-inhx-getfor the stable-frame app shell. - Every view returns a complete page; htmx
hx-selects the fragment it needs — no partial-only endpoints, direct-load always works. - For a screen with several live regions (a swappable main area plus a persistent panel), let each trigger select the region it owns, so they never clobber each other; route regions on the trigger, not with response-side OOB.
- Keep the reactive islands progressive too: build each over a real form (
method/action+ a fallback submit hidden when JS is on), so the screen still works with scripting off. - Deliver flash messages on partial swaps with a global OOB middleware; skip boosted swaps and redirects.
- Drive live updates from the effects stream: an effect type → an htmx trigger → a fragment re-fetch; no new endpoint, no client rendering.
- Reach for small, local, build-free JavaScript only for genuinely client-side interactions.
Part 10 closes the series with the media pipeline behind those fragments: rendering uploads eagerly into viewable derivatives, through one pluggable backend that runs in-process for tests and serverless in production.