Zum Inhalt springen
CASOON

Container API vs. Svelte Islands: Kombinieren oder Trennen?

Wenn der Server HTML-Fragmente rendert – braucht man dann überhaupt noch JavaScript-Islands?

10 Minuten
Container API vs. Svelte Islands: Kombinieren oder Trennen?
#Astro #HTMX #Alpine.js #Svelte
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:

  1. Starte mit dem AHA-Stack (Astro + HTMX + Alpine.js)
  2. Identifiziere Bereiche, die mehr brauchen
  3. Isoliere diese Bereiche als eigenständige Svelte-Islands
  4. Mische nicht innerhalb einer Komponente

Für bestehende SPAs:

  1. Migriere schrittweise – nicht alles auf einmal
  2. Content-Seiten zuerst (Blog, Docs, Marketing)
  3. Interaktive Features behalten (vorerst)
  4. Evaluiere pro Feature – braucht es wirklich den Client-State?

Vergleichstabelle

KriteriumHTMX + Alpine.jsSvelte Island
Bundle Size~30 KB (HTMX + Alpine)+15-50 KB pro Island
Server-AbhängigkeitJa (für Dynamik)Optional
SEOPerfekt (HTML)Hydration nötig
Komplexe UI-LogikUmständlichNatürlich
Offline-FähigkeitNeinMöglich
LernkurveNiedrigMittel
DebuggingBrowser DevToolsSvelte DevTools
State-ManagementServer + AlpineSvelte Stores
TestbarkeitE2E-TestsUnit + E2E