Skip to main content

Command Palette

Search for a command to run...

Preact Islands in Spring Boot with htmx: When Alpine.js Isn't Enough Anymore

Updated
Preact Islands in Spring Boot with htmx: When Alpine.js Isn't Enough Anymore

I build my webserver-rendered web applications with Spring Boot, Thymeleaf, and HTMX. I sprinkle some Alpine.js on top for dropdowns and toggles. And for 95% of interactions, this stack is perfect.

But then you hit that one feature – a kanban board with draggable cards, inline editing, cross-column state, and per-card interactions. And you're suddenly writing an entire application inside HTML attributes.

Preact Islands fix this without throwing away your server-rendered architecture. Drop a single .tsx file next to your ViewComponent, and it becomes a self-contained interactive widget. No webpack, no SPA router. 3KB of Preact and your component logic.

I personally use this pattern in PhotoQuest for the few components where HTMX + Alpine.js weren't cutting it. Let me show you how to set it up.

The Problem: JavaScript in HTML Attributes

Here's a kanban board in Alpine.js. Users drag cards between columns, edit cards inline, and add new ones:

<!-- KanbanComponent.html -->
<div x-data="{
    columns: [
        { id: 'todo', title: 'To Do', cards: [
            { id: 1, title: 'Design landing page', priority: 'high', assignee: 'Alice' },
            { id: 2, title: 'Write API docs', priority: 'medium', assignee: 'Bob' },
        ]},
        { id: 'progress', title: 'In Progress', cards: [
            { id: 3, title: 'Auth flow', priority: 'high', assignee: 'Charlie' },
        ]},
        { id: 'done', title: 'Done', cards: [] },
    ],
    dragCard: null,
    dragFromCol: null,
    editingCard: null,
    editForm: { title: '', priority: 'medium', assignee: '' },
    addingToCol: null,
    newCard: { title: '', priority: 'medium', assignee: '' },
    nextId: 4,
    startDrag(card, colId) { this.dragCard = card; this.dragFromCol = colId },
    onDrop(targetColId) {
        if (!this.dragCard || this.dragFromCol === targetColId) return;
        const from = this.columns.find(c => c.id === this.dragFromCol);
        const to = this.columns.find(c => c.id === targetColId);
        from.cards = from.cards.filter(c => c.id !== this.dragCard.id);
        to.cards.push(this.dragCard);
        this.dragCard = null; this.dragFromCol = null;
    },
    startEdit(card) {
        this.editingCard = card.id;
        this.editForm = { title: card.title, priority: card.priority, assignee: card.assignee };
    },
    saveEdit(colId) {
        const col = this.columns.find(c => c.id === colId);
        Object.assign(col.cards.find(c => c.id === this.editingCard), this.editForm);
        this.editingCard = null;
    },
    startAdd(colId) { this.addingToCol = colId; this.newCard = { title: '', priority: 'medium', assignee: '' } },
    confirmAdd() {
        this.columns.find(c => c.id === this.addingToCol).cards.push({ id: this.nextId++, ...this.newCard });
        this.addingToCol = null;
    },
    deleteCard(colId, cardId) {
        const col = this.columns.find(c => c.id === colId);
        col.cards = col.cards.filter(c => c.id !== cardId);
    },
    priorityColor(p) { return { high: 'badge-error', medium: 'badge-warning', low: 'badge-info' }[p] || '' }
}" class="flex gap-4">

  <template x-for="col in columns" :key="col.id">
    <div class="w-72 bg-base-200 rounded-lg p-3" @dragover.prevent @drop="onDrop(col.id)">
      <h3 x-text="col.title" class="font-bold mb-3"></h3>

      <template x-for="card in col.cards" :key="card.id">
        <div class="bg-base-100 rounded p-3 mb-2 cursor-grab"
             draggable="true" @dragstart="startDrag(card, col.id)">

          <template x-if="editingCard !== card.id">
            <div>
              <div class="flex justify-between">
                <span x-text="card.title" class="font-medium"></span>
                <span :class="'badge badge-sm ' + priorityColor(card.priority)"
                      x-text="card.priority"></span>
              </div>
              <div class="text-sm opacity-60 mt-1" x-text="card.assignee"></div>
              <div class="flex gap-1 mt-2">
                <button @click="startEdit(card)" class="btn btn-xs">Edit</button>
                <button @click="deleteCard(col.id, card.id)"
                        class="btn btn-xs btn-error">Delete
                </button>
              </div>
            </div>
          </template>

          <template x-if="editingCard === card.id">
            <div class="space-y-2">
              <input x-model="editForm.title" class="input input-sm w-full"/>
              <select x-model="editForm.priority" class="select select-sm w-full">
                <option value="low">Low</option>
                <option value="medium">Medium</option>
                <option value="high">High</option>
              </select>
              <input x-model="editForm.assignee" class="input input-sm w-full"
                     placeholder="Assignee"/>
              <div class="flex gap-1">
                <button @click="saveEdit(col.id)" class="btn btn-xs btn-primary">Save</button>
                <button @click="editingCard = null" class="btn btn-xs">Cancel</button>
              </div>
            </div>
          </template>
        </div>
      </template>

      <template x-if="addingToCol === col.id">
        <div class="bg-base-100 rounded p-3 mb-2 space-y-2">
          <input x-model="newCard.title" class="input input-sm w-full" placeholder="Card title"/>
          <select x-model="newCard.priority" class="select select-sm w-full">
            <option value="low">Low</option>
            <option value="medium">Medium</option>
            <option value="high">High</option>
          </select>
          <input x-model="newCard.assignee" class="input input-sm w-full" placeholder="Assignee"/>
          <div class="flex gap-1">
            <button @click="confirmAdd()" class="btn btn-xs btn-primary">Add</button>
            <button @click="addingToCol = null" class="btn btn-xs">Cancel</button>
          </div>
        </div>
      </template>

      <button @click="startAdd(col.id)" class="btn btn-sm btn-ghost w-full mt-2">+ Add Card</button>
    </div>
  </template>
