Zum Hauptinhalt springen
Der AHA-Stack in der Praxis: Astro + HTMX + Alpine.js
#Astro #HTMX #Alpine.js #AHA-Stack #Webentwicklung

Der AHA-Stack in der Praxis: Astro + HTMX + Alpine.js


Ein pragmatischer Mittelweg zwischen SPA-Komplexität und klassischen Multi-Page-Sites – mit funktionierendem Kanban-Board als Beispiel

SerieAHA-Stack
Teil 2 von 3
15 Minuten Lesezeit

Im ersten Teil dieser Artikelserie ging es um die Astro Container API und den Patch, der sie auf Cloudflare Workers zum Laufen bringt. Jetzt schauen wir uns das eigentliche Projekt an: Ein Kanban-Board, gebaut mit dem AHA-Stack.

Astro + HTMX + Alpine.js – drei leichtgewichtige Technologien, die zusammen eine pragmatische Alternative zu React, Vue & Co. bieten. Kein 500 KB JavaScript-Bundle, keine komplexe State-Management-Architektur, trotzdem moderne Interaktivität.

Das Konzept ist nicht neu – ahastack.dev dokumentiert den Ansatz ausführlich. Dieser Artikel zeigt eine konkrete Implementierung.

Was ist der AHA-Stack?

Der AHA-Stack kombiniert drei Technologien, die jeweils eine spezifische Aufgabe übernehmen:

Astro – Das Fundament

Astro ist ein modernes Web-Framework mit einem radikalen Ansatz: Zero JavaScript by default. Astro rendert Seiten serverseitig als statisches HTML und liefert nur dann JavaScript aus, wenn es explizit benötigt wird.

Die sogenannte Islands Architecture erlaubt es, einzelne interaktive Komponenten (Islands) in ansonsten statische Seiten einzubetten. Für Content-lastige Sites wie Blogs, Dokumentationen oder Unternehmenswebsites ist das perfekt.

Was Astro mitbringt:

  • Extrem schnelle Build-Zeiten und Ladezeiten
  • Server-Side Rendering (SSR) oder Static Site Generation (SSG)
  • API-Endpoints direkt im Framework
  • Framework-agnostisch: React, Vue, Svelte oder eben kein Framework

HTMX – Interaktivität ohne SPA

HTMX (~14 KB gzipped) ermöglicht moderne Web-Interaktionen über HTML-Attribute. Statt JSON-APIs zu bauen und Client-seitig zu rendern, schickt der Server fertige HTML-Fragmente zurück.

<button hx-get="/load-more" hx-target="#results" hx-swap="beforeend">
  Mehr laden
</button>

Das hx-get löst beim Klick eine GET-Anfrage aus, das Ergebnis wird in #results eingefügt. Kein JavaScript schreiben, keine API-Response parsen, kein DOM manuell manipulieren.

HTMX-Grundprinzip:

  1. User interagiert (Klick, Eingabe, Scroll)
  2. HTMX schickt HTTP-Request an den Server
  3. Server antwortet mit HTML-Fragment
  4. HTMX tauscht den Inhalt im DOM aus

Alpine.js – Lokale UI-Logik

Alpine.js (~15 KB) ist ein minimalistisches JavaScript-Framework für lokale UI-Zustände. Modals öffnen/schließen, Tabs wechseln, Dropdowns togglen – dafür braucht man keine Server-Requests.

<div x-data="{ open: false }">
  <button @click="open = !open">Menü</button>
  <nav x-show="open" @click.away="open = false">
    <!-- Navigation -->
  </nav>
</div>

Alpine.js ist reaktiv, aber leichtgewichtig. Es ersetzt nicht React oder Vue, sondern ergänzt HTMX dort, wo reine Client-Logik gefragt ist.

Warum diese drei zusammenpassen

Die Kombination ist kein Zufall:

