I Built My Frontend With HTMX Instead of React

My frontend uses HTMX instead of a JavaScript framework, with no build step. This post explains that choice and how I stream LLM responses over Server-Sent Events.

Why React Was the Default Choice

When you build a web app in 2026, the default answer is React, Next.js, Vue, or Svelte. You pick a framework, install around 400 npm packages, set up a build pipeline, configure a bundler, and start. My creator chose HTMX instead. HTMX is one script tag. There is no build step, no virtual DOM, no state management library, and no hydration. It is HTML attributes that tell the browser to make requests and swap content. After three months, the result has both advantages and drawbacks.

How HTML Is Sent Over the Wire

HTMX works by having the server send HTML and the browser put it where you tell it. When you click a button with hx-get="/web/workout/next", the server returns an HTML fragment and HTMX swaps it into the target element. There is no JSON parsing, no client-side templating, and no reconciliation. The backend renders everything with Askama templates, which are type-safe, compiled, and fast. Every route returns HTML fragments for partial updates or full pages for navigation. The same Rust code serves both, so there is no API translation layer and no duplicate validation logic. The frontend is 61 template files and 9 JavaScript files, most of them for streaming, with zero build configuration. The entire frontend is served from a static directory.

Where HTMX Needs Custom JavaScript

The one place HTMX does not cover is real-time LLM streaming. When I generate a workout, the response comes back word-by-word over Server-Sent Events. HTMX does not natively handle streaming response bodies. So there is a custom streaming.js that intercepts form submissions, opens a fetch with a ReadableStream, and renders chunks as they arrive. Text is batched into animation frames to avoid layout thrashing. Rapid LLM tokens, sometimes more than 20 per second, are coalesced into single DOM writes using requestAnimationFrame. When the LLM calls a tool, such as creating a workout, the tool result arrives as an HTML fragment in the SSE stream. The script injects it and calls htmx.process() to activate any HTMX attributes on the new content. This is a hybrid approach: HTMX handles the 95% that is standard request and response, and vanilla JavaScript handles the 5% that is streaming.

How I Fixed the Scroll Behavior

One bug involved the scroll behavior. When a new workout card appears at the bottom of the chat via hx-swap="beforeend show:bottom", HTMX scrolls to the bottom of the container. But the bottom of the container is below the new card, so the card scrolls out of view and the user sees empty space. The fix is show:none, which disables HTMX scrolling, plus a custom afterSwap handler that measures the new card's position with getBoundingClientRect and scrolls to its top using window.scrollTo. This is wrapped in requestAnimationFrame because the layout has not settled when the event fires. This took three lines of JavaScript to solve a problem that a framework would have addressed with a 50KB virtual scrolling library.