DEV Community

Cover image for We built a big app on htmx. Then we needed islands.
Arvin Wilderink
Arvin Wilderink

Posted on

We built a big app on htmx. Then we needed islands.

Why we picked htmx

Before I get into what broke, it is worth saying why we picked htmx in the first place, because I still think the choice was right.

Our app is a domain-driven monolith. Submissions, policies, parties, parameters. Each aggregate has routes that render representations of itself, and when the user acts on a thing, the server decides what the thing should look like next. The client does not need to know the state machine of a submission. The server just tells it. That is HATEOAS, and it is not a gimmick: it is the natural fit for a DDD app where the server is the source of truth. Moving that state machine into a client store would have meant duplicating a model we already had and paying a "keep the client model in sync with the server model" tax on every feature, forever.

htmx also gave us end-to-end type safety for free. Our server renders typed JSX against our domain models, and the browser consumes whatever comes back. There is no API contract, no OpenAPI generator, no client SDK. The contract is the HTML. Rename a field on a domain object and the typechecker catches every template that touched it.

And the mental model is tiny. Request → handler → HTML → swap. No virtual DOM, no hydration tax, no build-tool indirection between "I wrote it" and "the browser runs it." For 90% of the app (the CRUD, the lists, the forms, the navigation), this is simply the best trade anyone has ever offered us.

But there is a 10%. In our case that 10% took two forms: a growing pile of multi-region swaps where tracing "what updates what" had become real cognitive work, and a handful of genuinely stateful interactions where Alpine was the wrong shape. This is the story of both.

The wizard that broke me

We have a spreadsheet-import wizard. The user uploads a file, picks a sheet and header row, then maps each column to a field in our domain schema before a background job parses the rows. Three steps, one progress bar, one submit button. From the user's perspective, it is one feature.

I sat down to add a "Save mapping as template" button to it last quarter and stopped to count how many places the wizard already lived. Here it is:

Endpoint Target Swap
POST /import/step-1 #upload-form outerHTML
POST /import/step-2 #progress outerHTML
POST /import/step-3 #progress outerHTML
GET /import/back #step-content innerHTML
GET /import/retry #step-content innerHTML
POST /import/template-confirm n/a none
GET /import/progress #progress morph (every 500ms)
GET /import/cancel n/a none

Eight endpoints. Three swap targets. One in-memory Map<string, UploadJob> shared between the background parser and the polling endpoint. One implicit state machine that exists nowhere in the codebase. It lives in which endpoint writes which fragment into which <div id=...> wrapper, and which hx-target the caller happens to have.

Want to add a new transition? Find the route handler that should produce the new state. Then find the markup that owns the target id, in some other file you might or might not remember the name of. Then make sure your new fragment HTML matches the wrapper id of the existing target. Then test all four entry points that could land the user in the new state, because nothing in the codebase enumerates them.

Want to rename #step-content? Grep for the literal string. Hope you find every use. Hope nobody put it in a template literal.

The progress bar makes it worse. Every 500ms the browser hits GET /progress, which reads the in-memory job and re-renders the bar. Three actors operating on the same logical state (the async parser writing to the map, the polling endpoint reading from the map, the DOM displaying the last known value), none of them reactive in any meaningful sense. They are three actors yelling across a hallway every 500 milliseconds.

This is the moment you start drafting an architecture diagram in your head and realize you have invented a worse version of a signal.

The swap graph

The wizard is an extreme case, but the underlying pattern is everywhere in any non-trivial htmx app.

A single form post in a complex layout often needs to update more than one region: the main panel, a sidebar summary, a toolbar button, a toast. In htmx that means the route handler has to return every affected fragment, threaded through hx-swap-oob, and the caller has to name each target by DOM id. One action's "response shape" is now a distributed graph of IDs that live in other files.

Rename the id, break the swap. Move the markup that owns the id, break the swap. Add a new place that needs to react to the same action, go edit the route handler that returned it. Nothing connects these except strings, and nothing compiles.

In a small app this is fine. The graph is small enough to hold in your head. In a complex layout it becomes the source of brittleness. You trace "what happens when the user clicks Upload" by grepping across four files, and you find the broken cases in production.

