LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Server-Driven UI with htmx and Alpine.js

htmxalpinejshypermediafrontendweb-developmentserver-side-rendering

For a decade the default answer to “build an interactive web app” has been: stand up a single-page application (SPA) in React or similar, expose a JSON API, and serialize your UI state into JavaScript on the client. It works, and for genuinely app-like products it is the right call. But it imposes a heavy tax — a build pipeline, a client-side router, a state-management story, an API layer that exists only to feed the frontend, and two copies of your domain model (one in the backend, one in the client). For a backend engineer who just wants a server to render interactive HTML, that tax can feel absurd.

This post is about the alternative that has gone from contrarian to genuinely production-ready: hypermedia-driven applications built with htmx and Alpine.js. The pitch is simple — your server stays the source of truth and renders HTML, htmx swaps fragments of that HTML into the page in response to user actions, and Alpine handles the small bits of pure-client interactivity. No SPA, no JSON-for-the-frontend, minimal build step. For a large class of apps, you get a modern interactive experience while staying in the server-rendering world you already know.

This is the pragmatic counterpoint to the rest of this series, which mostly assumed a JS-framework frontend. Here is the other path.


The core idea: hypermedia, not JSON

The architectural distinction is worth stating precisely, because it is the whole philosophy.

In a SPA, the server sends data (JSON), and a client-side JavaScript application turns that data into HTML. The browser is running an application; the server is a data API.

  SPA / JSON model
  ────────────────
  Browser (React app) ──GET /api/orders──► Server
                       ◄──── JSON ─────────
  Browser turns JSON into HTML, manages UI state in JS

In a hypermedia-driven app (HDA), the server sends HTML, and the browser’s job is just to display it and swap pieces of it. The application logic and UI state live on the server.

  Hypermedia / HDA model (htmx)
  ─────────────────────────────
  Browser ──GET /orders (click)──► Server
          ◄──── HTML fragment ─────
  htmx swaps the fragment into the DOM. No client-side app, no JSON.

This is not new — it is how the web originally worked (links and forms are hypermedia controls). What htmx does is generalize hypermedia: it lets any element issue any HTTP request on any event and swap the response into any part of the page. The web’s original model, with the arbitrary limitations removed.


htmx: HTML attributes that make requests

htmx is a small (~14 KB) dependency-free library that adds a handful of HTML attributes. You do not write JavaScript; you annotate HTML. The core attributes:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<!-- On click, POST to /clicked, swap the response into this button's spot -->
<button hx-post="/clicked" hx-swap="outerHTML">Click me</button>

<!-- Live search: on every keystroke (debounced), GET results, fill #results -->
<input type="search" name="q"
       hx-get="/search"
       hx-trigger="input changed delay:300ms"
       hx-target="#results"
       hx-swap="innerHTML">
<div id="results"></div>

<!-- Infinite scroll: when this row scrolls into view, load the next page -->
<tr hx-get="/rows?page=2" hx-trigger="revealed" hx-swap="afterend">...</tr>

The vocabulary is small and composable:

Attribute Meaning
hx-get / hx-post / hx-put / hx-delete Issue this HTTP request
hx-trigger On what event (click, input, revealed, every 2s, …)
hx-target Which element to put the response into
hx-swap How to swap (innerHTML, outerHTML, beforeend, …)

The server’s job is unchanged from classic web development with one twist: it returns HTML fragments rather than full pages. A click on a “Save” button hits a normal route that renders and returns just the updated card’s HTML; htmx swaps it in. Your templating engine (Go templates, Jinja, ERB, Tera, Razor — whatever you already use) does all the rendering. There is no JSON serializer, no client-side renderer, no second model.

htmx 2.0 (the current major version) modernized the library: native ESM module support, a public htmx.swap() API, selfRequestsOnly as a security default (requests only go to your own origin unless you opt out), it dropped legacy IE support, and it moved extensions like SSE and WebSockets into their own independent repositories. It is mature, stable, and used in real production apps.


Alpine.js: the client-side sprinkles

htmx covers everything that involves the server. But some interactivity is purely local and should never round-trip: toggling a dropdown, showing a modal, a tab switch, an optimistic UI flourish. Doing those with htmx would mean a needless server request; doing them with a full framework would be overkill. Alpine.js fills exactly that gap.

Alpine is a tiny (~15 KB) library that adds reactive client-side state directly in your HTML, with no build step. If htmx is “AJAX, done in HTML,” Alpine is “the jQuery-UI-logic layer, done declaratively.” It is Vue-like reactivity scoped to small chunks of markup:

1
2
3
4
5
6
7
<div x-data="{ open: false }">
  <button @click="open = !open">Menu</button>
  <ul x-show="open" @click.outside="open = false">
    <li>Profile</li>
    <li>Settings</li>
  </ul>
</div>

x-data declares local reactive state, @click binds events, x-show/x-if/x-text react to state. It is intentionally small — for sprinkles of interactivity, not for building an entire app. The two libraries are explicitly complementary, and the community consensus in 2026 is to use them together: htmx for anything that talks to the server, Alpine for anything that is purely client-side. Between them they cover the vast majority of what teams previously reached for React to do.


Why this is attractive (and the honest costs)

