Skip to main content
Back to Journal
Web DevelopmentJavaScript

htmx: The Library That Made Me Rethink SPAs

I've been building SPAs with React for about seven years now. It's the default. Client asks for a web app, you spin up a React project, add a router, add a state management library, build an API layer, set up a build pipeline, and get to work. It works fine. But there's a growing conversation in 2023 about whether it should be the default for everything, and htmx is at the center of that conversation.

htmx is a 14KB JavaScript library (minified and gzipped) that extends HTML with attributes for making AJAX requests, handling WebSocket connections, and doing CSS transitions. Instead of building a JSON API and rendering it on the client with a JavaScript framework, you send HTML fragments from the server and swap them into the page. That's it. That's the whole idea.

The Core Concept

Traditional SPA flow: browser sends HTTP request, server returns JSON, client-side JavaScript parses JSON, builds DOM nodes, inserts them into the page. You need a framework to manage that rendering, state to track what's loaded, and error handling on both the JSON serialization and deserialization sides.

htmx flow: browser sends HTTP request (triggered by an HTML attribute), server returns an HTML fragment, htmx swaps that fragment into a target element. The server does the rendering. The client just puts HTML where you tell it to.

The philosophy is called HATEOAS (Hypermedia as the Engine of Application State), and it's actually how the web was originally designed to work. HTML is the application. Links and forms are the interaction model. htmx just extends that model so any element can make HTTP requests, not just links and forms.

Basic Attributes

Here's a simple example. A button that loads content from the server:

<button hx-get="/api/greeting"
        hx-target="#output"
        hx-swap="innerHTML">
  Load Greeting
</button>

<div id="output"></div>

When clicked, htmx sends a GET request to `/api/greeting`. The server responds with an HTML fragment (not JSON), something like `<p>Hello, Bryan!</p>`. htmx takes that fragment and swaps it into the `#output` div. No JavaScript written. No state to manage. No JSON parsing.

The core attributes:

  • `hx-get`, `hx-post`, `hx-put`, `hx-patch`, `hx-delete` make the corresponding HTTP requests.
  • `hx-target` specifies where to put the response (CSS selector). Defaults to the element itself.
  • `hx-swap` controls how the response is inserted: `innerHTML`, `outerHTML`, `beforebegin`, `afterend`, etc.
  • `hx-trigger` defines what triggers the request: `click` (default for buttons), `change`, `submit`, `keyup`, or custom events.

Search As You Type

Here's a real pattern: search with live results as the user types.

<input type="search"
       name="q"
       hx-get="/search"
       hx-trigger="keyup changed delay:300ms"
       hx-target="#results"
       hx-indicator="#spinner"
       placeholder="Search articles..." />

<span id="spinner" class="htmx-indicator">Searching...</span>

<div id="results"></div>

The `hx-trigger` here says: on keyup, but only when the value has actually changed, and wait 300ms after the last keystroke before sending (debounce). The `hx-indicator` shows a loading spinner during the request.

Your server endpoint at `/search?q=whatever` renders the search results as HTML and returns them. htmx swaps the HTML into `#results`. The debounce, the loading state, the AJAX request, and the DOM update are all handled by HTML attributes. Zero custom JavaScript.

To build this same feature in React, you'd need: a useState for the query, a useState for results, a useEffect for the debounced API call, a fetch call, JSON parsing, a loading state, and a JSX template. That's roughly 30 to 40 lines of React code versus 6 lines of HTML attributes.

Infinite Scroll

Another common pattern that htmx handles elegantly:

<div id="article-list">
  <article>First article...</article>
  <article>Second article...</article>

  <div hx-get="/articles?page=2"
       hx-trigger="revealed"
       hx-swap="outerHTML">
    Loading more...
  </div>
</div>

The `revealed` trigger fires when the element scrolls into the viewport. htmx sends a GET to `/articles?page=2`, which returns more article HTML along with a new trigger element pointing to page 3. The `outerHTML` swap replaces the loading div with the new content. Each page response includes the next trigger element, creating an infinite chain. No intersection observer code, no page state tracking, no scroll position calculations.

Progressive Enhancement with hx-boost

This might be my favorite htmx feature. Add `hx-boost="true"` to any element (or the body), and all links and forms within it are automatically "boosted" to use AJAX instead of full page navigations:

<body hx-boost="true">
  <nav>
    <a href="/about">About</a>
    <a href="/blog">Blog</a>
    <a href="/contact">Contact</a>
  </nav>
  <main id="content">
    <!-- Page content swapped here -->
  </main>
</body>

Clicking those links now fetches the target page via AJAX and swaps just the body content, giving you SPA-like navigation without a full page reload. The URL updates, the back button works, and if JavaScript fails to load, the links still work as regular links. Real progressive enhancement.

The Bundle Size Story

Let's compare the client-side cost of building a moderately interactive site (search, infinite scroll, form submissions, live validation):

  • React + React DOM + React Router + a fetch library: roughly 45KB to 60KB gzipped, plus your application code.
  • htmx: 14KB gzipped, and that's it. Your application logic lives on the server.

But it's not just about download size. React's bundle needs to parse, compile, and execute before your page becomes interactive. htmx is a thin layer of event handlers. The time-to-interactive difference is significant, especially on slower devices and connections.

When htmx Makes Sense

htmx is excellent for:

  • Content-heavy sites with sprinkles of interactivity (blogs, news sites, documentation).
  • CRUD applications where the server already has all the data and logic (admin panels, internal tools).
  • Server-rendered applications (Django, Rails, Laravel, Express with templates) that need dynamic behavior without a full SPA rewrite.
  • Teams that want to keep complexity on the server rather than splitting it between client and server.

When You Still Need an SPA

htmx is not the right tool for:

  • Complex client-side state (think Google Docs, Figma, or Notion). When the UI state is rich and needs to persist across many interactions without server round-trips, a client-side framework is the right choice.
  • Offline-first applications. htmx requires server connectivity for every interaction.
  • Real-time collaborative features where multiple users are editing the same data simultaneously.
  • Highly animated, transition-heavy interfaces where you need fine-grained control over every DOM update.

What Changed for Me

Building a few features with htmx didn't make me abandon React. But it did change my default assumption. Before htmx, I'd start most projects with React because it was the tool I knew. Now I ask a different question first: does this project actually need a client-side framework?

For a surprising number of projects, the answer is no. A server-rendered page with htmx for dynamic parts gives you 90% of the user experience at 10% of the client-side complexity. The server handles rendering, state, validation, and business logic in one place. The client displays HTML. That's a simpler architecture, and simpler architectures tend to have fewer bugs, easier testing, and faster development cycles.

htmx didn't invent these ideas. Libraries like Turbo (from the Rails world) and Unpoly have been doing HTML-over-the-wire for years. But htmx's simplicity and framework-agnostic approach have brought the idea to a much wider audience. In 2023, the "just send HTML" approach feels less like a contrarian take and more like common sense for the right use cases.

htmxhypermediahtml-over-the-wireprogressive-enhancementserver-side