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:
- User interagiert (Klick, Eingabe, Scroll)
- HTMX schickt HTTP-Request an den Server
- Server antwortet mit HTML-Fragment
- 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:
| Aufgabe | Technologie |
|---|---|
| Seiten rendern | Astro (SSR/SSG) |
| Server-Interaktionen | HTMX |
| Lokale UI-State | Alpine.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:
darkModewird aus dem localStorage geladen und persistierthandleDrop()kombiniert Alpine.js (Drag-State) mit HTMX (Server-Request)statswerden initial vom Server gerendert und nach HTMX-Requests aktualisiertx-initregistriert 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-Endpointhx-target="#kanban-board"– Response ersetzt das Kanban-Boardhx-swap="outerHTML"– Das komplette Element wird ersetzthx-on::after-request="this.reset()"– Formular nach Erfolg zurücksetzen
Alpine.js ergänzt:
x-model="newPriority"– Zwei-Wege-Binding für den Selectx-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 (
dragOverfü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> = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
};
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:
- Alpine.js öffnet das Modal und speichert die
deleteId - HTMX sendet den DELETE-Request mit dynamischer URL (
:hx-delete) - Alpine.js schließt das Modal nach dem Klick
- 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-putist dynamisch (Alpine.js Variable)x-modelbindet Formularfelder an Alpine.js State@htmx:after-requestschließ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?
| Interaktion | Technologie | Grund |
|---|---|---|
| Formular absenden | HTMX | Daten müssen zum Server |
| Modal öffnen/schließen | Alpine.js | Rein lokaler UI-State |
| Liste nachladen | HTMX | Neue Daten vom Server |
| Dark Mode Toggle | Alpine.js | Kein Server nötig, localStorage |
| Drag & Drop (visuell) | Alpine.js | Lokales Feedback |
| Drag & Drop (speichern) | HTMX | Status-Änderung persistieren |
| Live-Suche | HTMX | Server filtert Ergebnisse |
| Dropdown Toggle | Alpine.js | Rein lokaler State |
| Inline-Validierung | Beides | Alpine 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:
- Browser lädt leere HTML-Hülle
- JavaScript lädt und parst (~300-700 KB)
- API-Calls holen Daten
- 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:
- Browser lädt fertiges HTML mit allen Inhalten
- HTMX/Alpine laden (~30 KB)
- Seite ist sofort interaktiv
Crawler sehen:
- Vollständigen Content
- Semantische HTML-Struktur
- Keine JavaScript-Abhängigkeit für Inhalte
Konkrete Vorteile
| Aspekt | SPA (React) | AHA-Stack |
|---|---|---|
| First Contentful Paint | 2-4 Sekunden | < 1 Sekunde |
| Crawler-Kompatibilität | Nur Google (mit Verzögerung) | Alle Crawler |
| Core Web Vitals | Oft schlecht (LCP, CLS) | Meist gut |
| Meta-Tags | Erfordert SSR-Setup | Nativ |
| Strukturierte Daten | Umständlich | Direkt 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:
- Live-Demo ansehen
- Repository klonen
- Code anpassen und eigene Features bauen
Wer den AHA-Stack einmal verstanden hat, wird überrascht sein, wie oft er die bessere Wahl ist.
Quellen und weiterführende Links
- AHA Stack – Die offizielle Dokumentation des Konzepts
- Astro Dokumentation – Framework-Referenz und Guides
- HTMX Dokumentation – Alle Attribute, Events und Patterns
- HTMX Examples – Praktische Beispiele für typische Use Cases
- Alpine.js Dokumentation – Einführung und API-Referenz
- Alpine.js Directives – Alle verfügbaren Direktiven
- Beispielprojekt auf GitHub – Vollständiger Quellcode des Kanban-Boards
- Live-Demo – Das Projekt in Aktion
- Erster Teil: Container API – Astro Container API auf Cloudflare Workers