Skip to main content

Command Palette

Search for a command to run...

Svelte 5 Islands in Spring Boot with Kotlin: The Preact Alternative

Updated
Svelte 5 Islands in Spring Boot with Kotlin: The Preact Alternative

Svelte 5 Islands in Spring Boot with Kotlin: The Preact Alternative

In my previous post I showed how to use Preact Islands to handle the 5% of interactions where Alpine.js falls apart. Preact works great, but if you've ever looked at Svelte and thought "that looks cleaner" — you're right.

Svelte 5 with runes (\(state, \)props, $derived) is a natural fit for islands. No useSignalimport, no re-exporting render and h. The component syntax is just cleaner. And with Bun's plugin system, we compile .svelte files the same way we compiled .tsx — zero webpack, zero SPA router.

I migrated PhotoQuest from Preact to Svelte islands, and in this post I'll show you how to set up the same Kanban board example from the Preact article, but with Svelte 5 + Kotlin.

If you haven't read the Preact post, don't worry. This article is self-contained. But the Preact version is useful for comparison.

What Changes from Preact?

The architecture stays the same. Server renders HTML, passes JSON props, a mount script hydrates the island. What changes:

Preact Svelte 5
.tsx files .svelte files
useSignal(value) let x = $state(value)
signal<T>(value) (module-level) Module-level $state in <script module>
Re-export render, h from preact Re-export mount from svelte via <script module>
render(h(Component, props), el) mount(Component, { target: el, props })
Bun builds TSX natively Bun plugin compiles .svelte via svelte/compiler
Java records for props Kotlin data classes for props
Manually maintained .d.ts types Auto-generated .ts from @Serializable classes

Architecture Overview

src/main/kotlin/de/tschuehly/svelte_islands_demo/web/kanban/
    KanbanComponent.kt                # Server-side ViewComponent + Controller
    KanbanComponent.html              # Thymeleaf template (thin shell)
    KanbanIsland.svelte               # Svelte 5 island (all client logic)
    KanbanIslandProps.ts              # TypeScript interface (auto-generated)
    KanbanService.kt                  # Board data service

src/main/kotlin/de/tschuehly/svelte_islands_demo/common/
    IslandTypeGenerator.kt            # Generates .ts from @Serializable classes

frontend/
    build.ts                         # Bun build script (with Svelte plugin)
    watch.ts                         # Dev file watcher
    bunfig.toml                      # Bun config (no JSX needed)
    tsconfig.json                    # TypeScript config

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

Same colocation principle: the .svelte file lives next to the ViewComponent it belongs to.

Setting Up Svelte Islands

Install Dependencies

// package.json
{
  "dependencies": {
    "svelte": "^5.50.2"
  }
}

That's it. No @preact/signals, no JSX import source. Just Svelte.

Configure Bun

Since we're not using JSX anymore, the config is minimal:

// frontend/tsconfig.json
{
  "compilerOptions": {
    "target": "es2022",
    "module": "commonjs"
  }
}

No jsx, no jsxImportSource, no paths for module resolution. Svelte handles its own compilation.

The Build Script (with Svelte Plugin)

This is where the magic happens. Bun doesn't understand .svelte files natively, so we add a plugin that compiles them via svelte/compiler:

// frontend/build.ts
import {Glob} from "bun";
import {compile} from "svelte/compiler";
import type {BunPlugin} from "bun";

const sourceRoot = "../src/main/kotlin/de/tschuehly/svelte_islands_demo/web/";
const scanRoot = "../src/main/kotlin";

const sveltePlugin: BunPlugin = {
  name: "svelte",
  setup(build) {
    build.onLoad({filter: /\.svelte$/}, async (args) => {
      const source = await Bun.file(args.path).text();
      const result = compile(source, {
        filename: args.path,
        generate: "client",
        css: "injected",
      });
      return {
        contents: result.js.code,
        loader: "js",
      };
    });
  },
};

const glob = new Glob("**/*.svelte");
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",
  plugins: [sveltePlugin],
});

console.log("Build complete.");

In detail:

  • sveltePlugin -> intercepts every .svelte file import and compiles it to JavaScript using svelte/compiler
  • generate: "client" -> generates browser-ready code (not SSR)
  • css: "injected" -> Svelte injects scoped CSS directly into the DOM at runtime. No separate CSS files needed
  • The rest is identical to the Preact build: scan for entrypoints, output to islands/, mirror the package structure