The strengths are real:

  • One language, one model, one place for logic. Your domain logic, validation, and rendering all live on the server in the language you already use. No duplicated models, no separate API built solely to feed a frontend, no client/server state-sync bugs.
  • Tiny client footprint. ~30 KB of library total versus hundreds of KB of framework and app code. This shows up directly in the Core Web Vitals from the performance post — less JavaScript to download, parse, and execute means better LCP and INP almost for free. Teams routinely report Lighthouse scores improving when they move off a heavy SPA.
  • Minimal-to-no build step. You can literally include htmx and Alpine via a <script> tag and ship. No bundler, no node_modules, no transpile — a striking simplification for a backend team. (You can add a build step for asset hashing if you want; you are not forced to.)
  • Progressive enhancement. Because the server renders real HTML and forms, the app can degrade gracefully — links and forms still work without JS, and htmx enhances them. This is hard to achieve with a client-rendered SPA.
  • Backend-agnostic. htmx and Alpine do not care what renders the HTML. Go (html/template), Python (Django/Flask + Jinja), Rust (Axum + Askama/Maud), Ruby (Rails + ERB/ViewComponent), even ASP.NET Razor — all are first-class. The server just needs to return HTML fragments.

The costs are equally real, and pretending otherwise would be dishonest:

  • It is not for every app. Highly interactive, client-state-heavy applications — a spreadsheet, a drawing tool, a complex real-time dashboard, anything that must work offline or feel like a native app — are genuinely better served by a SPA framework. If your UI state is rich and lives naturally on the client, fighting that with server round-trips is the wrong tool.
  • Latency is on the critical path. Every server-driven interaction is a network round-trip. On a fast connection this is imperceptible; on a slow mobile network, an interaction that an SPA would handle instantly client-side now waits for the server. Alpine mitigates this for purely-local interactions, but the trade-off is structural.
  • You think in fragments. Designing endpoints that return the right HTML fragment for each interaction is a different muscle than designing a JSON API or full-page renders. It is learnable and arguably simpler, but it is a shift.
  • The ecosystem is smaller. React has a component library and a hire-able developer for every conceivable need. htmx/Alpine have a healthy but far smaller ecosystem; you will build more yourself.
  • Complex shared client state gets awkward. When many parts of the page must react to the same client-side state, Alpine’s deliberately-small model starts to strain, and you feel the absence of a real state framework.

When to choose which

A blunt decision guide:

   Is the app's interactivity mostly       Mostly client-side, rich local
   server-data-driven (CRUD, forms,        state, offline, app-like feel?
   dashboards, content, admin tools)?               │
            │                                        ▼
            ▼                                  SPA framework
   htmx + Alpine                              (React/Vue/Svelte)
   (hypermedia)                               + JSON API

   Need a few client sprinkles               Need deep, shared client
   on top of server-rendered HTML?           state across the whole UI?
            │                                        │
            ▼                                        ▼
   Add Alpine                                 Reach for a real framework

The honest framing: htmx + Alpine is the right default for the large category of apps that are fundamentally about reading and writing server data — CRUD apps, admin panels, content sites, dashboards, line-of-business tools, most internal software. That is a lot of the web. For that category, the hypermedia approach delivers a modern, interactive UX at a fraction of the complexity, and it lets a backend team ship a polished frontend without adopting and maintaining a whole second technology stack. For genuinely app-like products with rich client state, the SPA remains the right tool, and you should reach for it without guilt.


A concrete end-to-end example

To make it tangible, here is a complete “add a todo” interaction with a Go backend. Note there is no JSON, no client-side rendering, and no build step.

1
2
3
4
5
6
7
8
9
<!-- The form posts to /todos; the response (the new <li>) is appended to the list -->
<form hx-post="/todos" hx-target="#todo-list" hx-swap="beforeend"
      hx-on::after-request="this.reset()">
  <input name="title" placeholder="New todo" required>
  <button type="submit">Add</button>
</form>
<ul id="todo-list">
  <!-- existing todos rendered server-side -->
</ul>
1
2
3
4
5
6
7
// The handler renders and returns ONLY the new <li> fragment
func addTodo(w http.ResponseWriter, r *http.Request) {
    title := r.FormValue("title")
    todo := store.Create(title)                 // your normal domain logic
    // render just the fragment for this one item
    tmpl.ExecuteTemplate(w, "todo-item.html", todo)
}
1
2
3
4
5
6
7
<!-- todo-item.html — one fragment, reused for initial render and for swaps -->
<li x-data="{ done: {{.Done}} }">
  <input type="checkbox" x-model="done"
         hx-put="/todos/{{.ID}}/toggle" hx-trigger="change">
  <span :class="done && 'line-through'">{{.Title}}</span>
  <button hx-delete="/todos/{{.ID}}" hx-target="closest li" hx-swap="outerHTML">x</button>
</li>

That single template fragment serves triple duty: the server uses it for the initial page render, htmx uses the same endpoint to append a new item, and Alpine handles the instant visual toggle of the checkbox’s strikethrough without waiting for the server. One model, one template, one language — and a UI that feels modern. That is the whole promise of the hypermedia approach, and for the right app it is a genuinely liberating way to build.


This concludes the Web and Frontend Platform series. We went from the build pipeline that produces your bundles, through the browser runtime that executes them, into measuring and engineering performance, and finally to an architecture that sidesteps much of the JavaScript-framework complexity altogether. The throughline: the frontend is a comprehensible platform, not a mystery — and once you understand it, you can choose your tools deliberately rather than by default.


Sources:

Comments