DEV Community

Anton Makasin
Anton Makasin

Posted on

HTMX + Alpine.js: Building a SPA Feel Without a Client-Rendered App

Our marketplace uses no React, Vue, or Svelte application. Most of the interactive UI is HTMX and Alpine.js layered on server-rendered HTML. Alpine is a JavaScript framework, but it does not turn the site into a client-rendered SPA.

The Principle

Every navigable page URL can return a complete page rendered with Go and Templ. HTMX progressively enhances navigation by replacing fragments. Alpine.js handles local UI state such as dropdowns and modals.

There is no virtual DOM or hydration step. We still copy or minify our JavaScript for deployment, and an isolated rich-text editor has its own bundle; HTMX and Alpine do not require a framework-specific compile pipeline.

HTMX Navigation: Catalog Example

The catalog keeps an ordinary GET form. Global hx-boost progressively enhances links and forms, while the inherited target replaces the page's main content:

<body hx-boost="true"
      hx-target="#main-content"
      hx-swap="innerHTML transition:true show:window:top">
  <main id="main-content">
    <form method="get" action="/en/vehicles/cars/for-sale">
      <select name="brand">
        <option value="">All brands</option>
        <option value="toyota">Toyota</option>
      </select>
      <button type="submit">Apply</button>
    </form>
    <!-- Server-rendered catalog cards -->
  </main>
</body>
Enter fullscreen mode Exit fullscreen mode

Without JavaScript, the form performs normal navigation. With HTMX, hx-boost sends an HX-Request header and swaps #main-content. The same URL remains directly navigable and crawlable.

The form does not hardcode hx-push-url. When query facets can be represented by a canonical path, the server returns the HTMX response with HX-Push-Url; a normal request receives the corresponding redirect. This keeps URL normalization at the HTTP boundary instead of duplicating it in browser code.

Alpine.js: Client-Side State

Alpine handles interactions that do not need a server round trip:

<!-- Mobile menu toggle -->
<div x-data="{ open: false }">
    <button @click="open = !open"
            :aria-expanded="open.toString()"
            aria-controls="mobile-menu">
        Menu
    </button>
    <nav id="mobile-menu" x-show="open" x-transition>
        <!-- menu items -->
    </nav>
</div>
Enter fullscreen mode Exit fullscreen mode

Our Alpine components are registered from a plain JavaScript file. That is a project choice, not an Alpine requirement.

Notifications via HTMX Triggers

When a server action completes, it can send a small UI event through an HTMX response header. The public and account UI uses show-flash (the admin UI has an analogous show-toast event):

func SendSiteFlash(c *gin.Context, message string, isError bool) {
    payload := map[string]any{
        "show-flash": map[string]any{
            "message": message,
            "error":   isError,
        },
    }
    data, _ := json.Marshal(payload)
    c.Header("HX-Trigger", ToHeaderASCII(string(data)))
}
Enter fullscreen mode Exit fullscreen mode

On the client, Alpine listens for the event and updates an accessible status element:

<div x-data="{ msg: '', err: false, show: false }"
     @show-flash.window="msg = $event.detail.message; err = !!$event.detail.error; show = true"
     x-show="show"
     :role="err ? 'alert' : 'status'">
  <span x-text="msg"></span>
</div>
Enter fullscreen mode Exit fullscreen mode

JSON encoding protects the header's structure, while ASCII conversion keeps the HTTP header transport-safe. Neither step makes sensitive provider errors suitable for users, so map those to controlled messages first and keep header payloads small.

Why This Stack?

  1. Progressive enhancement. Important content and links exist in server-rendered HTML.
  2. A smaller client runtime. We ship only the libraries and custom behavior the page needs. The actual payload should be measured, not assumed.
  3. A direct mental model. The server returns HTML, HTMX swaps it, and Alpine owns small pockets of local state.

Complex client-side interactions can still justify a focused library. We use a rich-text editor as an isolated module rather than making the entire site a client application.

One boundary matters: Alpine validation is only a usability enhancement. Authorization and input validation still belong on the server.


This article is based on lessons from building Towami, a multi-country marketplace and white-label storefront platform. Follow for more practical notes on Go, HTMX, infrastructure, and SaaS.

Top comments (1)

Collapse
 
mp3sss31 profile image
mp3sss •

This is a nice counterpoint to the SPA default - progressive enhancement keeps every URL independently renderable, which is also the property I care about most in Electron: a window that can load a static HTML fallback or prerendered shell degrades gracefully when the renderer bundle fails, instead of showing a blank white screen. The no-hydration property is a real win too: Alpine's x-data scopes stay small enough to reason about locally, much like keeping UI state inside a webview with a narrow IPC contract rather than a full framework store. One trade-off worth naming: hx-boost swaps the main content on navigation, so if you ever need granular route-level code-splitting or cross-route transitions, the server-rendered model pushes that work to the server side.