The Mount Script

The mount script changes slightly. Instead of Preact's render(h()), we use Svelte's mount():

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

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

Important: Each Svelte island must re-export mount from svelte in a <script module> block:

<script module>
  export {mount} from 'svelte';
</script>

Why <script module>? This is Svelte 5's way of exporting things at the module level — code that runs once per import, not once per component instance. The mount function from Svelte is what creates and attaches a component to a DOM element. Since mount.js is a plain browser module that can't resolve bare 'svelte' imports, the island bundle must include it.

Include mount.js in Your Layout

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

Same as the Preact version.

Writing the Svelte Island

Here's the kanban board as a Svelte 5 component. Compare this to the Preact version — no useSignal, no h(), no explicit re-renders:

<!-- src/main/kotlin/de/tschuehly/svelte_islands_demo/web/kanban/KanbanIsland.svelte -->
<script module>
  export {mount} from 'svelte';
</script>

<script lang="ts">
  import type {KanbanIslandProps, KanbanCard, KanbanColumn} from './KanbanIslandProps.ts';

  let {columns: initialColumns}: KanbanIslandProps = $props();

  let columns: KanbanColumn[] = $state(
      initialColumns.map(col => ({...col, cards: [...col.cards]}))
  );
  let nextId = $state(
      Math.max(...initialColumns.flatMap(c => c.cards.map(card => card.id)), 0) + 1
  );

  // Drag state
  let dragCardId: number | null = $state(null);
  let dragFromColumnId: string | null = $state(null);

  // Edit state — which card is being edited
  let editingCardId: number | null = $state(null);
  let editTitle = $state('');
  let editPriority = $state('medium');
  let editAssignee = $state('');

  // Add state — which column has the add form open
  let addingColumnId: string | null = $state(null);
  let newTitle = $state('');
  let newPriority = $state('medium');
  let newAssignee = $state('');

  function moveCard(cardId: number, fromColId: string, toColId: string) {
    const card = columns.find(c => c.id === fromColId)!.cards.find(c => c.id === cardId)!;
    columns = columns.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 = columns.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 = columns.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++;
    columns = columns.map(col =>
        col.id === colId ? {...col, cards: [...col.cards, {id, ...data}]} : col
    );
  }

  function startEdit(card: KanbanCard) {
    editingCardId = card.id;
    editTitle = card.title;
    editPriority = card.priority;
    editAssignee = card.assignee;
  }

  function saveEdit(colId: string) {
    if (editingCardId == null) return;
    updateCard(colId, editingCardId, {
      title: editTitle,
      priority: editPriority,
      assignee: editAssignee
    });
    editingCardId = null;
  }

  function startAdd(colId: string) {
    addingColumnId = colId;
    newTitle = '';
    newPriority = 'medium';
    newAssignee = '';
  }

  function saveAdd() {
    if (addingColumnId == null) return;
    addCard(addingColumnId, {
      title: newTitle,
      priority: newPriority,
      assignee: newAssignee
    });
    addingColumnId = null;
  }
</script>