</div>

This technically works. But look at what we're dealing with:

  • One shared editForm and editingCard -> only one card can be edited at a time, and the state lives far from the card it belongs to

  • Six methods that all find columns by ID with .find() -> duplicated lookup logic everywhere

  • Drag state as two top-level variables -> dragCard and dragFromCol are global to the entire board

  • Nested x-for + x-if -> the template is 80+ lines of interleaved logic and markup with no way to extract reusable pieces

  • No syntax highlighting inside those HTML attributes, no IntelliJ autocomplete, no type safety

The Solution: Preact Islands

  1. Place a .tsx file next to your ViewComponent

  2. Bun compiles it to a JS bundle in static/js/islands/

  3. A 12-line mount.js script finds [data-island] elements and mounts the Preact component

  4. The server passes typed props as JSON via data-props

Architecture Overview

src/main/java/de/tschuehly/preact_islands_demo/web/kanban/
    KanbanComponent.java              # Server-side ViewComponent + Controller
    KanbanComponent.html              # Thymeleaf template (thin shell)
    KanbanIsland.tsx                   # Preact island (all client logic)
    KanbanIslandProps.d.ts             # TypeScript interface (manually maintained)
    KanbanService.java                 # Board data service

frontend/
    build.ts                         # Bun build script
    watch.ts                         # Dev file watcher
    bunfig.toml                      # JSX config
    tsconfig.json                    # TypeScript config

src/main/resources/static/js/
    mount.js                         # Island mount script (12 lines)
    islands/                         # Build output (gitignored)
        kanban/KanbanIsland.js

The .tsx lives next to the ViewComponent it belongs to, in the same package directory. Not in a separate frontend/src/components/ directory three folders away.

Setting Up Preact Islands

Install Dependencies

Add preact to your root package.json so IntelliJ resolves the imports properly:

// package.json
{
  "dependencies": {
    "preact": "^10.28.3",
    "@preact/signals": "^2.7.0"
  }
}

Configure Bun for Preact JSX

