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
editFormandeditingCard-> only one card can be edited at a time, and the state lives far from the card it belongs toSix methods that all find columns by ID with
.find()-> duplicated lookup logic everywhereDrag state as two top-level variables ->
dragCardanddragFromColare global to the entire boardNested
x-for+x-if-> the template is 80+ lines of interleaved logic and markup with no way to extract reusable piecesNo syntax highlighting inside those HTML attributes, no IntelliJ autocomplete, no type safety
The Solution: Preact Islands
Place a
.tsxfile next to your ViewComponentBun compiles it to a JS bundle in
static/js/islands/A 12-line
mount.jsscript finds[data-island]elements and mounts the Preact componentThe 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:
rootis set tosourceRoot-> the output directory structure mirrors the package structurenaming: "[dir]/[name].js"-> a TSX file atweb/kanban/KanbanIsland.tsxproducesislands/kanban/KanbanIsland.jsminify: 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 sharededitingCardvariable.AddCardForm-> owns its own form state. Each column gets its own form instance. No sharednewCard+addingToCol.Column-> handles drag-over highlighting and composes cards + add form. ThedragOversignal 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@GetMappinghandles the page route,render()builds the view context.Jackson automatically serializes Java records → no extra annotations needed
KanbanCardandKanbanColumnare public nested records reused by both the service and the TypeScript interfaceThe 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.jsth:data-props="${kanbanView.islandPropsJson()}"-> injects the server-side props as JSONThe spinner inside the
divis 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:
bunInstallruns first -> ensuresnode_modules/is up to datebunBuildcompiles the TSX with the.d.tsfiles in place -> Bun's type checker catches mismatches between Java and TypeScriptbunWatchis for development -> runs alongside Spring BootprocessResourcesdepends onbunBuild-> 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:
@RestartScopeon theLiveReloadServerbean -> survives DevTools restarts, so we don't lose the debounce stateislandFileSystemWatcher-> watchesstatic/js/islands/for new JS files and triggers LiveReloadclassPathRestartStrategy-> prevents.tsxfiles from triggering a full Spring Boot restart
This gives us a tight dev loop:
Edit a
.tsxfile in IntelliJbunWatchdetects the change, rebuilds in ~50msThe
FileSystemWatchersees new JS instatic/js/islands/LiveReload triggers a browser refresh
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/signalswithuseComputedis lighter and more ergonomic thanuseState+useMemochainsBun builds a Preact island in ~50ms
When to Use What
| Use Case | Tool |
| Toggle visibility, dropdowns, simple state | Alpine.js |
| Server-driven updates, form submissions | HTMX |
| Drag-and-drop, per-item state, deep component nesting | Preact 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 block | Five focused components (Card, Column, AddCardForm, etc.) |
One shared editingCard + editForm | Each Card owns its own edit state via useSignal |
One shared newCard + addingToCol | Each AddCardForm is an independent component instance |
| Drag state as global variables | Module-level signal shared across components |
Nested x-for + x-if (80+ lines) | Clean component tree with props and callbacks |
| No type safety | Full TypeScript with Java-mirrored interfaces |
| Can't extract or test pieces | Components 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!