That is one of the two locality problems an htmx-at-scale codebase hands you: the feature locality problem. The unit you actually want to reason about ("the upload wizard," "the orders panel") does not exist as a thing in the code. It is implicit in a graph of IDs and route handlers, and the only tool you have for navigating it is grep.

The other locality problem shows up the moment you try to build a stateful interaction inside one of those fragments.

The Alpine ceiling

For client-side state in an htmx app, the standard answer is Alpine. And it is genuinely great for the small stuff: class toggles, open/close, hover states, a confirm dialog. The ceiling is what happens when the interaction needs to do more than that.

Take the simplest possible "pick some things and submit them" UI. A list of tasks with hours, click to include each one, a running total, a submit button. The "right" Alpine pattern at this size is Alpine.data("id", () => ({...})) in its own file. So:

// alpine/data/picker.ts
window.Alpine.data('picker', () => ({
  selected: {} as Record<string, number>,

  get total() {
    return Object.values(this.selected).reduce((a, b) => a + b, 0);
  },

  toggle(id: string) {
    if (id in this.selected) {
      delete this.selected[id];
      return;
    }
    const row = document.querySelector(`[data-id="${id}"]`);
    this.selected[id] = Number(row?.getAttribute('data-hours') ?? 0);
  },
}));
Enter fullscreen mode Exit fullscreen mode

And the markup, in a file hundreds of lines away:

<div x-data="picker">
  {tasks.map((task) => (
    <label data-id={task.id} data-hours={task.hours}>
      <input type="checkbox" x-on:change={`toggle('${task.id}')`} />
      {task.name} ({task.hours}h)
    </label>
  ))}

  <p>Total: <span x-text="total"></span>h</p>

  <button
    hx-post="/invoice"
    x-bind:hx-vals="JSON.stringify({task_ids: Object.keys(selected)})"
  >
    Create invoice
  </button>
</div>
Enter fullscreen mode Exit fullscreen mode

Twenty-five lines, and look at the seams.

The server already has the task data (id, hours, name) at render time. To get the hours into the picker's running total, we serialize it back into a data-hours attribute on each row, then getAttribute it out and Number() it the moment it is touched. The selection state is Record<string, number> because the value just made a round trip through a string DOM attribute. No type system has any opinion on whether data-hours exists, what shape it is, or whether task.hours upstream is even still called that.

When the user clicks "Create invoice," Alpine has to hand its in-memory state over to htmx. There is no clean way to do this, so it goes through x-bind:hx-vals="JSON.stringify(...)". Alpine serializes its selection so htmx can deserialize it into a form body so the server can deserialize it again. Two frameworks negotiating over the same state, glued by a string.

Rename data-hours in the markup? getAttribute('data-hours') in the picker still compiles. It silently returns null. The total quietly becomes NaN.

Rename the Alpine data id from "picker" to "taskPicker"? Every x-on:change="toggle(...)" in the markup still compiles. They just stop firing.

Multiply this by every interaction on a real page and you have rebuilt components, badly, with three layers of indirection and no type safety across any of the seams.

The honest realization, sitting at my desk at the end of a long week: I want components for this part of the app, but I do not want to throw out htmx for the other 90%.

The rewrite that wasn't

The temptation, of course, is to rip the bandage off. Pick Next.js or Remix or SvelteKit, port everything over the next two quarters, and live happily ever after. We are not doing that. The htmx app works. Half of it would not benefit from React-style state management at all. A rewrite would burn months and ship nothing new to customers in the meantime. It is not the right trade.

The Astro insight is the one I wish I had earlier: server-render everything, ship JavaScript only where it earns its keep. Astro calls these islands. Most of your page is HTML, with small interactive components dotted through it. The trouble with Astro itself, for us, is that Astro is its own server. We already have Elysia. We already have our auth, our middleware, our htmx, our routing. We want islands without giving up any of that.

Atollic

So I built Atollic. One sentence: Astro-style islands for any WinterCG server.

