Abstract

Websites laden im Median über 700 KB JavaScript, im 75. Perzentil über 1,2 MB — auch dann, wenn der Inhalt zu 99 % statisch ist. Der Vortrag stellt die zentrale Frage: „Brauchen wir dafür wirklich eine SPA?" — und beantwortet sie nicht dogmatisch, sondern anhand zweier funktional identischer Rezept-Apps: einmal als klassische React-SPA, einmal mit Astro (Islands Architecture, SSG).

Gezeigt werden die Messwerte beider Varianten (Build-Output, übertragene Datenmenge, Core Web Vitals), die Astro-Grundlagen (.astro-Syntax, Content Collections, Scoped CSS, i18n, file-based Routing), die vier interaktiven Features in reinem Vanilla-JS statt Framework — und der Schritt vom Client zum Server (SSR, Server Islands, Actions).

Worum es NICHT geht: reines Astro-Tutorial, Framework-Vergleiche (außer ein paar spezifischen Metriken), KI.

Agenda

  1. Einleitung: das JS-Bloat-Problem
  2. Warum SPAs zum Standard wurden — und was sie kosten
  3. Die Baseline-App (React-SPA) und ihre Messwerte
  4. Astros Philosophie: „Ship Zero JavaScript by Default"
  5. Der Astro-Nachbau (SSG) inkl. Interaktivität
  6. Vom Client zum Server: SSR, Server Islands, Actions
  7. Fragenkatalog fürs nächste Projekt

01. Einleitung. Das Problem

Beobachtungen

Nicht der Erste, dem das auffällt

Beitrag Autor
JS-Bloat in 2024 Nikita Prokopov
Second-guessing the modern web (2020) Tom MacWright
Gegenangriff auf SPAs (2024) Stranger Than Usual
justfuckingusehtml.com —

Die zentrale Frage

„Brauchen wir dafür wirklich eine SPA?" Oder geht es auch leichtgewichtiger?

Die Antwort ist manchmal „Ja". Der Vortrag ist ein Plädoyer für die bewusste Entscheidung statt den Reflex.


02. Ein kurzer Blick in die Vergangenheit

Warum wurden SPAs zum Standard?

SPAs sind keine reine Modeerscheinung:

Folge: SPAs wurden auch zum Standard für reine Content-Seiten.

Die technischen Kosten

Lässt sich das messen? — Core Web Vitals

Metrik Zielwert Was sie misst
LCP (Largest Contentful Paint) < 2,5 s Ladezeitpunkt des größten Content-Elements — bei CSR-SPAs abhängig von Bundle laden/parsen/ausführen plus Datenladen; jede Stufe addiert sich
INP (Interaction to Next Paint) < 200 ms Reaktionsgeschwindigkeit auf Klicks/Eingaben — leidet bei blockiertem Hauptthread
CLS (Cumulative Layout Shift) < 0,1 Visuelle Stabilität: verschieben sich Elemente nach dem ersten Render?

Alle drei sind Google-definierte UX-Metriken und fließen ins Suchranking ein — also nicht nur UX-, sondern auch Business-relevant.

Warum interessiert uns das überhaupt?

Success Stories von Rakuten, BBC, Pinterest und Renault als Beleg:


03. Die Baseline-App: Rezeptbuch

Funktionsumfang

Stack: React 19 + React Router 7 · Vite-Build · ein JS-Bundle für die komplette App.

Die Feature-Auswahl deckt bewusst das Spektrum ab: von „keine Interaktivität" bis „einfache lokale Interaktivität ohne geteilten State" — typisch für content-lastige Websites.

Technischer Aufbau

Routing (App.tsx) — alle Routen aus einer Komponente/einem Bundle; der Server liefert immer dasselbe minimale HTML, der Router entscheidet im Browser:

export default function App() {
  return (
    <RecipesProvider>
      <Routes>
        <Route element={<Layout />}>
          <Route index element={<Home />} />
          <Route path="rezepte" element={<Recipes />} />
          <Route path="rezepte/:slug" element={<RecipeDetail />} />
          <Route path="ueber-uns" element={<About />} />
          <Route path="*" element={<NotFound />} />
        </Route>
      </Routes>
    </RecipesProvider>
  );
}

Wichtig für später: Jede Seite — auch die simple Über-uns-Seite — lädt exakt dasselbe Bundle wie die interaktivste Seite. Es gibt kein Konzept von „weniger JS pro Seite".