<div class="flex gap-4 overflow-x-auto p-4">
  {#each columns as column (column.id)}
    <div
        class="w-72 rounded-lg p-3 bg-base-200"
        ondragover={(e) => { e.preventDefault(); }}
        ondrop={() => {
          if (dragCardId && dragFromColumnId !== column.id) {
            moveCard(dragCardId, dragFromColumnId, 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>

      {#each column.cards as card (card.id)}
        <div
            class="bg-base-100 rounded-lg p-3 mb-2 shadow-sm cursor-grab"
            draggable="true"
            ondragstart={() => { dragCardId = card.id; dragFromColumnId = column.id; }}
            ondragend={() => { dragCardId = null; dragFromColumnId = null; }}
        >
          {#if editingCardId === card.id}
            <div class="space-y-2">
              <input class="input input-sm w-full" bind:value={editTitle} />
              <select class="select select-sm w-full" bind:value={editPriority}>
                <option value="low">Low</option>
                <option value="medium">Medium</option>
                <option value="high">High</option>
              </select>
              <input class="input input-sm w-full" bind:value={editAssignee}
                     placeholder="Assignee" />
              <div class="flex gap-1">
                <button class="btn btn-xs btn-primary" onclick={() => saveEdit(column.id)}>
                  Save
                </button>
                <button class="btn btn-xs" onclick={() => { editingCardId = null; }}>
                  Cancel
                </button>
              </div>
            </div>
          {:else}
            <div>
              <div class="flex justify-between items-start">
                <span class="font-medium">{card.title}</span>
                <span class="badge badge-sm {
                  card.priority === 'high' ? 'badge-error' :
                  card.priority === 'medium' ? 'badge-warning' : 'badge-info'
                }">{card.priority}</span>
              </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={() => startEdit(card)}>Edit</button>
                <button class="btn btn-xs btn-error"
                        onclick={() => deleteCard(column.id, card.id)}>Delete</button>
              </div>
            </div>
          {/if}
        </div>
      {/each}

      {#if addingColumnId === column.id}
        <div class="bg-base-100 rounded-lg p-3 mb-2 space-y-2">
          <input class="input input-sm w-full" bind:value={newTitle}
                 placeholder="Card title" />
          <select class="select select-sm w-full" bind:value={newPriority}>
            <option value="low">Low</option>
            <option value="medium">Medium</option>
            <option value="high">High</option>
          </select>
          <input class="input input-sm w-full" bind:value={newAssignee}
                 placeholder="Assignee" />
          <div class="flex gap-1">
            <button class="btn btn-xs btn-primary" onclick={saveAdd}>Add</button>
            <button class="btn btn-xs" onclick={() => { addingColumnId = null; }}>
              Cancel
            </button>
          </div>
        </div>
      {:else}
        <button class="btn btn-sm btn-ghost w-full mt-2"
                onclick={() => startAdd(column.id)}>+ Add Card</button>
      {/if}
    </div>
  {/each}
</div>

Compare this to the Preact version:

  • $state() instead of useSignal() -> same reactivity, but it looks like regular JavaScript. No .value on read, Svelte's compiler handles that.
  • $props() instead of function parameters -> destructured directly, type-safe via the imported interface
  • {#each} with keyed blocks -> Svelte's template syntax replaces .map() with JSX
  • bind:value -> two-way binding built into the language. No onInput handlers needed.
  • <script module> -> exports mount once at module level, no need to re-export render and h
  • css: "injected" -> if you add <style> blocks, Svelte scopes them to the component automatically
  • UI state as $state variables -> editing and adding state tracked at component level (editingCardId, addingColumnId), with helper functions like startEdit() and saveEdit() to manage transitions

Wiring the Server Side (Kotlin)

The ViewComponent

Same pattern as the Java version, but with Kotlin data classes and Spring ViewComponent:

// KanbanComponent.kt
@ViewComponent
@Controller
class KanbanComponent(
    private val objectMapper: ObjectMapper,
    private val kanbanService: KanbanService,
    private val layoutViewComponent: LayoutViewComponent
) {

    @Serializable
    data class KanbanCard(val id: Int, val title: String, val priority: String, val assignee: String)

    @Serializable
    data class KanbanColumn(val id: String, val title: String, val cards: List<KanbanCard>)

    @Serializable
    data class KanbanIslandProps(val columns: List<KanbanColumn>)

    data class KanbanView(val islandPropsJson: String) : ViewContext

    @GetMapping("/")
    fun index(): ViewContext = layoutViewComponent.render(render())

    fun render(): KanbanView {
        val columns = kanbanService.getColumns()
        val islandProps = KanbanIslandProps(columns)
        return KanbanView(objectMapper.writeValueAsString(islandProps))
    }
}

In detail:

  • Kotlin data classes serialize to JSON with Jackson out of the box — same as Java records but with less boilerplate
  • @Serializable -> marks the class for kotlinx.serialization. This is what the IslandTypeGenerator uses to produce TypeScript interfaces automatically
  • KanbanCard and KanbanColumn are the single source of truth — the TypeScript types are generated from them, not manually maintained
  • The objectMapper.writeValueAsString() call turns the props into JSON that gets injected into data-props

The KanbanService

// KanbanService.kt
@Service
class KanbanService {

    fun getColumns(): List<KanbanComponent.KanbanColumn> = listOf(
        KanbanComponent.KanbanColumn(
            "todo", "To Do", listOf(
                KanbanComponent.KanbanCard(1, "Design landing page", "high", "Alice"),
                KanbanComponent.KanbanCard(2, "Write API docs", "medium", "Bob"),
            )
        ),
        KanbanComponent.KanbanColumn(
            "progress", "In Progress", listOf(
                KanbanComponent.KanbanCard(3, "Auth flow", "high", "Charlie"),
            )
        ),
        KanbanComponent.KanbanColumn("done", "Done", listOf()),
    )
}

The Thymeleaf Template

Identical to the Preact version. The template doesn't care what framework the island uses:

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

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

Type-Safe Island Props (Auto-Generated)

In the Preact version, we manually maintained a .d.ts file that mirrored the Java records. That works, but it's one more thing that can drift out of sync. With Kotlin, we can do better.

The IslandTypeGenerator scans for any class ending in IslandProps that's annotated with @Serializable, and generates the TypeScript interface automatically using kxs-ts-gen:

// IslandTypeGenerator.kt
package de.tschuehly.svelte_islands_demo.common

import dev.adamko.kxstsgen.KxsTsGenerator
import kotlinx.serialization.InternalSerializationApi
import kotlinx.serialization.serializer
import java.io.File

@OptIn(InternalSerializationApi::class)
fun main() {
    val tsGenerator = KxsTsGenerator()
    val classesDir = File("build/classes/kotlin/main")
    val basePackage = "de/tschuehly/svelte_islands_demo"

    val islandPropsFiles = classesDir.resolve(basePackage)
        .walk()
        .filter { it.isFile && it.name.endsWith("IslandProps.class") }
        .toList()

    var count = 0
    for (classFile in islandPropsFiles) {
        val className = classFile.relativeTo(classesDir).path
            .removeSuffix(".class")
            .replace(File.separatorChar, '.')
        val clazz = Class.forName(className)
        val serializer = clazz.kotlin.serializer()
        val tsContent = tsGenerator.generate(serializer)

        val packagePath = clazz.`package`.name.replace('.', '/')
        val outputFile = File("src/main/kotlin/\(packagePath/\){clazz.simpleName}.ts")
        outputFile.writeText(buildString {
            appendLine("// Auto-generated by IslandTypeGenerator — do not edit")
            appendLine()
            appendLine(tsContent)
        })
        println("Generated ${outputFile.path}")
        count++
    }
    println("Generated $count island type definition(s)")
}

In detail:

  • KxsTsGenerator -> converts kotlinx.serialization descriptors to TypeScript interfaces
  • The walker scans compiled classes for anything ending in IslandProps.class — this also matches inner classes like KanbanComponent$KanbanIslandProps.class, which is exactly what we want since the props are nested inside the ViewComponent
  • clazz.kotlin.serializer() -> gets the serializer that @Serializable generated at compile time
  • The output .ts file is written next to the Kotlin source — same colocation principle as the .svelte files
  • The // Auto-generated header makes it clear this file shouldn't be hand-edited

Running ./gradlew generateIslandTypes produces this file:

// KanbanIslandProps.ts
// Auto-generated by IslandTypeGenerator — do not edit

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[];
}

No manual syncing. Add a field to the Kotlin data class, run the build, and the TypeScript interface updates automatically. The Svelte component imports it the same way:

<script lang="ts">
  import type {KanbanIslandProps, KanbanCard, KanbanColumn} from './KanbanIslandProps.ts';
</script>

You need two dependencies in your build.gradle.kts for this to work:

// build.gradle.kts (dependencies block)
implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.3")
implementation("dev.adamko.kxstsgen:kxs-ts-gen-core:0.2.1")

And the kotlinx.serialization Gradle plugin:

// build.gradle.kts (plugins block)
plugins {
    kotlin("plugin.serialization") version "2.1.0"
}

Gradle Integration

The Gradle tasks are nearly identical to the Preact version — swap .tsx for .svelte and add the type generation step:

// 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<JavaExec>("generateIslandTypes") {
    dependsOn("compileKotlin")
    mainClass.set("de.tschuehly.svelte_islands_demo.common.IslandTypeGeneratorKt")
    classpath = files(
        tasks.named("compileKotlin").map { it.outputs.files },
        sourceSets["main"].compileClasspath
    )
}

tasks.register<Exec>("bunBuild") {
    dependsOn("bunInstall", "generateIslandTypes")
    workingDir = file("frontend")
    commandLine(bunBinary, "run", "build.ts")
    inputs.files(fileTree("src/main/kotlin").matching { include("**/*.svelte") })
    inputs.file("frontend/build.ts")
    outputs.dir("src/main/resources/static/js/islands")
}

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

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

In detail:

  • generateIslandTypes -> compiles Kotlin first, then runs the IslandTypeGenerator against the compiled classes. This generates the .ts files before Bun tries to build the islands
  • bunBuild depends on both bunInstall and generateIslandTypes -> so the TypeScript interfaces are always up-to-date when Bun compiles the .svelte files
  • The inputs.files pattern uses **/*.svelte so Gradle knows when to re-run the build

Live Reload in Development

The Watch Script

Same approach as Preact, watching for .svelte changes:

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

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

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 .svelte changes...`);

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

Spring Boot DevTools Integration (Kotlin)

The LiveReload config in Kotlin, excluding .svelte from triggering app restarts:

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

    private val logger = LoggerFactory.getLogger(IslandLiveReloadConfig::class.java)

    @Bean
    @RestartScope
    fun liveReloadServer(properties: DevToolsProperties): LiveReloadServer {
        return object : LiveReloadServer(
            properties.livereload.port,
            Restarter.getInstance().threadFactory
        ) {
            @Volatile
            private var lastReloadTime = 0L

            override fun triggerReload() {
                val now = System.currentTimeMillis()
                if (now - lastReloadTime < 3000) {
                    logger.debug("Debounced duplicate livereload")
                    return
                }
                lastReloadTime = now
                super.triggerReload()
            }
        }
    }

    @Bean
    fun islandFileSystemWatcher(liveReloadServer: LiveReloadServer): FileSystemWatcher {
        val watcher = FileSystemWatcher()
        val islandsDir = File("src/main/resources/static/js/islands")
        watcher.addSourceDirectory(islandsDir)
        watcher.addListener { changeSet ->
            if (changeSet.any { cf -> cf.files.any { it.relativeName.endsWith(".js") } }) {
                liveReloadServer.triggerReload()
            }
        }
        watcher.start()
        return watcher
    }

    @Bean
    fun classPathRestartStrategy(properties: DevToolsProperties): ClassPathRestartStrategy {
        val delegate = PatternClassPathRestartStrategy(properties.restart.allExclude)
        return ClassPathRestartStrategy { changedFile ->
            if (changedFile.relativeName.endsWith(".svelte")) false
            else delegate.isRestartRequired(changedFile)
        }
    }
}

Why Svelte over Preact?

Both work great for islands. Choose based on preference:

Aspect Preact Svelte 5
Bundle size ~3KB gzipped ~2KB gzipped (no runtime)
Reactivity useSignal() + .value $state() — reads like plain JS
Component syntax JSX (familiar to React devs) .svelte template (HTML-first)
CSS scoping None built-in Automatic with <style> blocks
Build step Native Bun support 15-line Bun plugin
Two-way binding Manual onInput handlers bind:value
Learning curve Zero if you know React Small, but the syntax is arguably simpler
Props types Manually maintained .d.ts Auto-generated from @Serializable

I personally switched PhotoQuest to Svelte because $state reads cleaner than useSignal() and the built-in CSS scoping is a nice bonus for islands that need custom styling.

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 Svelte Island

Same as before. The three coexist perfectly. HTMX handles 90% of interactions. Alpine.js handles UI toggles. Svelte Islands handle the 5% where you need real component state and composability.

Summary

Preact Islands Svelte 5 Islands
Re-export render, h from preact Re-export mount from svelte in <script module>
Bun compiles TSX natively 15-line Bun plugin compiles .svelte
useSignal() + .value everywhere $state() — plain variable assignment
JSX with explicit event handlers Template syntax with bind:value
No built-in CSS scoping Scoped <style> blocks out of the box
Java records for props Kotlin data classes for props
Manually maintained .d.ts Auto-generated .ts via IslandTypeGenerator

The architecture is identical: server renders HTML, passes typed JSON props, a 12-line mount script hydrates the island. The only difference is which frontend framework does the client-side rendering — and with the IslandTypeGenerator, the TypeScript interfaces stay in sync with your Kotlin data classes automatically. Pick whichever syntax you prefer.

If you want to learn more about HTMX + Spring Boot check out my HTMX + Spring Boot series. My side business PhotoQuest is built with Svelte islands + Kotlin + Spring Boot — exactly this stack.

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

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.