<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Arvin Wilderink</title>
    <description>The latest articles on DEV Community by Arvin Wilderink (@awilderink).</description>
    <link>https://dev.to/awilderink</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F366459%2Fb1d33b9d-15b1-41cf-940e-6c5a4b67142c.jpeg</url>
      <title>DEV Community: Arvin Wilderink</title>
      <link>https://dev.to/awilderink</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/awilderink"/>
    <language>en</language>
    <item>
      <title>We built a big app on htmx. Then we needed islands.</title>
      <dc:creator>Arvin Wilderink</dc:creator>
      <pubDate>Tue, 07 Apr 2026 16:09:33 +0000</pubDate>
      <link>https://dev.to/awilderink/we-built-a-big-app-on-htmx-then-we-needed-islands-2dp2</link>
      <guid>https://dev.to/awilderink/we-built-a-big-app-on-htmx-then-we-needed-islands-2dp2</guid>
      <description>&lt;h2&gt;
  
  
  Why we picked htmx
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;is&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;is&lt;/em&gt; the HTML. Rename a field on a domain object and the typechecker catches every template that touched it.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wizard that broke me
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Endpoint&lt;/th&gt;
&lt;th&gt;Target&lt;/th&gt;
&lt;th&gt;Swap&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;POST /import/step-1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;#upload-form&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;outerHTML&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;POST /import/step-2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;#progress&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;outerHTML&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;POST /import/step-3&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;#progress&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;outerHTML&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET  /import/back&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;#step-content&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;innerHTML&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET  /import/retry&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;#step-content&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;innerHTML&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;POST /import/template-confirm&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;&lt;code&gt;none&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET  /import/progress&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;#progress&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;morph&lt;/code&gt; (every 500ms)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET  /import/cancel&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;&lt;code&gt;none&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Eight endpoints. Three swap targets. One in-memory &lt;code&gt;Map&amp;lt;string, UploadJob&amp;gt;&lt;/code&gt; 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 &lt;code&gt;&amp;lt;div id=...&amp;gt;&lt;/code&gt; wrapper, and which &lt;code&gt;hx-target&lt;/code&gt; the caller happens to have.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Want to rename &lt;code&gt;#step-content&lt;/code&gt;? Grep for the literal string. Hope you find every use. Hope nobody put it in a template literal.&lt;/p&gt;

&lt;p&gt;The progress bar makes it worse. Every 500ms the browser hits &lt;code&gt;GET /progress&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;This is the moment you start drafting an architecture diagram in your head and realize you have invented a worse version of a signal.&lt;/p&gt;

&lt;h2&gt;
  
  
  The swap graph
&lt;/h2&gt;

&lt;p&gt;The wizard is an extreme case, but the underlying pattern is everywhere in any non-trivial htmx app.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;hx-swap-oob&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;In a small app this is fine. The graph is small enough to hold in your head. In a complex layout it becomes &lt;em&gt;the&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;That is one of the two locality problems an htmx-at-scale codebase hands you: the &lt;em&gt;feature locality&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;The other locality problem shows up the moment you try to build a stateful interaction inside one of those fragments.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Alpine ceiling
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;Alpine.data("id", () =&amp;gt; ({...}))&lt;/code&gt; in its own file. So:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// alpine/data/picker.ts&lt;/span&gt;
&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Alpine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;picker&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;selected&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

  &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="nf"&gt;total&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;selected&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;

  &lt;span class="nf"&gt;toggle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;selected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;selected&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`[data-id="&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"]`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;selected&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;getAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;data-hours&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the markup, in a file hundreds of lines away:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;x-data&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"picker"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;label&lt;/span&gt; &lt;span class="na"&gt;data-id&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;data-hours&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hours&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"checkbox"&lt;/span&gt; &lt;span class="na"&gt;x-on&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="na"&gt;change&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;`toggle('&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;')`&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; (&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hours&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;h)
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;label&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;

  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Total: &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;x-text&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"total"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;h&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;

  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;
    &lt;span class="na"&gt;hx-post&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"/invoice"&lt;/span&gt;
    &lt;span class="na"&gt;x-bind&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="na"&gt;hx-vals&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"JSON.stringify({task_ids: Object.keys(selected)})"&lt;/span&gt;
  &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    Create invoice
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twenty-five lines, and look at the seams.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;data-hours&lt;/code&gt; attribute on each row, then &lt;code&gt;getAttribute&lt;/code&gt; it out and &lt;code&gt;Number()&lt;/code&gt; it the moment it is touched. The selection state is &lt;code&gt;Record&amp;lt;string, number&amp;gt;&lt;/code&gt; because the value just made a round trip through a string DOM attribute. No type system has any opinion on whether &lt;code&gt;data-hours&lt;/code&gt; exists, what shape it is, or whether &lt;code&gt;task.hours&lt;/code&gt; upstream is even still called that.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;x-bind:hx-vals="JSON.stringify(...)"&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;Rename &lt;code&gt;data-hours&lt;/code&gt; in the markup? &lt;code&gt;getAttribute('data-hours')&lt;/code&gt; in the picker still compiles. It silently returns &lt;code&gt;null&lt;/code&gt;. The total quietly becomes &lt;code&gt;NaN&lt;/code&gt;.&lt;/p&gt;

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

&lt;p&gt;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.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  The rewrite that wasn't
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The Astro insight is the one I wish I had earlier: &lt;strong&gt;server-render everything, ship JavaScript only where it earns its keep.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Atollic
&lt;/h2&gt;

&lt;p&gt;So I built &lt;a href="https://github.com/awilderink/atollic" rel="noopener noreferrer"&gt;Atollic&lt;/a&gt;. One sentence: &lt;strong&gt;Astro-style islands for any WinterCG server.&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;The same picker, as a single Solid island, looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="cm"&gt;/** @jsxImportSource solid-js */&lt;/span&gt;
&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createSignal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;For&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;solid-js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Task&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;hours&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;TaskPicker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;selected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setSelected&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;createSignal&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tasks&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;selected&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hours&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;toggle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nf"&gt;setSelected&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;For&lt;/span&gt; &lt;span class="na"&gt;each&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tasks&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;label&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt;
              &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"checkbox"&lt;/span&gt;
              &lt;span class="na"&gt;checked&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;selected&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
              &lt;span class="na"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;toggle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
            &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; (&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hours&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;h)
          &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;label&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;For&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;

      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Total: &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;total&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;h&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;

      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;form&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"/invoice"&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"post"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;For&lt;/span&gt; &lt;span class="na"&gt;each&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nf"&gt;selected&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"hidden"&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"task_ids"&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;For&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"submit"&lt;/span&gt; &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;selected&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          Create invoice
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;form&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;h2&gt;
  
  
  What we get back
&lt;/h2&gt;

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

&lt;h2&gt;
  
  
  Where this is at, honestly
&lt;/h2&gt;

&lt;p&gt;Atollic is &lt;code&gt;v0.0.x&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;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, &lt;code&gt;bun add atollic&lt;/code&gt;, &lt;a href="https://github.com/awilderink/atollic" rel="noopener noreferrer"&gt;read the README&lt;/a&gt;, and tell me what breaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Help shape it
&lt;/h2&gt;

&lt;p&gt;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:&lt;/p&gt;

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

&lt;p&gt;Issues and PRs are open at &lt;a href="https://github.com/awilderink/atollic" rel="noopener noreferrer"&gt;github.com/awilderink/atollic&lt;/a&gt;. 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>fullstack</category>
      <category>solidjs</category>
      <category>bunjs</category>
    </item>
  </channel>
</rss>