Datenladen (context/RecipesContext.tsx) — die Rezeptdaten sind bewusst nicht ins Bundle gebündelt, sondern werden via fetch() aus einer statischen recipes.json zur Laufzeit geladen, inkl. echtem Ladezustand über React Context. Das ist realistisch: bei echten Daten aus DB/CMS könnte man ohnehin nicht statisch importieren.

Weitere Bausteine: pages/RecipeDetail.tsx (Portionsrechner via useState + useMemo, Kochmodus-Checkliste via Set<number>), hooks/useFavorites.ts (useSyncExternalStore über einem localStorage-Store).

Die Baseline-Messwerte

Metrik recipe-react (SPA)
Build-Output gesamt (dist/) 284,7 KB
LCP (Rezepteübersicht) 146 ms
übertragene Datenmenge (Rezepteübersicht) 89,8 KB
LCP („Über uns") 68 ms
übertragene Datenmenge („Über uns") 89,8 KB

Hinweise zur Messung: Build-Output inkl. recipes.json, ohne Fotos; Messungen ohne Netzwerklatenz gegen einen lokalen Server auf leistungsstarkem Rechner; Übertragungsgrößen ebenfalls ohne Fotos.

Auffällig: Die Übertragungsgröße ist auf beiden Seiten identisch — die textlastige Über-uns-Seite lädt genauso viel wie die Rezepteübersicht.


04. Astros Philosophie

„Ship Zero JavaScript by Default"

Islands Architecture

Eine Seite besteht aus statischen Bereichen (Sidebar, Hauptinhalt, Footer) und einzelnen interaktiven Inseln (z. B. Header, Karussell). JS wird nur für die Inseln geladen — wahlweise initial mit dem Rest der Seite oder erst, wenn das Element sichtbar wird.

Ähnliche Content-first-Ansätze

Framework Ansatz
Qwik Resumability — serialisiert State ins HTML, setzt Ausführung im Client fort, ganz ohne Hydration-Schritt
htmx HTML-Attribute statt Komponenten — hx-get/hx-post laden Server-HTML-Fragmente nach, kein virtueller DOM
Next.js Partial Prerendering / React Server Components — Server Components laufen nur serverseitig
Astro unser Beispiel heute

Astro wurde als zugängliches Beispiel für ein größeres Denkmuster gewählt — nicht als einzig richtige Antwort.

Weitere Astro-Features

Scoped CSS · Content Collections · optimierte Bilder · Einbindung von JS-Framework-Komponenten · i18n · View Transitions

Anatomie einer .astro-Datei

Die .astro-Syntax ist eine Übermenge von HTML — reines HTML ist bereits eine gültige Astro-Komponente. Eine Datei hat zwei Zonen: das Frontmatter oben zwischen drei Bindestrichen (JS/TS: Imports, Typen, Props) und darunter das Template.

---
const names = ["John", "Paul"];
---
<main>
  <h1>Rezepte</h1>
  <ul>
    {names.map(name => <li>{name}</li>)}
  </ul>
</main>

Das Frontmatter läuft zur Build-Zeit (nie im Browser); es entsteht statisches HTML mit <li>John</li><li>Paul</li>.

Unterschiede zu JSX: normales class statt className, kein key-Prop bei Listen nötig, kein umschließendes Fragment nötig (mehrere Root-Elemente erlaubt). Bedingtes Rendern funktioniert wie in JSX ({condition && <div>…</div>}).

Komponenten, Slots, Props: Komponenten werden wie normale HTML-Tags verwendet, Props als Attribute; <slot /> markiert die Stelle für Kind-Inhalt (Pendant zu {children} in React); Astro.props enthält die übergebenen Props, typisiert über ein type Props.


05. Der Astro-Nachbau (SSG)

Dieselbe App — gleiche Seiten, Features und Daten — noch einmal mit Astro gebaut. Einziger Unterschied ist die Architektur (plus eine zusätzliche englische Version). Wichtig für die Benchmarks: Astro läuft hier im rein statischen Modus (SSG, kein Server zur Laufzeit).

File-based Routing

Die Ordnerstruktur unter src/pages/ definiert die Routen. Bei vielen Sprachen lässt sich die Struktur auch dynamisch in Bezug auf Sprachen aufbauen.

Pages und Layout

---
import BaseLayout from '../../layouts/BaseLayout.astro';
import AboutView from '../../components/views/AboutView.astro';
import { useTranslations } from '../../i18n/utils';

const lang = 'de' as const;
const t = useTranslations(lang);
---

<BaseLayout lang={lang} title="Über uns" description={t('about.p1')}>
  <AboutView lang={lang} />
</BaseLayout>

BaseLayout.astro ist eine normale Astro-Komponente mit <slot />, die den Kind-Inhalt in das Grundlayout (<head>, Header, <main>, globale Styles) rendert.

Scoped CSS

CSS, das nicht explizit als is:global markiert ist, wird lokal gescoped und leakt nicht in andere Komponenten. Astro hängt dafür beim Build ein data-astro-cid-*-Attribut an die Elemente und schreibt die Selektoren entsprechend um:

<div class="page" data-astro-cid-gtkanwr>
  <h1 data-astro-cid-gtkanwr>Über uns</h1>
</div>
<style>
.page[data-astro-cid-gtkanwr] { max-width: 640px; }
</style>

i18n

Alle UI-sichtbaren Strings liegen in i18n/ui.ts (de/en), eine Hilfsfunktion useTranslations(lang) gibt die Labels der gewünschten Sprache zurück, mit Fallback auf de.

Content Collections

Typsichere Definition der Inhalte mit Zod-Schema in content.config.ts:

const recipes = defineCollection({
  loader: glob({ pattern: "**/*.yaml", base: "./src/content/recipes" }),
  schema: z.object({
    title: z.object({ de: z.string(), en: z.string() }),
    category: z.enum(["Vegetarisch", "Fleisch", "Dessert", "Vegan"]),
    cookTime: z.number().positive(),
    difficulty: z.number().int().min(1).max(5),
    servings: z.number().int().positive(),
    featured: z.boolean().default(false),
    image: z.string(),
    ingredients: z.array(z.object({
      name: z.string(), amount: z.number().positive(), unit: z.string(),
    })),
    steps: z.array(z.string()),
  }),
});

Die Rezepte selbst liegen als YAML-Dateien unter src/content/recipes/. Bei Schema-Verstoß (z. B. fehlende en-Übersetzung) schlägt der Build fehl — mit klarer Fehlermeldung samt Feld und Datei.

Vergleich: Bei klassischem JSON ohne Validierung (React-Baseline) fällt derselbe Fehler erst zur Laufzeit auf — als leere Übersetzung oder undefined, eventuell erst, wenn ein Nutzer sich beschwert.

Abgerufen wird die Collection im Frontmatter per await getCollection('recipes').

Dynamische Generierung von Seiten

---
export async function getStaticPaths() {
  const recipes = await getCollection("recipes");
  return recipes.map((recipe) => ({
    params: { slug: recipe.id },
    props: { recipe },
  }));
}

const { recipe } = Astro.props;
---

<BaseLayout title={`${recipe.data.title.de} - Rezeptbuch`} …>
  <RecipeDetailView lang={lang} recipe={recipe} />
</BaseLayout>

Die exportierte getStaticPaths() gibt die Liste der Seitenkonfigurationen zurück: Pfad-Parameter für den dynamischen Pfad, Props für den Seiteninhalt. Aus 8 YAML-Dateien werden so 8 vorgerenderte HTML-Seiten pro Sprache.

Interaktivität — ohne Framework

1. Kochmodus-Checkliste — das simpelste der vier Features, das Skript steht inline in der View-Komponente. Fünf Zeilen JS reichen:

document
  .querySelectorAll('.step-list input[type="checkbox"]')
  .forEach((checkbox) => {
    checkbox.addEventListener("change", () => {
      checkbox.closest("li")?.classList.toggle("done", checkbox.checked);
    });
  });

Das zeigt den Kern der „Zero JS by default"-Idee am deutlichsten: keine Komponenten-Architektur, kein State-Container — einfach ein Skript dort, wo es gebraucht wird.

2. Portionsrechner — eigene Komponente PortionCalculator.astro mit einem ausgelagerten Skript. data-Attribute sind der Weg, Variablen aus dem Build-Time-Frontmatter in die Runtime-Umgebung zu bekommen:

<div class="portion-calculator" data-base-servings={servings}>
  …
  <span class="amount" data-amount={ing.amount} data-unit={ing.unit}>
    {ing.amount} {ing.unit}
  </span>
</div>

<script>
  import '../scripts/portion-calculator';
</script>

Die Kernlogik in scripts/portion-calculator.ts skaliert die Mengen und schreibt sie per direkter DOM-Manipulation zurück — kein virtueller DOM, kein Neu-Rendern.

3. Favoriten-Toggle — liest und schreibt direkt in localStorage, kein globales State-Management (Context/Redux) nötig. Jede Button-Instanz synchronisiert unabhängig ihren Zustand über eine sync()-Funktion; mehrere Instanzen auf einer Seite sind völlig unabhängig voneinander.

4. Such- und Filterleiste — funktioniert analog: direkte DOM-Manipulation, um Rezeptkarten ein- bzw. auszublenden. Die Karten selbst werden von der Insel nicht neu gerendert — RecipesView.astro reicht die fertig gerenderten RecipeCards als Kind-Inhalt (Slot) an SearchFilter durch.

Zwischenfazit

Interaktivität geht auch ohne Framework!

Benchmark-Vergleich

Metrik recipe-react (SPA) recipe-astro
Build-Output gesamt (dist/) 284,7 KB 260,6 KB
LCP (Rezepteübersicht) 146 ms 93 ms
übertragene Datenmenge (Rezepteübersicht) 89,8 KB 5,9 KB
LCP („Über uns") 68 ms 38 ms
übertragene Datenmenge („Über uns") 89,8 KB 3,5 KB

06. Vom Client zum Server

„Zero JS by default" heißt nicht Verzicht auf dynamische Anforderungen. Astro kann mehr als nur statisch: SSR, Server Islands, Actions, Endpoints.

Serveradapter

Konzeptionell ist es der Umstieg von output: 'static' auf output: 'server' — aber kein reiner Konfigurationsschalter: Es ist zwingend ein Deployment-Adapter nötig (offiziell für Node, Vercel, Cloudflare, Netlify).

export default defineConfig({
  output: "server",
  adapter: node({ mode: "standalone" }),
  i18n: { defaultLocale: "de", locales: ["de", "en"], … },
});

mode: 'standalone' startet einen eigenständigen Node-Prozess, statt sich als Middleware einzuklinken. Folge: reines statisches Hosting reicht nicht mehr — eine laufende Serverinstanz oder Edge-Function ist nötig, mit Implikationen für Pipeline, Kosten und Skalierung.

Wichtig: output: 'server' heißt nicht „jetzt ist alles dynamisch". Pro Seite ist das explizit wählbar:

---
export const prerender = true;
---

Im Beispielprojekt bleiben Rezept-Detailseiten, Übersicht, Startseite und Über-uns komplett vorgerendert — nur die wirklich dynamischen Teile werden on-demand gerendert.

Actions

Typisierte, serverseitige Funktionen, die sich wie normale JS-Funktionen aufrufen lassen. Alternative zu einem rohen POST-Endpoint: Input-Validierung (Zod) und Server-Handler stecken in einer typisierten Funktion.

export const server = {
  rateRecipe: defineAction({
    accept: "form",
    input: z.object({
      slug: z.string(),
      rating: z.coerce.number().int().min(1).max(5),
    }),
    handler: async ({ slug, rating }) => {
      addRating(slug, rating);
      return getRating(slug);
    },
  }),
};

accept: 'form' erlaubt ein klassisches Formular (FormData) als Input; der Handler läuft ausschließlich auf dem Server, der Rückgabewert kommt typisiert beim Aufrufer an:

const { data, error } = await actions.rateRecipe(new FormData(form));

Server Islands

Dynamische, on-demand gerenderte Inseln. server:defer rendert eine Komponente weiterhin serverseitig, entkoppelt aber ihre Auslieferung vom Rest der Seite:

<ServerHighlights server:defer lang={lang} currentSlug={recipe.id}>
  <p slot="fallback">{t("server.loading")}</p>
</ServerHighlights>

Astro rendert einen Platzhalter ins statische HTML; die eigentliche Komponente wird per separatem Request nachgeladen. Dadurch bleiben TTFB und LCP der Hauptseite unabhängig davon, wie lange die Insel für ihre Berechnung braucht.

Im Beispiel: „gerade beliebte Rezepte" samt Aufrufzähler und Durchschnittsbewertung — zu häufig wechselnd für Build-Zeit-Rendering. Das Frontmatter der Insel läuft zur Laufzeit statt zur Build-Zeit, sie holt sich ihre Daten selbst (eigener getCollection-Aufruf) und ist damit eine eigenständige, an den Request gebundene Einheit.

SSR

Die komplette Seite wird on-demand gerendert (im Beispiel: die Statistik-Seite mit export const prerender = false) — sinnvoll für Inhalte, die sich zu häufig ändern oder zu individuell sind, um vorab gebaut zu werden: Dashboards, personalisierte Seiten, Live-Daten.

„Herzlichen Glückwunsch, wir haben PHP neu erfunden." Keine reine Wiederholung — Islands sind gezielter und granularer als klassisches PHP-Rendering. Aber die Grundrichtung „zurück zum Server" stimmt.

Use Cases für Server-Funktionen

Botschaft: gezielt dynamisch nur für die Teile, die es brauchen — nicht die ganze App. Vorteil gegenüber „alles ist SPA": inkrementell genau so viel Dynamik hinzufügen wie nötig, das hält den ausgelieferten Code minimal.


07. Fazit

Wann SPAs die bessere Wahl sind

→ Komplexe, hochinteraktive Anwendungen mit viel Client-State z. B. Dashboards, Editoren, Tools mit dauerhaftem „App-Charakter"

Das ist keine Ausnahme von der Kernthese, sondern ihre logische Konsequenz: Bei interaktiven Tools ist „sehr viel JS" die ehrliche Antwort — dann ist die SPA konsequent richtig und keine schlechte Gewohnheit. Komplexe JS-Logik ist mit einem Framework meist einfacher zu entwickeln als in reinem JS/TS.

Ein Fragenkatalog für euer nächstes Projekt

  1. Wie viel der Seite ist tatsächlich interaktiv vs. nur reiner Content? Marketing-Seite, Blog, Doku, Rezepte-App = überwiegend Content → Islands-/Content-first-Ansatz. Grafik-Editor, PM-Board, Dashboard = fast alles interaktiv → klassische SPA.
  2. Wird clientseitiger Zustand benötigt, der über viele Komponenten hinweg geteilt und synchron gehalten werden muss? Islands sind bewusst isoliert — Stärke bei einfachen lokalen Interaktionen, Schwäche bei viel geteiltem State (Warenkorb-Icon, Formular mit Cross-Feld-Validierung). Lösbar auch mit Islands (Astro empfiehlt Nanostores), aber ab einem gewissen Komplexitätsgrad ist eine SPA mit eingebautem State-Management oft pragmatischer.
  3. Ist SEO bzw. sind die Core Web Vitals ein kritischer Faktor? Öffentliche Inhalte (Marketing, Blog, Produktseiten, Doku): fast immer „ja". Interne Tools, Admin, login-geschützt: SEO irrelevant, andere Kriterien entscheiden.
  4. Besteht bereits eine erhebliche Investition in ein bestehendes SPA-Setup? Team-Know-how, gewachsene Codebasis und Tooling-Integrationen haben realen Wert und reale Umstellungskosten. Ein technisch „besseres" Paradigma rechtfertigt nicht automatisch eine Neuentwicklung. Junges Projekt + starke Antworten auf Fragen 1–3 → Architektur-Vorteil überwiegt; etabliertes Produkt mit eingespieltem Team → oft anders.

Der Fragenkatalog ist bewusst tool-unabhängig formuliert. Die wichtigste Botschaft: nicht ein Tool gegen ein anderes tauschen, sondern den Entscheidungsprozess selbst bewusster gestalten.

TL;DR

Die entscheidende Frage ist nicht „Welches Framework?", sondern „Wie viel JavaScript braucht diese Seite tatsächlich?"

Thema Link
Slides & Code (Monorepo) https://github.com/VascoSchwarze/do-you-really-need-an-spa
Benchmark-Details & Messmethodik COMPARISON.md im Repo
Beispiel: React-SPA examples/recipe-react
Beispiel: Astro (SSG) examples/recipe-astro
Beispiel: Astro mit Node-Adapter (SSR, Server Islands, Actions) examples/recipe-astro-server
Astro https://astro.build
„JS-Bloat in 2024", Nikita Prokopov https://tonsky.me/blog/js-bloat/
„Second-guessing the modern web", Tom MacWright https://macwright.com/2020/05/10/spa-fatigue
Just Fucking Use HTML https://justfuckingusehtml.com
Sprecher vasco.schwarze@sciota.io · GitHub: VascoSchwarze