# frontend/bunfig.toml
[build]
jsx = "react-jsx"
jsxImportSource = "preact"
// frontend/tsconfig.json
{
  "compilerOptions": {
    "target": "es2022",
    "module": "commonjs",
    "jsx": "react-jsx",
    "jsxImportSource": "preact",
    "baseUrl": "..",
    "paths": {
      "preact": [
        "./node_modules/preact"
      ],
      "preact/*": [
        "./node_modules/preact/*"
      ]
    }
  }
}

The Build Script

frontend/build.ts scans for all .tsx files in the Java source tree and compiles them:

// frontend/build.ts
import {Glob} from "bun";

const sourceRoot = "../src/main/java/de/tschuehly/preact_islands_demo/web/";
const scanRoot = "../src/main/java";
const glob = new Glob("**/*.tsx");
const entrypoints: string[] = [];

for await (const file of glob.scan(scanRoot)) {
  entrypoints.push(`${scanRoot}/${file}`);
}

console.log(`Found ${entrypoints.length} island(s):`);
entrypoints.forEach((e) => console.log(`  ${e}`));

await Bun.build({
  entrypoints,
  outdir: "../src/main/resources/static/js/islands",
  root: sourceRoot,
  minify: true,
  naming: "[dir]/[name].js",
});

console.log("Build complete.");

In detail:

  • root is set to sourceRoot -> the output directory structure mirrors the package structure

  • naming: "[dir]/[name].js" -> a TSX file at web/kanban/KanbanIsland.tsx produces islands/kanban/KanbanIsland.js

  • minify: true -> Bun tree-shakes and minifies everything in one pass

The Mount Script

This is the entire client-side runtime. 12 lines.

// src/main/resources/static/js/mount.js
document.querySelectorAll("[data-island]").forEach(async (el) => {
  const name = el.dataset.island;
  const props = JSON.parse(el.dataset.props || "{}");

  try {
    const {default: Component, render, h} = await import(`/js/islands/${name}.js`);
    el.innerHTML = "";
    render(h(Component, props), el);
  } catch (err) {
    console.error(`Failed to mount island "${name}":`, err);
    el.innerHTML = '<div class="alert alert-error">Component could not be loaded.</div>';
  }
});

It finds every [data-island] element, dynamically imports the JS bundle, and mounts it with the server-provided props. That's it.

Important: Each island TSX must re-export render and h:

export {render, h};

Why? mount.js is a plain browser JS module. It's not processed by Bun. It can't import { render, h } from "preact" because bare module specifiers don't resolve in the browser without an import map. Bun inlines Preact into each island bundle, so re-exporting from the island is how mount.js gets access.

Include mount.js in Your Layout

Add a single <script> tag to your layout template:

<!-- LayoutViewComponent.html -->
<script th:src="@{/js/mount.js}" type="module"></script>

type="module" ensures it runs after the DOM is ready and supports dynamic import().

Writing the Preact Island

Here's the same kanban board as composable Preact components:

// src/main/java/de/tschuehly/preact_islands_demo/web/kanban/KanbanIsland.tsx
import {render, h} from "preact";
import {useSignal, signal} from "@preact/signals";

export {render, h};

import type {KanbanIslandProps, KanbanCard, KanbanColumn} from "./KanbanIslandProps";

// --- Shared drag state (module-level signal) ---

const dragState = signal<{ cardId: number; fromColumnId: string } | null>(null);

// --- Sub-components ---

function PriorityBadge({priority}: { priority: string }) {
  const colors: Record<string, string> = {
    high: "badge-error", medium: "badge-warning", low: "badge-info",
  };
  return <span class={`badge badge-sm ${colors[priority] || ""}`}>{priority}</span>;
}