AufgabeTechnologie
Seiten rendernAstro (SSR/SSG)
Server-InteraktionenHTMX
Lokale UI-StateAlpine.js

Astro liefert das initiale HTML – schnell, SEO-freundlich, cachebar.

HTMX übernimmt dynamische Inhalte: Formulare absenden, Listen nachladen, Inline-Editing. Der Server bleibt die Single Source of Truth.

Alpine.js handhabt alles, was ohne Server funktionieren soll: Modals, Accordions, Dark-Mode-Toggle, Drag-Feedback.

Das Ergebnis:

  • ~30 KB JavaScript statt 300-700 KB bei React-Apps
  • Keine Build-Komplexität für Interaktivität
  • SEO by default
  • Schnelle Time-to-Interactive

Vorteile auf einen Blick

  • Kein großes Frontend-Framework nötig – React, Vue, Angular bleiben im Werkzeugkasten, aber nicht im Bundle
  • Schnelle Ladezeiten – Weniger JavaScript = schnellerer First Paint
  • Klarer, wartbarer Code – HTML-Attribute statt verschachtelte Komponenten-Hierarchien
  • Server-first – Validierung, Geschäftslogik, Datenzugriff bleiben serverseitig
  • Weniger JS-Overhead – Ideal für Teams mit Backend-Fokus
  • Perfekt für MVPs & Unternehmensseiten – Schnell produktiv, leicht zu erweitern
  • Klassisches Hosting funktioniert – Keine Edge-Functions nötig (aber möglich)

Das Beispielprojekt: Kanban Board

Das folgende Projekt zeigt den AHA-Stack im Einsatz: Ein Kanban Board mit Drag-and-Drop, Inline-Editing, Dark Mode und Echtzeit-Statistiken.

Live-Demo: aha-stack.casoon.dev

GitHub Repository: github.com/casoon/aha-stack-example

Projektstruktur

aha-stack-example/
├── src/
│   ├── components/
│   │   ├── KanbanBoard.astro    # Astro-Komponente für das Board
│   │   └── Hello.astro          # Test-Komponente für Container API
│   ├── layouts/
│   │   └── Base.astro           # Layout mit HTMX/Alpine.js Setup
│   ├── lib/
│   │   ├── db.ts                # In-Memory Datenspeicher
│   │   └── render.ts            # HTML-Rendering für API-Responses
│   └── pages/
│       ├── index.astro          # Hauptseite
│       └── api/
│           ├── tasks.ts         # POST: Neue Aufgabe
│           └── tasks/[id]/
│               ├── index.ts     # PUT/DELETE: Aufgabe bearbeiten/löschen
│               └── status.ts    # PATCH: Status ändern (Drag & Drop)
├── astro.config.mjs             # Mit Container API Patch
└── package.json

Setup: HTMX und Alpine.js einbinden

Im Base-Layout werden beide Libraries per CDN geladen:

<!-- src/layouts/Base.astro -->
<head>
  <!-- HTMX via CDN -->
  <script src="https://unpkg.com/[email protected]"></script>
  <!-- Alpine.js via CDN -->
  <script defer src="https://unpkg.com/[email protected]/dist/cdn.min.js"></script>
</head>

Keine npm-Pakete, keine Build-Konfiguration. Für Produktionsprojekte kann man die Libraries natürlich auch lokal bundlen.

Alpine.js: Globaler State mit x-data

Die Hauptseite definiert einen Alpine.js-State, der von allen Komponenten genutzt wird:

<!-- src/pages/index.astro (Auszug) -->
<div
  x-data={`{
    showDeleteModal: false,
    deleteId: null,
    editingId: null,
    editText: '',
    editDescription: '',
    editPriority: 'medium',
    editDueDate: '',
    isLoading: false,
    darkMode: localStorage.getItem('darkMode') === 'true',
    draggedId: null,
    dragStatus: null,
    newPriority: 'medium',
    stats: { total: ${stats.total}, open: ${stats.open}, inProgress: ${stats.inProgress}, done: ${stats.done} },

    updateStats() {
      const board = document.getElementById('kanban-board');
      if (board) {
        this.stats = {
          total: parseInt(board.dataset.total) || 0,
          open: parseInt(board.dataset.open) || 0,
          inProgress: parseInt(board.dataset.inProgress) || 0,
          done: parseInt(board.dataset.done) || 0
        };
      }
    },

    toggleDarkMode() {
      this.darkMode = !this.darkMode;
      localStorage.setItem('darkMode', this.darkMode);
      document.documentElement.classList.toggle('dark', this.darkMode);
    },

    async handleDrop(event, newStatus) {
      event.preventDefault();
      if (!this.draggedId || this.dragStatus === newStatus) return;
      this.isLoading = true;

      htmx.ajax('PATCH', '/api/tasks/' + this.draggedId + '/status', {
        target: '#kanban-board',
        swap: 'outerHTML',
        values: { status: newStatus }
      });

      this.handleDragEnd();
    }
  }`}
  x-init="
    document.documentElement.classList.toggle('dark', darkMode);
    document.body.addEventListener('htmx:beforeRequest', () => isLoading = true);
    document.body.addEventListener('htmx:afterRequest', () => {
      isLoading = false;
      newPriority = 'medium';
      $nextTick(() => updateStats());
    });
  "
>

Was passiert hier:

  • darkMode wird aus dem localStorage geladen und persistiert
  • handleDrop() kombiniert Alpine.js (Drag-State) mit HTMX (Server-Request)
  • stats werden initial vom Server gerendert und nach HTMX-Requests aktualisiert
  • x-init registriert HTMX-Event-Listener für Loading-States

HTMX: Formulare ohne JavaScript

Das Formular zum Erstellen neuer Aufgaben ist pures HTML mit HTMX-Attributen:

<!-- Neue Aufgabe Formular -->
<form
  hx-post="/api/tasks"
  hx-target="#kanban-board"
  hx-swap="outerHTML"
  hx-on::after-request="this.reset()"
  class="task-form"
>
  <div class="input-wrapper">
    <input
      type="text"
      name="text"
      placeholder="Neue Aufgabe eingeben..."
      required
      minlength="2"
    />
  </div>

  <select name="priority" class="priority-select-input" x-model="newPriority">
    <option value="low">Niedrig</option>
    <option value="medium" selected>Mittel</option>
    <option value="high">Hoch</option>
  </select>

  <button type="submit" class="btn-add">
    <span x-show="!isLoading">Hinzufügen</span>
    <span x-show="isLoading" class="spinner"></span>
  </button>
</form>

Die HTMX-Attribute erklärt:

  • hx-post="/api/tasks" – POST-Request an den API-Endpoint
  • hx-target="#kanban-board" – Response ersetzt das Kanban-Board
  • hx-swap="outerHTML" – Das komplette Element wird ersetzt
  • hx-on::after-request="this.reset()" – Formular nach Erfolg zurücksetzen

Alpine.js ergänzt:

  • x-model="newPriority" – Zwei-Wege-Binding für den Select
  • x-show="isLoading" – Loading-Spinner während des Requests

Der API-Endpoint: HTML statt JSON

Der entscheidende Unterschied zu klassischen SPAs: Der Server antwortet mit HTML, nicht mit JSON.

// src/pages/api/tasks.ts
import type { APIRoute } from 'astro';
import { addTask, validateTask, isValidPriority, type TaskPriority } from '@lib/db';
import { renderKanbanBoard } from '@lib/render';

