Privacy Policy© 2026 DEV BAK - TECH BLOG. All rights reserved.
DEV BAK - TECH BLOG
frontend

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.

html
<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.

python
# 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})
html
<!-- 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 useEffect calls, 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.

html
<!-- 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?"

html
<!-- 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?"

html
<!-- 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>.

html
<!-- 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.

html
<!-- 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 use hx-vals to 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.

html
<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

  1. 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.

  2. Implementing search without debounce on hx-trigger: Writing hx-get="/search" hx-trigger="keyup" fires a request on every keypress. Using the delay:300ms and changed modifiers together ensures a request fires only once, 300ms after the value actually changes.

  3. 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 — like django-htmx or 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:

  1. Add one CDN line — Put <script src="https://unpkg.com/htmx.org@2" defer></script> in your <head>, then attach hx-get and hx-trigger to the simplest search feature in your existing project. You don't need to touch your build environment at all.

  2. Add one HTML fragment endpoint — Create a view at /search that returns only a <li> list, then attach a pattern that detects HTMX requests with django-htmx or Rails' request.xhr? condition to render the partial template.

  3. 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
#HTMX#서버사이드렌더링#HDA#HATEOAS#Alpine.js#Django#REST-API#DOM-Morphing#무한스크롤#인라인편집
Share

Table of Contents

Core ConceptsWhy HTMX Works Differently — The HDA PatternCore Attributes at a GlancePractical ApplicationExample 1: Live Search — Update Results as You TypeExample 2: Infinite Scroll — Detecting Viewport EntryExample 3: Inline Editing — Click for a Form, Save for Text AgainExample 4: Combining with Alpine.js — Supplementing Client InteractionsPros and Cons AnalysisProsCons and CaveatsThe Most Common Mistakes in Real WorkClosing ThoughtsReferences

Recommended Posts

View Transitions API — Production Page Transition Animations Without Libraries, After Achieving Baseline 2025
frontend

View Transitions API — Production Page Transition Animations Without Libraries, After Achieving Baseline 2025

Honestly, my first reaction when I saw this API was "this actually works?" The idea of implementing app-like page transitions with just a few lines of CSS and a...

June 7, 202620 min read
We Migrated from Webpack to Rsbuild and Production Builds Got 74% Faster — The Migration Reality and Rspack Pitfalls
frontend

We Migrated from Webpack to Rsbuild and Production Builds Got 74% Faster — The Migration Reality and Rspack Pitfalls

Honestly, my first reaction was "another new build tool?" — same as when Vite came out, same as when Turbopack was announced. But after seeing the case from Ala...

June 23, 202619 min read
Fixing Micro Frontend Style Conflicts with CSS @layer | Cascade Layers Isolation Strategy
frontend

Fixing Micro Frontend Style Conflicts with CSS @layer | Cascade Layers Isolation Strategy

When I first worked on a Micro Frontend (MFE) project, I remember being quite surprised to see a button style deployed by Team A showing up altered on Team B's ...

June 23, 202622 min read
How to Reduce TTFB from 350ms to 60ms with Next.js RSC + Streaming
frontend

How to Reduce TTFB from 350ms to 60ms with Next.js RSC + Streaming

While reviewing the Core Web Vitals of a production service, I once discovered pages where TTFB was well over 400ms. Each DB query was individually fast, so I w...

June 7, 202619 min read
Speed Up `next build` 10× with Next.js Turbopack Build Cache — Pitfalls of Experimental Flags and CI Integration Strategies
frontend

Speed Up `next build` 10× with Next.js Turbopack Build Cache — Pitfalls of Experimental Flags and CI Integration Strategies

When in CI starts exceeding three minutes, the stress quietly accumulates. The time you spend waiting in front of the deployment pipeline every time you open a...

May 30, 202616 min read
Zustand persist migrate: 4 Ways to Safely Narrow `persistedState unknown` Type with TypeScript
frontend

Zustand persist migrate: 4 Ways to Safely Narrow `persistedState unknown` Type with TypeScript

When persisting state to localStorage with Zustand, there inevitably comes a moment when you need to change the schema. Renaming fields, adding new ones, flatte...

May 30, 202619 min read