function Card({card, columnId, onDelete, onUpdate}: {
  card: KanbanCard;
  columnId: string;
  onDelete: (cardId: number) => void;
  onUpdate: (cardId: number, data: Partial<KanbanCard>) => void;
}) {
  const editing = useSignal(false);
  const title = useSignal(card.title);
  const priority = useSignal(card.priority);
  const assignee = useSignal(card.assignee);

  function save() {
    onUpdate(card.id, {title: title.value, priority: priority.value, assignee: assignee.value});
    editing.value = false;
  }

  return (
      <div class="bg-base-100 rounded-lg p-3 mb-2 shadow-sm cursor-grab"
           draggable
           onDragStart={() => {
             dragState.value = {cardId: card.id, fromColumnId: columnId}
           }}
           onDragEnd={() => {
             dragState.value = null
           }}>
        {editing.value ? (
            <div class="space-y-2">
              <input class="input input-sm w-full" value={title}
                     onInput={e => title.value = e.currentTarget.value}/>
              <select class="select select-sm w-full" value={priority}
                      onChange={e => priority.value = e.currentTarget.value}>
                <option value="low">Low</option>
                <option value="medium">Medium</option>
                <option value="high">High</option>
              </select>
              <input class="input input-sm w-full" value={assignee}
                     onInput={e => assignee.value = e.currentTarget.value} placeholder="Assignee"/>
              <div class="flex gap-1">
                <button class="btn btn-xs btn-primary" onClick={save}>Save</button>
                <button class="btn btn-xs" onClick={() => editing.value = false}>Cancel</button>
              </div>
            </div>
        ) : (
            <div>
              <div class="flex justify-between items-start">
                <span class="font-medium">{card.title}</span>
                <PriorityBadge priority={card.priority}/>
              </div>
              <div class="text-sm text-base-content/60 mt-1">{card.assignee}</div>
              <div class="flex gap-1 mt-2">
                <button class="btn btn-xs" onClick={() => editing.value = true}>Edit</button>
                <button class="btn btn-xs btn-error" onClick={() => onDelete(card.id)}>Delete</button>
              </div>
            </div>
        )}
      </div>
  );
}

function AddCardForm({onAdd, onCancel}: {
  onAdd: (data: Omit<KanbanCard, "id">) => void;
  onCancel: () => void;
}) {
  const title = useSignal("");
  const priority = useSignal("medium");
  const assignee = useSignal("");

  return (
      <div class="bg-base-100 rounded-lg p-3 mb-2 space-y-2">
        <input class="input input-sm w-full" value={title}
               onInput={e => title.value = e.currentTarget.value} placeholder="Card title"/>
        <select class="select select-sm w-full" value={priority}
                onChange={e => priority.value = e.currentTarget.value}>
          <option value="low">Low</option>
          <option value="medium">Medium</option>
          <option value="high">High</option>
        </select>
        <input class="input input-sm w-full" value={assignee}
               onInput={e => assignee.value = e.currentTarget.value} placeholder="Assignee"/>
        <div class="flex gap-1">
          <button class="btn btn-xs btn-primary" onClick={() => {
            onAdd({title: title.value, priority: priority.value, assignee: assignee.value});
          }}>Add
          </button>
          <button class="btn btn-xs" onClick={onCancel}>Cancel</button>
        </div>
      </div>
  );
}

function Column({column, onMove, onDelete, onUpdate, onAdd}: {
  column: KanbanColumn;
  onMove: (cardId: number, fromColId: string, toColId: string) => void;
  onDelete: (colId: string, cardId: number) => void;
  onUpdate: (colId: string, cardId: number, data: Partial<KanbanCard>) => void;
  onAdd: (colId: string, data: Omit<KanbanCard, "id">) => void;
}) {
  const adding = useSignal(false);
  const dragOver = useSignal(false);

  return (
      <div class={`w-72 rounded-lg p-3 ${dragOver.value ? "bg-primary/10" : "bg-base-200"}`}
           onDragOver={(e) => {
             e.preventDefault();
             dragOver.value = true
           }}
           onDragLeave={() => dragOver.value = false}
           onDrop={() => {
             dragOver.value = false;
             if (dragState.value && dragState.value.fromColumnId !== column.id) {
               onMove(dragState.value.cardId, dragState.value.fromColumnId, column.id);
             }
           }}>
        <div class="flex justify-between items-center mb-3">
          <h3 class="font-bold">{column.title}</h3>
          <span class="badge badge-ghost badge-sm">{column.cards.length}</span>
        </div>

        {column.cards.map(card => (
            <Card key={card.id} card={card} columnId={column.id}
                  onDelete={(cardId) => onDelete(column.id, cardId)}
                  onUpdate={(cardId, data) => onUpdate(column.id, cardId, data)}/>
        ))}

        {adding.value ? (
            <AddCardForm
                onAdd={(data) => {
                  onAdd(column.id, data);
                  adding.value = false
                }}
                onCancel={() => adding.value = false}/>
        ) : (
            <button class="btn btn-sm btn-ghost w-full mt-2"
                    onClick={() => adding.value = true}>+ Add Card</button>
        )}
      </div>
  );
}