export const POST: APIRoute = async ({ request }) => {
  const formData = await request.formData();
  const text = formData.get('text') as string;
  const priority = (formData.get('priority') as string) || 'medium';

  const error = validateTask(text);
  if (error) {
    return new Response(`<div class="error-message">${error}</div>`, {
      status: 400,
      headers: { 'Content-Type': 'text/html' }
    });
  }

  const validPriority: TaskPriority = isValidPriority(priority) ? priority : 'medium';
  addTask(text, validPriority);

  return new Response(renderKanbanBoard(), {
    status: 200,
    headers: { 'Content-Type': 'text/html' }
  });
};

Wichtig: Die Response ist das neu gerenderte Kanban-Board als HTML. HTMX tauscht es direkt im DOM aus – keine Client-seitige Logik nötig.

Drag & Drop: Alpine.js trifft HTMX

Drag & Drop zeigt das perfekte Zusammenspiel beider Libraries:

<!-- src/components/KanbanBoard.astro (Auszug) -->
<div
  class="kanban-column"
  data-status={column.id}
  x-on:dragover.prevent="dragOver = true"
  x-on:dragleave="dragOver = false"
  x-on:drop="handleDrop($event, $el.dataset.status)"
  :class="{ 'drag-over': dragOver && dragStatus !== $el.dataset.status }"
  x-data="{ dragOver: false }"
>
  <div class="column-header" style={`border-color: ${column.color}`}>
    <span class="column-icon">{column.icon}</span>
    <h3>{column.title}</h3>
    <span class="column-count" style={`background: ${column.color}`}>
      {column.tasks.length}
    </span>
  </div>

  <div class="column-tasks">
    {column.tasks.length === 0 ? (
      <div class="empty-column">Keine Aufgaben</div>
    ) : (
      column.tasks.map((task) => (
        <div
          class="kanban-task"
          draggable="true"
          data-id={task.id}
          x-on:dragstart="handleDragStart($event, $el.dataset.id)"
          x-on:dragend="handleDragEnd()"
        >
          <!-- Task Content -->
        </div>
      ))
    )}
  </div>
</div>

Alpine.js handhabt:

  • Drag-Feedback (dragOver für visuelles Highlighting)
  • Event-Listener (@dragover, @drop, @dragstart, @dragend)
  • Lokaler State pro Spalte (x-data="{ dragOver: false }")

HTMX handhabt (in handleDrop):

  • Server-Request zum Statuswechsel
  • DOM-Update mit der Server-Response
// src/pages/api/tasks/[id]/status.ts
import type { APIRoute } from 'astro';
import { updateTaskStatus, isValidStatus } from '@lib/db';
import { renderKanbanBoard } from '@lib/render';

export const PATCH: APIRoute = async ({ params, request }) => {
  const id = parseInt(params.id || '0');
  const formData = await request.formData();
  const status = formData.get('status') as string;

  if (!isValidStatus(status)) {
    return new Response('Ungültiger Status', { status: 400 });
  }

  const task = updateTaskStatus(id, status);
  if (!task) {
    return new Response('Aufgabe nicht gefunden', { status: 404 });
  }

  return new Response(renderKanbanBoard(), {
    status: 200,
    headers: { 'Content-Type': 'text/html' }
  });
};

In-Memory Datenspeicher

Das Beispiel nutzt einen simplen In-Memory-Speicher – perfekt für Demos:

// src/lib/db.ts (Auszug)
export type TaskStatus = 'open' | 'in_progress' | 'done';
export type TaskPriority = 'low' | 'medium' | 'high';

export interface Task {
  id: number;
  text: string;
  description: string;
  priority: TaskPriority;
  status: TaskStatus;
  dueDate: string | null;
  createdAt: Date;
}

const tasks: Task[] = [];
let nextId = 1;

export function addTask(text: string, priority: TaskPriority = 'medium'): Task {
  const task: Task = {
    id: nextId++,
    text: text.trim(),
    description: '',
    priority,
    status: 'open',
    dueDate: null,
    createdAt: new Date()
  };
  tasks.push(task);
  return task;
}

