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.sveltefile import and compiles it to JavaScript usingsvelte/compilergenerate: "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 ofuseSignal()-> same reactivity, but it looks like regular JavaScript. No.valueon 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 JSXbind:value-> two-way binding built into the language. NoonInputhandlers needed.<script module>-> exportsmountonce at module level, no need to re-exportrenderandhcss: "injected"-> if you add<style>blocks, Svelte scopes them to the component automatically- UI state as
$statevariables -> editing and adding state tracked at component level (editingCardId,addingColumnId), with helper functions likestartEdit()andsaveEdit()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 forkotlinx.serialization. This is what theIslandTypeGeneratoruses to produce TypeScript interfaces automaticallyKanbanCardandKanbanColumnare 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 intodata-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-> convertskotlinx.serializationdescriptors to TypeScript interfaces- The walker scans compiled classes for anything ending in
IslandProps.class— this also matches inner classes likeKanbanComponent$KanbanIslandProps.class, which is exactly what we want since the props are nested inside the ViewComponent clazz.kotlin.serializer()-> gets the serializer that@Serializablegenerated at compile time- The output
.tsfile is written next to the Kotlin source — same colocation principle as the.sveltefiles - The
// Auto-generatedheader 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 theIslandTypeGeneratoragainst the compiled classes. This generates the.tsfiles before Bun tries to build the islandsbunBuilddepends on bothbunInstallandgenerateIslandTypes-> so the TypeScript interfaces are always up-to-date when Bun compiles the.sveltefiles- The
inputs.filespattern uses**/*.svelteso 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!