// --- Main island component ---

export default function KanbanIsland({columns: initialColumns}: KanbanIslandProps) {
  const columns = useSignal(
      initialColumns.map(col => ({...col, cards: [...col.cards]}))
  );
  const nextId = useSignal(
      Math.max(...initialColumns.flatMap(c => c.cards.map(card => card.id)), 0) + 1
  );

  function moveCard(cardId: number, fromColId: string, toColId: string) {
    const prev = columns.value;
    const card = prev.find(c => c.id === fromColId)!.cards.find(c => c.id === cardId)!;
    columns.value = prev.map(col => {
      if (col.id === fromColId) return {...col, cards: col.cards.filter(c => c.id !== cardId)};
      if (col.id === toColId) return {...col, cards: [...col.cards, card]};
      return col;
    });
  }

  function deleteCard(colId: string, cardId: number) {
    columns.value = columns.value.map(col =>
        col.id === colId ? {...col, cards: col.cards.filter(c => c.id !== cardId)} : col
    );
  }

  function updateCard(colId: string, cardId: number, data: Partial<KanbanCard>) {
    columns.value = columns.value.map(col =>
        col.id === colId
            ? {...col, cards: col.cards.map(c => c.id === cardId ? {...c, ...data} : c)}
            : col
    );
  }

  function addCard(colId: string, data: Omit<KanbanCard, "id">) {
    const id = nextId.value++;
    columns.value = columns.value.map(col =>
        col.id === colId ? {...col, cards: [...col.cards, {id, ...data}]} : col
    );
  }

  return (
      <div class="flex gap-4 overflow-x-auto p-4">
        {columns.value.map(col => (
            <Column key={col.id} column={col}
                    onMove={moveCard} onDelete={deleteCard}
                    onUpdate={updateCard} onAdd={addCard}/>
        ))}
      </div>
  );
}

We went from one massive x-data block to five focused components:

  • PriorityBadge -> tiny display component, reused in every card.

  • Card -> owns its own edit state. Each card can be edited independently. No shared editingCard variable.

  • AddCardForm -> owns its own form state. Each column gets its own form instance. No shared newCard + addingToCol.

  • Column -> handles drag-over highlighting and composes cards + add form. The dragOver signal is local to each column.

  • KanbanIsland -> board-level state and CRUD operations passed down as callbacks.

Wiring the Server Side

The ViewComponent

The ViewComponent prepares the props as JSON and passes them to the Thymeleaf template. We define the props as Java records these are the single source of truth that we mirror in the TypeScript interface.

// KanbanComponent.java
@ViewComponent
@Controller
public class KanbanComponent {

  private final ObjectMapper objectMapper;
  private final KanbanService kanbanService;
  private final LayoutViewComponent layoutViewComponent;

  public KanbanComponent(ObjectMapper objectMapper, KanbanService kanbanService,
      LayoutViewComponent layoutViewComponent) {
    this.objectMapper = objectMapper;
    this.kanbanService = kanbanService;
    this.layoutViewComponent = layoutViewComponent;
  }

  public record KanbanCard(int id, String title, String priority, String assignee) {}

  public record KanbanColumn(String id, String title, List<KanbanCard> cards) {}

  public record KanbanIslandProps(List<KanbanColumn> columns) {}

  public record KanbanView(String islandPropsJson) implements IViewContext {}