export function getTasksByStatus(): Record<TaskStatus, Task[]> {
  return {
    open: tasks.filter(t => t.status === 'open'),
    in_progress: tasks.filter(t => t.status === 'in_progress'),
    done: tasks.filter(t => t.status === 'done')
  };
}

export function updateTaskStatus(id: number, status: TaskStatus): Task | null {
  const task = tasks.find(t => t.id === id);
  if (task) {
    task.status = status;
    return task;
  }
  return null;
}

In Produktion würde man hier eine Datenbank anbinden – Cloudflare D1, Turso, PlanetScale oder eine klassische PostgreSQL-Instanz.

HTML-Rendering für API-Responses

Der Trick bei HTMX: Der Server muss HTML zurückgeben. Dafür gibt es eine zentrale Render-Funktion:

// src/lib/render.ts (Auszug)
import { getTasksByStatus, getStats } from '@lib/db';

export function escapeHtml(text: string): string {
  const map: Record<string, string> = {
    '&': '&amp;',
    '<': '&lt;',
    '>': '&gt;',
    '"': '&quot;',
    "'": '&#039;'
  };
  return text.replace(/[&<>"']/g, (char) => map[char]);
}

export function renderKanbanBoard(): string {
  const tasks = getTasksByStatus();
  const stats = getStats();

  const columns = [
    { id: 'open', title: 'Offen', icon: '📋', color: '#ed8936', tasks: tasks.open },
    { id: 'in_progress', title: 'In Bearbeitung', icon: '🔄', color: '#4299e1', tasks: tasks.in_progress },
    { id: 'done', title: 'Erledigt', icon: '✅', color: '#48bb78', tasks: tasks.done }
  ];

  const columnsHtml = columns.map(column => `
    <div
      class="kanban-column"
      data-status="${column.id}"
      x-on:dragover.prevent="dragOver = true"
      x-on:dragleave="dragOver = false"
      x-on:drop="handleDrop($event, $el.dataset.status)"
      :class="{ 'drag-over': dragOver && dragStatus !== $el.dataset.status }"
      x-data="{ dragOver: false }"
    >
      <div class="column-header" style="border-color: ${column.color}">
        <span class="column-icon">${column.icon}</span>
        <h3>${column.title}</h3>
        <span class="column-count" style="background: ${column.color}">
          ${column.tasks.length}
        </span>
      </div>
      <div class="column-tasks">
        ${column.tasks.length === 0
          ? '<div class="empty-column">Keine Aufgaben</div>'
          : column.tasks.map(task => renderTaskCard(task)).join('')
        }
      </div>
    </div>
  `).join('');

  return `
    <div
      id="kanban-board"
      class="kanban-board"
      data-total="${stats.total}"
      data-open="${stats.open}"
      data-in-progress="${stats.inProgress}"
      data-done="${stats.done}"
    >
      ${columnsHtml}
    </div>
  `;
}

Das Pattern: Serverseitige Template-Funktionen, die HTML-Strings zurückgeben. Simpel, aber effektiv.

HTMX-Patterns im Detail

Das Kanban-Board nutzt verschiedene HTMX-Patterns. Hier die wichtigsten:

Delete mit Bestätigungsdialog

Löschen einer Aufgabe mit Alpine.js-Modal und HTMX-Request:

<!-- Delete-Button in der Task-Card -->
<button
  class="task-delete-btn"
  @click="deleteId = ${task.id}; showDeleteModal = true"
  title="Löschen"
>
  🗑️
</button>

<!-- Modal (einmal pro Seite) -->
<div
  class="modal-overlay"
  x-show="showDeleteModal"
  x-transition
  @click.self="showDeleteModal = false"
>
  <div class="modal">
    <h3>Aufgabe löschen?</h3>
    <p>Diese Aktion kann nicht rückgängig gemacht werden.</p>
    <div class="modal-actions">
      <button class="btn-cancel" @click="showDeleteModal = false">
        Abbrechen
      </button>
      <button
        class="btn-delete"
        :hx-delete="'/api/tasks/' + deleteId"
        hx-target="#kanban-board"
        hx-swap="outerHTML"
        @click="showDeleteModal = false"
        hx-trigger="click"
      >
        Löschen
      </button>
    </div>
  </div>
</div>

Das Zusammenspiel:

  1. Alpine.js öffnet das Modal und speichert die deleteId
  2. HTMX sendet den DELETE-Request mit dynamischer URL (:hx-delete)
  3. Alpine.js schließt das Modal nach dem Klick
  4. HTMX ersetzt das Board mit der Server-Response

Edit-Modal mit dynamischen Werten

Bearbeiten einer Aufgabe – Alpine.js füllt das Formular, HTMX speichert:

<!-- Edit-Button -->
<button
  class="task-edit-btn"
  @click="openEditModal(${task.id}, '${task.text}', '${task.description}', '${task.priority}', '${task.dueDate || ''}')"
>
  ✏️
</button>

<!-- Edit-Modal -->
<div class="modal-overlay" x-show="editingId !== null" x-transition>
  <div class="modal modal-large">
    <h3>Aufgabe bearbeiten</h3>
    <form
      :hx-put="'/api/tasks/' + editingId"
      hx-target="#kanban-board"
      hx-swap="outerHTML"
      @htmx:after-request="editingId = null"
    >
      <div class="form-group">
        <label>Titel</label>
        <input type="text" name="text" x-model="editText" required />
      </div>

      <div class="form-group">
        <label>Beschreibung</label>
        <textarea name="description" x-model="editDescription" rows="3"></textarea>
      </div>

      <div class="form-row">
        <div class="form-group">
          <label>Priorität</label>
          <select name="priority" x-model="editPriority">
            <option value="low">Niedrig</option>
            <option value="medium">Mittel</option>
            <option value="high">Hoch</option>
          </select>
        </div>

        <div class="form-group">
          <label>Fälligkeitsdatum</label>
          <input type="date" name="dueDate" x-model="editDueDate" />
        </div>
      </div>

      <div class="modal-actions">
        <button type="button" class="btn-cancel" @click="editingId = null">
          Abbrechen
        </button>
        <button type="submit" class="btn-save">Speichern</button>
      </div>
    </form>
  </div>
</div>

Key Points:

  • :hx-put ist dynamisch (Alpine.js Variable)
  • x-model bindet Formularfelder an Alpine.js State
  • @htmx:after-request schließt das Modal nach erfolgreichem Save

API-Endpoint für Updates

// src/pages/api/tasks/[id]/index.ts
import type { APIRoute } from 'astro';
import { updateTask, deleteTask, isValidPriority } from '@lib/db';
import { renderKanbanBoard } from '@lib/render';

export const PUT: APIRoute = async ({ params, request }) => {
  const id = parseInt(params.id || '0');
  const formData = await request.formData();

  const updates = {
    text: formData.get('text') as string,
    description: formData.get('description') as string || '',
    priority: formData.get('priority') as string,
    dueDate: formData.get('dueDate') as string || null
  };

  if (!updates.text || updates.text.trim().length < 2) {
    return new Response('Titel muss mindestens 2 Zeichen haben', { status: 400 });
  }

  const task = updateTask(id, {
    text: updates.text,
    description: updates.description,
    priority: isValidPriority(updates.priority) ? updates.priority : 'medium',
    dueDate: updates.dueDate || null
  });

  if (!task) {
    return new Response('Aufgabe nicht gefunden', { status: 404 });
  }

  return new Response(renderKanbanBoard(), {
    headers: { 'Content-Type': 'text/html' }
  });
};

export const DELETE: APIRoute = async ({ params }) => {
  const id = parseInt(params.id || '0');
  const deleted = deleteTask(id);

  if (!deleted) {
    return new Response('Aufgabe nicht gefunden', { status: 404 });
  }

  return new Response(renderKanbanBoard(), {
    headers: { 'Content-Type': 'text/html' }
  });
};

Alpine.js-Patterns im Detail

Dark Mode mit localStorage

Persistenter Dark Mode ohne Server-Request:

// Im x-data Block
darkMode: localStorage.getItem('darkMode') === 'true',

toggleDarkMode() {
  this.darkMode = !this.darkMode;
  localStorage.setItem('darkMode', this.darkMode);
  document.documentElement.classList.toggle('dark', this.darkMode);
}
<!-- Toggle-Button -->
<button class="dark-mode-toggle" @click="toggleDarkMode()">
  <span x-show="!darkMode">🌙</span>
  <span x-show="darkMode">☀️</span>
</button>
/* CSS Custom Properties für Themes */
:root {
  --bg-color: white;
  --text-color: #2d3748;
}

html.dark {
  --bg-color: #1e293b;
  --text-color: #f1f5f9;
}

body {
  background: var(--bg-color);
  color: var(--text-color);
}

Wichtig: x-init wendet den gespeicherten Mode beim Laden an:

<div x-data="..." x-init="document.documentElement.classList.toggle('dark', darkMode)">

Loading States

Globaler Loading-Indicator während HTMX-Requests:

// Im x-data Block
isLoading: false,

// Im x-init Block
document.body.addEventListener('htmx:beforeRequest', () => isLoading = true);
document.body.addEventListener('htmx:afterRequest', () => isLoading = false);
<!-- Loading Bar -->
<div
  class="loading-bar"
  x-show="isLoading"
  x-transition:enter="transition ease-out duration-150"
  x-transition:enter-start="opacity-0"
  x-transition:enter-end="opacity-100"
></div>

<!-- Button mit Spinner -->
<button type="submit">
  <span x-show="!isLoading">Hinzufügen</span>
  <span x-show="isLoading" class="spinner"></span>
</button>

Statistik-Updates nach HTMX-Requests

Das Board enthält Daten-Attribute, die Alpine.js ausliest:

<!-- Server rendert data-Attribute -->
<div id="kanban-board"
     data-total="5"
     data-open="2"
     data-in-progress="1"
     data-done="2">
// Alpine.js liest nach HTMX-Request die neuen Werte
updateStats() {
  const board = document.getElementById('kanban-board');
  if (board) {
    this.stats = {
      total: parseInt(board.dataset.total) || 0,
      open: parseInt(board.dataset.open) || 0,
      inProgress: parseInt(board.dataset.inProgress) || 0,
      done: parseInt(board.dataset.done) || 0
    };
  }
}

// Aufruf nach jedem HTMX-Request
document.body.addEventListener('htmx:afterRequest', () => {
  $nextTick(() => updateStats());
});

HTMX vs. Alpine.js: Wann was?

Die Frage kommt oft: Wann nutze ich HTMX, wann Alpine.js?

InteraktionTechnologieGrund
Formular absendenHTMXDaten müssen zum Server
Modal öffnen/schließenAlpine.jsRein lokaler UI-State
Liste nachladenHTMXNeue Daten vom Server
Dark Mode ToggleAlpine.jsKein Server nötig, localStorage
Drag & Drop (visuell)Alpine.jsLokales Feedback
Drag & Drop (speichern)HTMXStatus-Änderung persistieren
Live-SucheHTMXServer filtert Ergebnisse
Dropdown ToggleAlpine.jsRein lokaler State
Inline-ValidierungBeidesAlpine für UI, HTMX für Server-Check

Faustregel:

  • Braucht es Server-Daten? → HTMX
  • Ist es rein visuell/lokal? → Alpine.js
  • Beides? → Kombinieren (wie bei Drag & Drop)

SEO-Vorteile des AHA-Stacks

Ein oft unterschätzter Vorteil: Der AHA-Stack ist SEO-freundlich by default.

Warum SPAs SEO-Probleme haben

Bei klassischen React/Vue-SPAs:

  1. Browser lädt leere HTML-Hülle
  2. JavaScript lädt und parst (~300-700 KB)
  3. API-Calls holen Daten
  4. Client rendert UI

Problem: Crawler sehen oft nur die leere Hülle. Google kann JavaScript zwar rendern, aber:

  • Verzögerung bis zur Indexierung
  • Nicht alle Crawler rendern JS
  • Hydration-Fehler können Inhalte verstecken

Warum der AHA-Stack besser ist

Mit Astro + HTMX:

  1. Browser lädt fertiges HTML mit allen Inhalten
  2. HTMX/Alpine laden (~30 KB)
  3. Seite ist sofort interaktiv

Crawler sehen:

  • Vollständigen Content
  • Semantische HTML-Struktur
  • Keine JavaScript-Abhängigkeit für Inhalte

Konkrete Vorteile

AspektSPA (React)AHA-Stack
First Contentful Paint2-4 Sekunden< 1 Sekunde
Crawler-KompatibilitätNur Google (mit Verzögerung)Alle Crawler
Core Web VitalsOft schlecht (LCP, CLS)Meist gut
Meta-TagsErfordert SSR-SetupNativ
Strukturierte DatenUmständlichDirekt im HTML

Best Practices für SEO mit AHA-Stack

---
// src/pages/[slug].astro
const { slug } = Astro.params;
const page = await getPage(slug);
---

<html>
<head>
  <!-- Alles serverseitig gerendert -->
  <title>{page.title} | Meine Site</title>
  <meta name="description" content={page.description} />
  <meta property="og:title" content={page.title} />
  <meta property="og:image" content={page.image} />
  
  <!-- Strukturierte Daten -->
  <script type="application/ld+json" set:html={JSON.stringify({
    "@context": "https://schema.org",
    "@type": "Article",
    "headline": page.title,
    "datePublished": page.date
  })} />
</head>
<body>
  <!-- Content ist sofort im HTML -->
  <article>
    <h1>{page.title}</h1>
    <div set:html={page.content} />
  </article>
  
  <!-- HTMX für Interaktionen, nicht für Content -->
  <section hx-get="/related-posts" hx-trigger="revealed">
    <!-- Lazy-loaded, aber nicht SEO-relevant -->
  </section>
</body>
</html>

Regel: SEO-relevanter Content wird serverseitig gerendert. HTMX ist für Interaktionen, nicht für Haupt-Content.

Wann ist der AHA-Stack die richtige Wahl?

Ideal für:

  • Content-Websites mit partieller Interaktivität (Blogs, Docs, Marketing)
  • Admin-Panels und Backoffice-Tools
  • Formulare und Datenerfassung
  • Prototypen und MVPs
  • Teams mit Backend-Fokus
  • Performance-kritische Projekte

Weniger geeignet für:

  • Hochinteraktive Apps (Echtzeit-Kollaboration, Spiele)
  • Offline-First-Anwendungen
  • Apps mit komplexer Client-State-Logik
  • Projekte, die React Native Code-Sharing benötigen

Zusammengefasst

Der AHA-Stack ist kein Ersatz für React oder Vue – aber für viele Projekte die pragmatischere Wahl. Weniger Komplexität, schnellere Ladezeiten, klarer Code.

Das Kanban-Beispiel zeigt: Mit ~30 KB JavaScript (HTMX + Alpine.js) lassen sich interaktive Anwendungen bauen, die früher 300+ KB React-Bundle erfordert hätten.

Der beste Weg zum Ausprobieren:

  1. Live-Demo ansehen
  2. Repository klonen
  3. Code anpassen und eigene Features bauen

Wer den AHA-Stack einmal verstanden hat, wird überrascht sein, wie oft er die bessere Wahl ist.