HTMX 4.0 Server Rendering Patterns: Architecture Choices for Building Interactive Web Apps Without Client State
When I first learned React, I honestly buried this question in the back of my mind: "Does clicking a single button really require this much code?" State management, build tools, hydration errors... The web clearly had a time when it ran on HTML and forms, and at some point it felt like that simplicity had been lost. If you're a backend developer with no React experience, don't worry. If you're familiar with "rendering full pages from the server," HTMX may actually feel more natural.
HTMX answers that question head-on. With a single 14KB library, you can implement AJAX requests, infinite scroll, and inline editing using just a few HTML attributes like hx-get and hx-post — no npm, no build tools, no bundler. The current stable version is 2.x, and 4.0, whose alpha has been available since the second half of 2025, is preparing a major update that switches internally from XMLHttpRequest to the modern fetch() API and adds DOM Morphing and View Transitions API support. This article uses 2.x syntax, which you can use right now.
This article examines HTMX's core interaction patterns with real code and lays out criteria for deciding whether it's worth seriously considering for your project. Rather than a step-by-step installation guide, the focus is on helping developers who already have server-side experience decide "can I use this in my project?" The examples are primarily Django-based, but the same patterns apply with render partial: in Rails or templ components in Go.
Core Concepts
Why HTMX Works Differently — The HDA Pattern
HTMX is not a simple AJAX helper. Its philosophy is rooted in the HDA (Hypermedia-Driven Application) pattern. It's a concept that actually implements REST's HATEOAS principle — the term sounds complex, but in practice it's very simple.
HATEOAS (Hypermedia as the Engine of Application State): An approach where the server communicates "what you can do next" through HTML links and forms. The client simply displays the HTML the server provides, as-is.
In SPAs, the server sends down JSON, and the client receives that data, manages state directly, and renders the UI. In traditional server rendering, the server sends down a complete HTML page, but the entire page refreshes each time. HTMX sits somewhere in between. The server returns a completed HTML fragment, and the browser simply inserts that HTML into a designated position in the DOM. There's no full page reload, and there's no JavaScript state on the client.
Core Attributes at a Glance
Every interaction in HTMX is expressed as a combination of hx-* attributes. I was confused at first about what each attribute does, but breaking them into three roles makes it much clearer.
| Role | Attribute | Description |
|---|---|---|
| Request | hx-get / hx-post / hx-delete |
Which HTTP request to send and where |
| Placement | hx-target / hx-swap |
Where and how to insert the response HTML |
| Trigger | hx-trigger |
When to send the request |
| URL Management | hx-push-url |
Update the browser history URL |
| Progressive Enhancement | hx-boost |
Automatically upgrades existing <a> and <form> elements to HTMX behavior. Convenient for bulk-converting existing links to replace only the content area instead of navigating the full page |
Looking at the main options for hx-swap, you can fine-tune the DOM update position with modes like innerHTML (default: replace the inner content of the target element), outerHTML (replace the target element itself), and beforeend (append to the end of the target element).
Patterns covered in this article:
- Example 1: Live Search
- Example 2: Infinite Scroll
- Example 3: Inline Editing
- Example 4: Combining with Alpine.js
Practical Application
Example 1: Live Search — Update Results as You Type
This is a situation you frequently encounter in real work. With React, you'd need a combination of useState + useEffect + a debounce hook. In HTMX, it's handled with a single HTML attribute.
<input
hx-get="/search"
hx-trigger="keyup changed delay:300ms"
hx-target="#results"
name="q"
placeholder="Enter search term..."
/>
<div id="results"></div>| Attribute | Role |
|---|---|
hx-get="/search" |
Sends a GET request to /search?q=... on each keystroke |
hx-trigger="keyup changed delay:300ms" |
Only when the value actually changes, with 300ms debounce applied |
hx-target="#results" |
Replaces the content inside #results with the response HTML |
The server (using Django as an example) returns only an HTML fragment — not JSON.
# Django view
def search(request):
q = request.GET.get("q", "")
items = Item.objects.filter(name__icontains=q)
return render(request, "partials/search_results.html", {"items": items})<!-- partials/search_results.html -->
{% for item in items %}
<li>{{ item.name }}</li>
{% endfor %}In Rails it would be render partial: "search_results", locals: { items: @items }, and in Go it would be a templ component — the same structure applies.
Key point: There are no
useEffectcalls, no debounce utilities, and no state management libraries whatsoever. The server returns HTML, and the browser places it.
There's one thing I want to highlight. When the server returns an HTML fragment, you need to watch out for XSS. Django's {{ item.name }} is auto-escaped and therefore safe, but overusing mark_safe() or the |safe filter is dangerous. Rails' <%= %> and Go's html/template also have auto-escaping on by default. Regardless of the framework you use, keeping auto-escaping on by default is important — I'm deliberately emphasizing this because I've occasionally seen developers new to HTMX gloss over this point.
Example 2: Infinite Scroll — Detecting Viewport Entry
This pattern loads the next page by scrolling alone, without pagination buttons. If you've ever worked directly with the Intersection Observer API, you'll feel just how much code this eliminates.
When the first page renders, the <ul> contains the first batch of items along with a loading trigger div. Subsequent page fragments return <li> items along with the next loading div together.
<!-- Initial page: first page items + loading div inside <ul> -->
<ul id="post-list">
<li>Post 1</li>
<li>Post 2</li>
<!-- ... 10 items -->
<div
hx-get="/posts?page=2"
hx-trigger="revealed"
hx-swap="outerHTML"
hx-target="this">
<span>Loading...</span>
</div>
</ul>| Attribute | Role |
|---|---|
hx-trigger="revealed" |
Fires the request the moment this element enters the viewport |
hx-swap="outerHTML" |
Replaces the loading div itself entirely with the response HTML |
The server returns the next page's <li> items along with another loading div (page=3). It's a structure that keeps working recursively — when I first saw this pattern, my immediate reaction was "how can this be this clean?"
<!-- Server response: page=2 fragment -->
<li>Post 11</li>
<li>Post 12</li>
<!-- ... -->
<!-- New loading div for the next page -->
<div
hx-get="/posts?page=3"
hx-trigger="revealed"
hx-swap="outerHTML"
hx-target="this">
<span>Loading...</span>
</div>Key point: This is a recursive structure where the loading div replaces itself with the next page's fragment. On the last page, the server simply returns
<li>items without a new loading div, and the infinite scroll naturally terminates.
Example 3: Inline Editing — Click for a Form, Save for Text Again
This was the most impressive pattern when I first saw HTMX. Inline editing is implemented without any client state. My reaction was exactly: "Wait, this actually works?"
<!-- Read mode -->
<span
hx-get="/items/1/edit"
hx-trigger="dblclick"
hx-target="this"
hx-swap="outerHTML">
Text to edit
</span>On double-click, the server returns <form> HTML, and that form replaces the <span>.
<!-- Edit form returned by server -->
<form hx-put="/items/1" hx-target="this" hx-swap="outerHTML">
<input type="text" name="text" value="Text to edit">
<button type="submit">Save</button>
<button hx-get="/items/1" hx-target="this" hx-swap="outerHTML">Cancel</button>
</form>On save, the server returns the updated <span>, switching back to read mode. The cancel button also requests the original <span> to remove the form. There is absolutely no state like "currently editing item" on the client.
Key point: The entire read mode → edit mode → save/cancel → read mode transition is driven by HTML fragments returned by the server. The structure has zero client-side state variables.
Example 4: Combining with Alpine.js — Supplementing Client Interactions
HTMX alone feels awkward for pure client interactions that should be handled without a server round trip — opening/closing dropdowns, accordions, tab switching, etc. The pattern of combining Alpine.js (~15KB) is the most commonly used in real work.
<!-- Alpine.js for dropdown toggle, HTMX for data loading -->
<div x-data="{ open: false }">
<button @click="open = !open">Open Filter</button>
<div x-show="open">
<select
hx-get="/items"
hx-trigger="change"
hx-target="#item-list"
hx-include="[name='search']"
name="category">
<option value="">All</option>
<option value="frontend">Frontend</option>
<option value="backend">Backend</option>
</select>
</div>
</div>
<input type="text" name="search" placeholder="Search term..." />
<div id="item-list">
<!-- List fetched from server by HTMX -->
</div>The hx-include="[name='search']" part is important. If the <select> is outside a form tag and you want to send along another filter (here, the search input), you must explicitly include it with hx-include. I remember spending a long time puzzling over "why isn't the other filter value being sent?" after leaving this out in real work.
Role separation principle: Alpine.js handles "UI interactions that can be processed without a server request"; HTMX handles "everything that requires server data." Keeping this distinction clear is enough to make the code straightforward.
Key point: When multiple filters are scattered outside a form tag, you can either explicitly specify the selector to include with
hx-include, or usehx-valsto pass dynamic values.
Pros and Cons Analysis
Pros
| Item | Details |
|---|---|
| Minimal bundle size | 14KB (gzip). React alone starts at around 45KB (gzip), and adding React Router + state management libraries pushes it over 100KB — the difference is noticeable on mobile |
| No hydration | Since completed HTML comes down from the server, there is no client-side hydration step. The page becomes interactive as soon as the HTML is parsed |
| Minimal barrier to entry | Install with a single <script src> line. No npm, build tools, or bundler required |
| Single backend | You can use existing MVC server code as-is, without a separate REST/GraphQL API layer |
| SEO-friendly | Content is fully rendered on the server, so crawlers can index it without executing JS |
What is hydration: The process where client JS "attaches" to server-side rendered HTML and registers event listeners. This is exactly the step where "hydration mismatch" errors frequently occur in React SSR. Because HTMX has the server send down completed HTML that the client uses directly, this step doesn't exist at all.
Cons and Caveats
| Item | Details | Real-world situation |
|---|---|---|
| JSON API incompatibility | HTMX only handles HTML responses. You cannot use an existing JSON API server as-is | You need to add a separate HTML rendering layer to the server. This is a more natural fit for new projects than converting existing ones |
| Limits with complex client state | React still has the advantage for offline mode, real-time collaboration, rich editors, and advanced drag-and-drop | The range that can be supplemented with Alpine.js is wider than you'd think, but it's better to honestly acknowledge that HTMX is not the right fit for these areas |
| Lack of component ecosystem | The reusable UI component library ecosystem is sparse compared to React/Vue | The common approach is to compose directly with Tailwind CSS utility classes |
| Lack of type safety | hx-* attributes are string-based, so IDE autocomplete and type checking are limited |
Partially compensated with the VSCode HTMX extension or backend template type tools |
| Network dependency | Every interaction requires a server round trip | It's good to use hx-indicator to show loading states and pair this with a pattern that subscribes to the htmx:responseError event to handle response failures |
If you skip error handling, a server 500 response or network failure will leave the screen silently doing nothing. Subscribing to the response event with hx-on::after-request as shown below is much more reliable in practice.
<input
hx-get="/search"
hx-trigger="keyup changed delay:300ms"
hx-target="#results"
hx-on::after-request="if(event.detail.failed) document.getElementById('error-msg').classList.remove('hidden')"
name="q"
/>
<p id="error-msg" class="hidden text-red-500">A problem occurred during the request.</p>The Most Common Mistakes in Real Work
-
Trying to attach HTMX directly to a JSON API server: The core of HTMX is that the server returns HTML. If you attach it to an existing REST API server directly, the server will return JSON and nothing will work. You need to build separate HTML-rendering endpoints.
-
Implementing search without debounce on
hx-trigger: Writinghx-get="/search" hx-trigger="keyup"fires a request on every keypress. Using thedelay:300msandchangedmodifiers together ensures a request fires only once, 300ms after the value actually changes. -
Returning the full page layout as a fragment: You should return only the HTML fragment that matches the area specified by
hx-target. If the server view renders and returns the full page template, the layout will be nested. Using utilities that detect HTMX requests and render only the partial template — likedjango-htmxor Rails'request.xhr?condition — helps you avoid this mistake.
Closing Thoughts
HTMX is not a technology for "eliminating JavaScript" — it is an architectural choice that brings the value of server rendering back to the center of the web. For apps that "display and modify data" — admin panels, CMSes, internal tools, CRUD dashboards — real-world cases reporting 30–50% reductions in code volume compared to React have been documented. On the other hand, if complex client interactions like offline support, real-time collaboration, or drag-and-drop are core to your app, React is still the right tool.
Three steps you can start with right now:
-
Add one CDN line — Put
<script src="https://unpkg.com/htmx.org@2" defer></script>in your<head>, then attachhx-getandhx-triggerto the simplest search feature in your existing project. You don't need to touch your build environment at all. -
Add one HTML fragment endpoint — Create a view at
/searchthat returns only a<li>list, then attach a pattern that detects HTMX requests withdjango-htmxor Rails'request.xhr?condition to render the partial template. -
Agree on the boundary with Alpine.js — Alpine.js for pure UI state that requires no server requests (dropdowns, opening/closing modals); HTMX for everything that requires server data. Establishing this distinction within the team in advance prevents code from getting mixed up later.
References
- htmx.org Official Documentation
- Hypermedia-Driven Applications — htmx Official Essay
- When Should You Use Hypermedia? — htmx Official Essay
- htmx 4.0 — The Fetchening Official Essay
- HTMX 4.0: Hypermedia finds a new gear | InfoWorld
- htmx in 2026: When You Don't Need React | DEV Community
- HTMX and Alpine.js Combination Guide | InfoWorld
- Another Real World React → HTMX Port | htmx Official
- Pros and Cons for a HTMX Beginner | Labcodes
- SE Radio 671: Carson Gross on HTMX | Software Engineering Radio