  @GetMapping("/")
  public IViewContext index() {
    return layoutViewComponent.render(render());
  }

  public KanbanView render() {
    var columns = kanbanService.getColumns();
    var islandProps = new KanbanIslandProps(columns);
    return new KanbanView(objectMapper.writeValueAsString(islandProps));
  }
}

In detail:

  • @ViewComponent @Controller -> the component is both a Spring MVC controller and a ViewComponent. The @GetMapping handles the page route, render() builds the view context.

  • Jackson automatically serializes Java records → no extra annotations needed

  • KanbanCard and KanbanColumn are public nested records reused by both the service and the TypeScript interface

  • The board data comes from a service → the server controls the initial state. The island handles all client-side interactions (drag, edit, add). You could add HTMX calls to persist changes back to the server.

The KanbanService

// KanbanService.java
@Service
public class KanbanService {

  public List<KanbanColumn> getColumns() {
    return List.of(
        new KanbanColumn("todo", "To Do", List.of(
            new KanbanCard(1, "Design landing page", "high", "Alice"),
            new KanbanCard(2, "Write API docs", "medium", "Bob")
        )),
        new KanbanColumn("progress", "In Progress", List.of(
            new KanbanCard(3, "Auth flow", "high", "Charlie")
        )),
        new KanbanColumn("done", "Done", List.of())
    );
  }
}

In a real app this would query a database. For our demo, hardcoded data is fine.

The Thymeleaf Template: Just a Shell

<!-- KanbanComponent.html -->
<!--/*@thymesVar id="kanbanView" type="de.tschuehly.preact_islands_demo.web.kanban.KanbanComponent.KanbanView"*/-->

<div class="px-6 py-4">
  <!-- Preact Island mounts here -->
  <div data-island="kanban/KanbanIsland"
       th:data-props="${kanbanView.islandPropsJson()}">
    <span class="loading loading-spinner loading-lg"></span>
  </div>
</div>

In detail:

  • data-island="kanban/KanbanIsland" -> maps to the output path /js/islands/kanban/KanbanIsland.js

  • th:data-props="${kanbanView.islandPropsJson()}" -> injects the server-side props as JSON

  • The spinner inside the div is shown until Preact mounts and replaces the content, giving you a loading state for free

Type-Safe Island Props

The TypeScript interface mirrors the Java records and lives right next to the island:

// KanbanIslandProps.d.ts
export interface KanbanCard {
  id: number;
  title: string;
  priority: string;
  assignee: string;
}

export interface KanbanColumn {
  id: string;
  title: string;
  cards: KanbanCard[];
}

export interface KanbanIslandProps {
  columns: KanbanColumn[];
}

The TSX imports it with a simple relative path:

import type {KanbanIslandProps, KanbanCard, KanbanColumn} from "./KanbanIslandProps";

When you add or change a field on a Java record, update the .d.ts to match. With a handful of islands, this takes seconds. The Bun compiler catches any mismatch between the .d.ts and your TSX on the next build. You get a type error instead of a runtime surprise.

If you add a field to KanbanIslandProps or KanbanCard, the TSX compiler catches any missing props. Type safety from Java to TypeScript.

Gradle Integration

Let's wire everything into the build. Add these tasks to build.gradle.kts:

// build.gradle.kts
val bunBinary: String = System.getenv("BUN_INSTALL")?.let { "$it/bin/bun" }
    ?: "${System.getProperty("user.home")}/.bun/bin/bun"

tasks.register<Exec>("bunInstall") {
    commandLine(bunBinary, "install")
}

tasks.register<Exec>("bunBuild") {
    dependsOn("bunInstall")
    workingDir = file("frontend")
    commandLine(bunBinary, "run", "build.ts")
}

tasks.register<Exec>("bunWatch") {
    dependsOn("bunInstall")
    workingDir = file("frontend")
    commandLine(bunBinary, "run", "watch.ts")
}

tasks.named<ProcessResources>("processResources") {
    dependsOn("bunBuild")
}

Build chain: bunInstall -> bunBuild -> processResources.

