Wenn der Server HTML-Fragmente rendert – braucht man dann überhaupt noch JavaScript-Islands?
SerieAHA-Stack
Teil 5 von 5
Nach vier Teilen über den AHA-Stack – von der Container API über das Kanban-Board bis zur Rechenkiste – stellt sich eine Frage, die ich oft höre:
Wenn die Container API Astro-Komponenten serverseitig rendert und HTMX diese als HTML-Fragmente ausliefert – braucht man dann überhaupt noch Svelte-Islands?
Die kurze Antwort: Ja, aber seltener als gedacht. Die lange Antwort folgt jetzt.
Zwei Welten, ein Framework
Astro ist besonders, weil es beide Ansätze unterstützt:
Container API + HTMX = Server-first. Der Server rendert HTML, HTMX tauscht Fragmente aus. Kein JavaScript-Framework im Browser.
Svelte Islands = Client-first. Svelte-Komponenten werden im Browser hydratisiert und übernehmen die Interaktion.
Beide können in derselben Astro-Anwendung koexistieren. Aber sollten sie?
Was Container API + HTMX bereits löst
Bevor wir über Svelte-Islands nachdenken, schauen wir, was der AHA-Stack bereits kann:
Dynamische Listen und Tabellen
<!-- Produkte filtern und sortieren -->
<input
type="search"
name="q"
hx-get="/api/products"
hx-trigger="keyup changed delay:300ms"
hx-target="#product-list"
/>
<div id="product-list">
<!-- Server rendert gefilterte Liste -->
</div>
Der Server filtert, sortiert, paginiert – und liefert fertiges HTML. Kein Client-seitiges State-Management nötig.
Formulare mit Validierung
<!-- Formular mit Server-Validierung -->
<form hx-post="/api/contact" hx-swap="outerHTML">
<input type="email" name="email" required />
<button type="submit">Absenden</button>
</form>
Bei Fehlern rendert der Server das Formular mit Fehlermeldungen neu. Bei Erfolg eine Bestätigung. Alles HTML.
Modals und Overlays
<!-- Modal laden -->
<button
hx-get="/api/product/123/details"
hx-target="#modal-content"
hx-trigger="click"
@click="modalOpen = true"
>
Details
</button>
<div x-show="modalOpen" @click.outside="modalOpen = false">
<div id="modal-content"></div>
</div>
HTMX lädt den Inhalt, Alpine.js steuert die Sichtbarkeit. Einfach und effektiv.
Inline-Editing
<!-- Inline-Edit einer Task -->
<div
id="task-123"
x-data="{ editing: false }"
>
<template x-if="!editing">
<span @click="editing = true">Task-Titel</span>
</template>
<template x-if="editing">
<form
hx-put="/api/tasks/123"
hx-target="#task-123"
hx-swap="outerHTML"
@submit="editing = false"
>
<input type="text" name="title" value="Task-Titel" />
<button type="submit">Speichern</button>
</form>
</template>
</div>
Alpine.js für den Edit-Mode, HTMX für das Speichern, Server rendert die aktualisierte Ansicht.
Was Svelte Islands lösen
Für 90% der Anwendungsfälle reicht der AHA-Stack. Aber es gibt Szenarien, wo Svelte-Islands die bessere Wahl sind:
Komplexe Client-Logik
Ein Drag-and-Drop-Builder mit verschachtelten Elementen, Undo/Redo, und Echtzeit-Preview:
<!-- PageBuilder.svelte -->
<script>
import { writable } from 'svelte/store';
import { dndzone } from 'svelte-dnd-action';
let elements = writable([]);
let history = [];
let historyIndex = -1;
function undo() {
if (historyIndex > 0) {
historyIndex--;
elements.set(history[historyIndex]);
}
}
function redo() {
if (historyIndex < history.length - 1) {
historyIndex++;
elements.set(history[historyIndex]);
}
}
</script>
<div use:dndzone={{ items: $elements }} on:consider={handleDnd}>
{#each $elements as element (element.id)}
<ElementRenderer {element} />
{/each}
</div>
Das ist mit HTMX nicht sinnvoll umsetzbar. Jede Drag-Aktion würde einen Server-Roundtrip erfordern – zu langsam für flüssige Interaktion.
Offline-Fähigkeit
<!-- OfflineNotes.svelte -->
<script>
import { onMount } from 'svelte';
import { openDB } from 'idb';
let notes = [];
let db;
onMount(async () => {
db = await openDB('notes', 1, {
upgrade(db) {
db.createObjectStore('notes', { keyPath: 'id' });
}
});
notes = await db.getAll('notes');
});
async function saveNote(note) {
await db.put('notes', note);
// Später mit Server synchronisieren
}
</script>
Offline-First erfordert Client-seitigen State und IndexedDB. Das kann der Server nicht leisten.
Echtzeit-Validierung mit komplexer Logik
<!-- PasswordStrength.svelte -->
<script>
let password = '';
$: strength = calculateStrength(password);
$: suggestions = getSuggestions(password);
function calculateStrength(pw) {
let score = 0;
if (pw.length >= 8) score++;
if (pw.length >= 12) score++;
if (/[A-Z]/.test(pw)) score++;
if (/[0-9]/.test(pw)) score++;
if (/[^A-Za-z0-9]/.test(pw)) score++;
return score;
}
</script>
<input bind:value={password} type="password" />
<div class="strength-meter" data-score={strength} />
<ul>
{#each suggestions as suggestion}
<li>{suggestion}</li>
{/each}
</ul>
Bei jedem Tastendruck eine Server-Anfrage? Möglich, aber unnötig. Die Logik ist nicht sicherheitsrelevant – das eigentliche Speichern validiert der Server sowieso.
Canvas und WebGL
<!-- ImageEditor.svelte -->
<script>
import { onMount } from 'svelte';
let canvas;
let ctx;
onMount(() => {
ctx = canvas.getContext('2d');
// Komplexe Canvas-Operationen
});
function applyFilter(filter) {
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
// Pixel-Manipulation im Browser
ctx.putImageData(imageData, 0, 0);
}
</script>
<canvas bind:this={canvas} />
Canvas-Rendering muss im Browser passieren. Keine Frage.
Der Vergleich: Gleiche Aufgabe, zwei Ansätze
Nehmen wir ein konkretes Beispiel: Inline-Editing einer Task mit Validierung.
HTMX + Alpine.js Ansatz
---
// /api/tasks/[id].ts
export async function PUT({ params, request }) {
const formData = await request.formData();
const title = formData.get('title');
// Validierung
if (!title || title.length < 3) {
return renderError('Titel muss mindestens 3 Zeichen haben');
}
// Speichern
const task = await updateTask(params.id, { title });
// HTML-Fragment zurückgeben
return renderTaskCard(task);
}
---
<!-- TaskCard.astro -->
<div
id="task-{task.id}"
class="task-card"
x-data="{ editing: false, error: '' }"
>
<template x-if="!editing">
<div class="task-display" @click="editing = true">
<span class="task-title">{task.title}</span>
<span class="edit-hint">Klicken zum Bearbeiten</span>
</div>
</template>
<template x-if="editing">
<form
hx-put={`/api/tasks/${task.id}`}
hx-target={`#task-${task.id}`}
hx-swap="outerHTML"
@htmx:before-request="error = ''"
@htmx:response-error="error = 'Speichern fehlgeschlagen'"
>
<input
type="text"
name="title"
value={task.title}
class="task-input"
@keydown.escape="editing = false"
/>
<div class="task-actions">
<button type="submit" class="btn-save">Speichern</button>
<button type="button" class="btn-cancel" @click="editing = false">
Abbrechen
</button>
</div>
<p x-show="error" x-text="error" class="error"></p>
</form>
</template>
</div>
Vorteile:
- Validierung auf dem Server – nicht manipulierbar
- Kein zusätzliches JavaScript-Bundle
- SEO-freundlich (initialer Inhalt ist HTML)
Nachteile:
- Server-Roundtrip für jede Änderung
- Echtzeit-Feedback nur mit zusätzlichem Aufwand
Svelte Island Ansatz
<!-- TaskCard.svelte -->
<script>
export let task;
let editing = false;
let title = task.title;
let error = '';
let saving = false;
$: isValid = title.length >= 3;
$: validationMessage = title.length < 3 ? 'Mindestens 3 Zeichen' : '';
async function save() {
if (!isValid) return;
saving = true;
error = '';
try {
const res = await fetch(`/api/tasks/${task.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title })
});
if (!res.ok) throw new Error('Speichern fehlgeschlagen');
task = await res.json();
editing = false;
} catch (e) {
error = e.message;
} finally {
saving = false;
}
}
</script>
<div class="task-card">
{#if !editing}
<div class="task-display" on:click={() => editing = true}>
<span class="task-title">{task.title}</span>
</div>
{:else}
<form on:submit|preventDefault={save}>
<input
bind:value={title}
class="task-input"
class:invalid={!isValid && title.length > 0}
on:keydown={(e) => e.key === 'Escape' && (editing = false)}
/>
{#if validationMessage}
<p class="validation-hint">{validationMessage}</p>
{/if}
<div class="task-actions">
<button type="submit" disabled={!isValid || saving}>
{saving ? 'Speichert...' : 'Speichern'}
</button>
<button type="button" on:click={() => editing = false}>
Abbrechen
</button>
</div>
{#if error}
<p class="error">{error}</p>
{/if}
</form>
{/if}
</div>
Vorteile:
- Echtzeit-Validierung ohne Roundtrip
- Flüssigere UX (sofortiges Feedback)
- Komplexere Interaktionen möglich
Nachteile:
- Zusätzliches JavaScript (~15-20 KB für Svelte Runtime)
- Hydration nötig (kurze Verzögerung nach Laden)
- Doppelte Validierung (Client + Server)
Welcher ist besser?
Für eine einfache Task-Karte: HTMX + Alpine.js. Der Mehrwert von Echtzeit-Validierung rechtfertigt nicht die zusätzliche Komplexität.
Für einen Drag-and-Drop-Kanban mit Live-Collaboration: Svelte Island. Die Interaktionsanforderungen übersteigen, was HTMX sinnvoll leisten kann.
Wann Kombination Sinn macht
Es gibt Projekte, wo beide Ansätze nebeneinander existieren sollten:
Große App mit unterschiedlichen Bereichen
/ → HTMX (Landing Page, SEO wichtig)
/blog → HTMX (Content, SEO wichtig)
/dashboard → HTMX (Listen, Tabellen, Filter)
/dashboard/builder → Svelte Island (Drag-Drop-Editor)
/dashboard/analytics → Svelte Island (Charts, Echtzeit)
Klare Trennung nach Anforderungen. Nicht vermischen.
Content-Seite mit interaktivem Widget
---
// Produktseite – Hauptinhalt via HTMX
---
<Layout>
<article>
<h1>{product.title}</h1>
<p>{product.description}</p>
<!-- Produktkonfigurator als Svelte Island -->
<ProductConfigurator
client:visible
product={product}
/>
<!-- Bewertungen via HTMX -->
<div
hx-get={`/api/products/${product.id}/reviews`}
hx-trigger="revealed"
>
Laden...
</div>
</article>
</Layout>
Der Konfigurator braucht komplexe Client-Logik (3D-Preview, Preisberechnung in Echtzeit). Die Bewertungen sind statischer Content – perfekt für HTMX.
Wann Kombination KEINEN Sinn macht
Auch wenn die Kombination auf den ersten Blick nach “Best of both Worlds” aussieht – einige Ansätze funktionieren technisch nicht:
Container API rendert Svelte-Komponente
// ❌ Nicht so!
const html = await runtime.renderToString({
componentId: "svelte-chart",
props: { data }
});
Svelte-Komponenten brauchen Hydration. Wenn der Server sie rendert und HTMX sie einfügt, fehlt die Hydration. Das Chart wäre tot – keine Interaktion möglich.
HTMX swappt Island
<!-- ❌ Auch nicht so! -->
<div
hx-get="/api/widget"
hx-target="#widget-container"
>
Laden...
</div>
Wenn /api/widget eine Svelte-Komponente zurückgibt, muss sie nach dem Swap hydratisiert werden. Astro macht das nicht automatisch. Du bräuchtest Custom-Code, der nach jedem HTMX-Swap die Hydration triggert. Machbar, aber unnötig komplex.
Doppelte State-Verwaltung
<!-- ❌ State-Chaos -->
<script>
let items = []; // Svelte State
// Aber auch HTMX lädt Items?
document.body.addEventListener('htmx:afterSwap', () => {
// Jetzt sind DOM und Svelte State out of sync
});
</script>
Zwei Kapitäne auf einem Schiff. Einer muss das Sagen haben – entweder der Server (HTMX) oder der Client (Svelte).
Umsetzung in der Praxis
Für neue Projekte:
- Starte mit dem AHA-Stack (Astro + HTMX + Alpine.js)
- Identifiziere Bereiche, die mehr brauchen
- Isoliere diese Bereiche als eigenständige Svelte-Islands
- Mische nicht innerhalb einer Komponente
Für bestehende SPAs:
- Migriere schrittweise – nicht alles auf einmal
- Content-Seiten zuerst (Blog, Docs, Marketing)
- Interaktive Features behalten (vorerst)
- Evaluiere pro Feature – braucht es wirklich den Client-State?
Vergleichstabelle
| Kriterium | HTMX + Alpine.js | Svelte Island |
|---|---|---|
| Bundle Size | ~30 KB (HTMX + Alpine) | +15-50 KB pro Island |
| Server-Abhängigkeit | Ja (für Dynamik) | Optional |
| SEO | Perfekt (HTML) | Hydration nötig |
| Komplexe UI-Logik | Umständlich | Natürlich |
| Offline-Fähigkeit | Nein | Möglich |
| Lernkurve | Niedrig | Mittel |
| Debugging | Browser DevTools | Svelte DevTools |
| State-Management | Server + Alpine | Svelte Stores |
| Testbarkeit | E2E-Tests | Unit + E2E |