Bring your own server (Elysia, Hono, Bun, Workers, anything that speaks Request/Response). Bring your own UI framework via an adapter (Solid today, Preact and others pluggable). Mark a component with "use client" and Atollic SSRs it on the server, then hydrates it on the client. Everything else is zero-JS HTML.

The same picker, as a single Solid island, looks like this:

/** @jsxImportSource solid-js */
"use client";

import { createSignal, For } from "solid-js";

type Task = { id: string; name: string; hours: number };

export default function TaskPicker(props: { tasks: Task[] }) {
  const [selected, setSelected] = createSignal<Set<string>>(new Set());

  const total = () =>
    props.tasks
      .filter((t) => selected().has(t.id))
      .reduce((sum, t) => sum + t.hours, 0);

  const toggle = (id: string) =>
    setSelected((s) => {
      const next = new Set(s);
      next.has(id) ? next.delete(id) : next.add(id);
      return next;
    });

  return (
    <div>
      <For each={props.tasks}>
        {(task) => (
          <label>
            <input
              type="checkbox"
              checked={selected().has(task.id)}
              onChange={() => toggle(task.id)}
            />
            {task.name} ({task.hours}h)
          </label>
        )}
      </For>

      <p>Total: {total()}h</p>

      <form action="/invoice" method="post">
        <For each={[...selected()]}>
          {(id) => <input type="hidden" name="task_ids" value={id} />}
        </For>
        <button type="submit" disabled={selected().size === 0}>
          Create invoice
        </button>
      </form>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

One file. The tasks are a typed Task[] prop. No data-* attributes, no Number() casts, no Record<string, number>. Selection is a typed Set<string> signal. The submit button is a normal HTML form posting task_ids, so htmx and the rest of the page can swap whatever they want around it without coordination, and the form still works in a <noscript> page.

And critically: htmx swaps still work. Atollic installs a MutationObserver on document.body, so when an htmx swap drops a fresh <TaskPicker> into the DOM, it gets mounted automatically. No htmx:afterSwap listeners. No re-init code. It just works the way you would naively expect.

What we get back

  • Locality, both kinds. For interactions, state + behavior + markup + types live in one file. For features that span multiple UI regions, the island is the boundary. No more fan-out of hx-swap-oob targets glued by string IDs across unrelated routes.
  • Type safety across the seam. Props are serialized and typed on both sides. No more "oh, I forgot the server is sending a string."
  • Zero JS where it does not earn its keep. Pages without islands ship zero JavaScript. No client framework on the listings page.
  • htmx is still in charge. Routing, navigation, server fetches, partial swaps, all htmx, exactly as before. Solid only owns the gnarly parts.
  • No rewrite. We dropped one component into one wizard. Nothing else moved. The other 90% of the app does not know islands exist.

Where this is at, honestly

Atollic is v0.0.x. It is a real working library that ships our import wizard in production, but the API will move before 1.0 and there are sharp edges. There is no React adapter yet. Documentation is README-thin. If you want the safest possible "boring stack" decision, this is not it.

If you want the smallest possible escape hatch out of "htmx is starting to hurt in the complex corners" without throwing away the parts that work, bun add atollic, read the README, and tell me what breaks.

Help shape it

Atollic is small enough that one motivated person can move it forward in a weekend, and there is plenty of room for hands. If any of this sounds like a problem you have lived with, I would love your help making it better. A few concrete things that would land hard right now:

  • A React adapter. The FrameworkAdapter interface is small. Solid is the reference implementation.
  • A Preact adapter. Same story, smaller surface area.
  • A Node fetch adapter recipe for people not on Bun.
  • Examples. Real little apps using Atollic with Hono, with Workers, with htmx in anger. If you build one, I will link it.
  • Bug reports. Especially weird ones. The sharp edges only get sanded down when someone bumps into them.
  • Docs. The README is the docs right now, and that is not going to be true forever.

Issues and PRs are open at github.com/awilderink/atollic. If you want to talk through an idea before you write code, open a discussion or just ping me. No contribution is too small. A typo fix is a contribution.

And whether or not you write a line of code: I am very interested in where your htmx app starts to hurt. Drop it in the comments.

Top comments (0)