In detail:

  • bunInstall runs first -> ensures node_modules/ is up to date

  • bunBuild compiles the TSX with the .d.ts files in place -> Bun's type checker catches mismatches between Java and TypeScript

  • bunWatch is for development -> runs alongside Spring Boot

  • processResources depends on bunBuild -> the compiled JS is in place before the app starts

.gitignore

Remember to gitignore the build artifacts:

/frontend/node_modules/
/src/main/resources/static/js/islands/

The compiled JS in islands/ is a build artifact. It gets regenerated on every build.

Live Reload in Development

For a great DX, we want TSX changes to appear in the browser instantly without restarting Spring Boot.

The Watch Script

frontend/watch.ts watches for .tsx changes and triggers a rebuild:

// frontend/watch.ts
import {watch} from "fs";
import {resolve} from "path";

const scanRoot = resolve(import.meta.dir, "../src/main/java");

async function build() {
  const proc = Bun.spawn(["bun", "run", "build.ts"], {
    cwd: import.meta.dir,
    stdout: "inherit",
    stderr: "inherit",
  });
  await proc.exited;
}

await build();

console.log(`Watching ${scanRoot} for .tsx changes...`);

let debounce: Timer | null = null;
watch(scanRoot, {recursive: true}, (event, filename) => {
  if (!filename?.endsWith(".tsx")) return;
  if (debounce) clearTimeout(debounce);
  debounce = setTimeout(async () => {
    console.log(`\n${filename} changed, rebuilding...`);
    await build();
  }, 200);
});

Run it alongside your Spring Boot app:

./gradlew bunWatch

Spring Boot DevTools Integration

The watch script rebuilds the JS, but the browser still needs to reload. Spring DevTools has a LiveReload server, but it needs to know about island changes. Also, .tsx files in the classpath must not trigger a full app restart, that would defeat the purpose.

// IslandLiveReloadConfig.java
@Configuration
@Profile("dev")
@ConditionalOnClass(FileSystemWatcher.class)
@EnableConfigurationProperties(DevToolsProperties.class)
public class IslandLiveReloadConfig {

  private static final Logger logger = LoggerFactory.getLogger(IslandLiveReloadConfig.class);

  /**
   * Debounced LiveReloadServer -- prevents double-reload when both
   * our island watcher and devtools classpath watcher fire.
   */
  @Bean
  @RestartScope
  LiveReloadServer liveReloadServer(DevToolsProperties properties) {
    return new LiveReloadServer(
        properties.getLivereload().getPort(),
        Restarter.getInstance().getThreadFactory()
    ) {
      private volatile long lastReloadTime = 0L;

      @Override
      public void triggerReload() {
        long now = System.currentTimeMillis();
        if (now - lastReloadTime < 3000) {
          logger.debug("Debounced duplicate livereload");
          return;
        }
        lastReloadTime = now;
        super.triggerReload();
      }
    };
  }

  /**
   * Watches bun build output for changes and triggers LiveReload.
   */
  @Bean
  FileSystemWatcher islandFileSystemWatcher(LiveReloadServer liveReloadServer) {
    var watcher = new FileSystemWatcher();
    var islandsDir = new File("src/main/resources/static/js/islands");
    watcher.addSourceDirectory(islandsDir);
    watcher.addListener(changeSet -> {
      boolean hasJs = changeSet.stream()
          .flatMap(cf -> cf.getFiles().stream())
          .anyMatch(f -> f.getRelativeName().endsWith(".js"));
      if (hasJs) {
        liveReloadServer.triggerReload();
      }
    });
    watcher.start();
    return watcher;
  }

  /**
   * Exclude .tsx from triggering a full app restart.
   */
  @Bean
  ClassPathRestartStrategy classPathRestartStrategy(DevToolsProperties properties) {
    var delegate = new PatternClassPathRestartStrategy(properties.getRestart().getAllExclude());
    return changedFile -> {
      if (changedFile.getRelativeName().endsWith(".tsx"))
        return false;
      return delegate.isRestartRequired(changedFile);
    };
  }
}

In detail:

  • @RestartScope on the LiveReloadServer bean -> survives DevTools restarts, so we don't lose the debounce state

  • islandFileSystemWatcher -> watches static/js/islands/ for new JS files and triggers LiveReload

  • classPathRestartStrategy -> prevents .tsx files from triggering a full Spring Boot restart

This gives us a tight dev loop:

  1. Edit a .tsx file in IntelliJ

  2. bunWatch detects the change, rebuilds in ~50ms

  3. The FileSystemWatcher sees new JS in static/js/islands/

  4. LiveReload triggers a browser refresh

  5. No Spring Boot restart required

How the Pieces Fit Together

  ┌──────────────────────┐
  │  Java records         │
  │  KanbanCard           │──── manually ────▶  KanbanIslandProps.d.ts
  │  KanbanColumn         │     mirrored        (TypeScript)
  │  KanbanIslandProps    │                        │
  └──────────┬────────────┘                        │
             │ ObjectMapper                        │ import type
  ┌──────────▼────────────┐                        │
  │   ViewComponent       │                        │
  │   objectMapper        │                        │
  │     .writeValueAsStr  │                        │
  └──────────┬────────────┘                        │
             │ data-props="{...}"                   │
  ┌──────────▼────────────┐                        │
  │   Thymeleaf Template  │                        │
  │   <div data-island=   │                        │
  │        data-props= /> │                        │
  └──────────┬────────────┘                        │
             │ HTML sent to browser                │
  ┌──────────▼────────────┐                        │
  │   mount.js            │                        │
  │   import → render     │                        │
  └──────────┬────────────┘                        │
             │                                     │
  ┌──────────▼────────────┐                        │
  │   Preact Island       │◀───────────────────────┘
  │   (.tsx)              │
  │   Composable pieces:  │
  │   Card                │
  │   Column              │
  │   AddCardForm         │
  └───────────────────────┘

Why Preact over React?

  • 3KB gzipped vs React's 40KB+

  • Same JSX/hooks API – your React knowledge transfers 1:1

  • @preact/signals with useComputed is lighter and more ergonomic than useState + useMemo chains

  • Bun builds a Preact island in ~50ms

When to Use What

Use CaseTool
Toggle visibility, dropdowns, simple stateAlpine.js
Server-driven updates, form submissionsHTMX
Drag-and-drop, per-item state, deep component nestingPreact Island

The three coexist perfectly. HTMX handles 90% of interactions. Alpine.js handles UI toggles. Preact Islands handle the 5% of components where you need per-item component state, drag-and-drop, or deep composability with type safety.

Summary

Before (Alpine.js)After (Preact Island)
All logic in one flat x-data blockFive focused components (Card, Column, AddCardForm, etc.)
One shared editingCard + editFormEach Card owns its own edit state via useSignal
One shared newCard + addingToColEach AddCardForm is an independent component instance
Drag state as global variablesModule-level signal shared across components
Nested x-for + x-if (80+ lines)Clean component tree with props and callbacks
No type safetyFull TypeScript with Java-mirrored interfaces
Can't extract or test piecesComponents testable in isolation

The server controls the initial board state and passes it as typed JSON props, with the Java record as the single source of truth for the TypeScript interface. The rest of the page remains server-rendered with HTMX. Preact handles the interactive island. The same approach works with Svelte aswell.

If you want to learn more about HTMX + Spring Boot check out my HTMX + Spring Boot series. My side business PhotoQuest is also built with this exact stack.

If you have questions, ping me on twitter.com/tschuehly!

Web development without the JavaScript headache with Spring + HTMX

Part 1 of 4

In this series, I will show you how to create web applications with Spring Boot easier than ever before! We will use JTE as template engine and htmx for interactivity with HATEOAS.

Up next

The best way to build Spring Boot applications with htmx

Supercharging Hypermedia Driven Application with Spring ViewComponent

More from this blog

Thomas Schilling | Spring/HTMX/Claude Code

22 posts

Youngest Speaker @Spring I/O & Spring ViewComponent creator.

Passionate about building awesome software with Spring + HTMX. Pushing full-stack development with Spring forward.