React SEO: So werden Single-Page-Apps indexierbar

React SEO: So werden Single-Page-Apps indexierbar

Eine React-Anwendung, die mit Vite oder Create React App aufgesetzt wurde, liefert im ersten HTTP-Response praktisch nur ein leeres div mit der id root. Der komplette Inhalt entsteht erst, nachdem der Browser das JavaScript-Bundle geladen und ausgeführt hat. Für Menschen ist das unsichtbar, weil der Browser das in Millisekunden erledigt. Für Suchmaschinen ist genau das der Grund, warum plain React SPAs regelmäßig entweder gar nicht, nur teilweise oder erst mit tagelanger Verzögerung indexiert werden.

Dieser Artikel geht nicht auf Rendering-Konzepte allgemein ein und auch nicht auf Next.js, Nuxt oder Vue - das sind eigene Baustellen mit eigenen Lösungen. Es geht konkret um eine mit react-router aufgebaute Client-Side-App: was beim Rendering technisch passiert, wann Prerendering reicht und wann eine SSR-Migration nötig wird, wie Title, Meta-Description und Canonical pro Route gesetzt werden, und wie man vermeidet, dass jede unbekannte Route als Soft-404 endet.

Warum Googlebot bei React-Apps ins Stolpern kommt

Googlebot crawlt in zwei Wellen. In der ersten Welle wird die rohe HTML-Antwort geholt und ausgewertet: Links, Statuscode, grundlegende Metadaten. Bei einer plain React SPA ist das genau jenes leere div, plus ein script-Tag, das auf das Bundle verweist. Erst in einer zweiten Welle, die je nach Crawl-Budget Stunden oder mehrere Tage später folgen kann, rendert der Web Rendering Service - im Kern ein aktueller, headless Chromium - die Seite so, wie ein Browser es täte, und schickt das Ergebnis erneut durch die Indexierung. Bis diese zweite Welle durch ist, sieht Google im Zweifel: nichts.

Das Problem betrifft nicht nur Google. Bing rendert JavaScript ebenfalls, aber mit einem deutlich kleineren Budget, und viele SEO-Tools sowie Social-Media-Crawler von Facebook, LinkedIn oder Twitter/X führen überhaupt kein JavaScript aus. Wer sich beim Teilen eines Links wundert, warum die Vorschau leer ist oder ein generisches Bild zeigt, hat exakt dieses Problem: Der Crawler des sozialen Netzwerks liest nur die rohe HTML-Antwort, nicht das gerenderte Ergebnis. Deshalb reicht die Annahme "es steht ja im DOM, sobald React fertig ist" für React SEO nicht aus - entscheidend ist, was im initialen Response steht.

  • Erste Welle: Crawler liest reines HTML, ohne JavaScript-Ausführung.
  • Zweite Welle: Web Rendering Service führt das Bundle aus, oft mit Verzögerung von Stunden bis Tagen.
  • Andere Bots wie Social-Media-Crawler und viele SEO-Tools führen gar kein JavaScript aus.

Prerendering: statische Schnappschüsse für Bots und Nutzer

Prerendering erzeugt zur Build-Zeit für jede Route eine fertige HTML-Datei mit dem kompletten gerenderten Inhalt, exakt so, wie er nach Ausführung des JavaScripts aussehen würde. Der Crawler bekommt beim ersten Request bereits vollständiges Markup, ganz ohne auf eine zweite Rendering-Welle warten zu müssen. Für eine überwiegend statische Marketing-Seite - Startseite, Produktseiten, Blog, Preise - ist das der pragmatischste Einstieg, weil sich am restlichen Setup nichts ändert: Die App bleibt eine plain React SPA, es kommt nur ein Build-Schritt hinzu, der jede Route einmal durchrendert und als HTML-Datei ablegt.

react-snap, das früher der Standardweg war, wird nicht mehr gepflegt und sollte für neue Projekte nicht mehr gewählt werden. Praktikabler sind heute Vite-Plugins, die Puppeteer oder Playwright im Build headless starten, jede Route in der Routenliste aufrufen und das gerenderte DOM als statische Datei speichern, oder ein Wechsel auf ein Meta-Framework mit eingebauter SSG-Option ausschließlich für die öffentlichen Marketing-Routen.

Beim Ausliefern der Snapshots entscheidet die Hosting-Konfiguration, ob der Aufwand überhaupt ankommt. Ein statischer Host muss /produkte auf produkte.html (oder produkte/index.html) auflösen, nicht per Client-Redirect auf die leere SPA-Shell zurückfallen - sonst bekommt der Crawler trotz Prerendering wieder nur das leere Grundgerüst. Rewrite-Regeln, die konsequent zwischen Trailing-Slash- und Non-Slash-Varianten wählen und diese Wahl mit dem Canonical-Tag abstimmen, verhindern, dass am Ende zwei URLs für dieselbe Route indexiert werden. Ebenso wichtig: Der Build-Schritt muss bei jedem Deploy erneut laufen, sonst weichen die ausgelieferten Snapshots zunehmend vom aktuellen Stand der App ab, ohne dass das im laufenden Betrieb auffällt.

Wichtig ist außerdem der Unterschied zu Dynamic Rendering - dem alten Ansatz, Bots eine vorgerenderte Version und Nutzern die normale SPA auszuliefern, je nach User-Agent. Google hat das früher als Übergangslösung empfohlen, führt es inzwischen aber nur noch als Workaround für Suchmaschinen, die selbst kein JavaScript rendern, und rät nicht mehr dazu, es als dauerhafte SEO-Strategie einzuplanen - schon weil User-Agent-Weichen fehleranfällig sind und als Cloaking fehlinterpretiert werden können, sobald sich Inhalt und Statuscode zwischen den Versionen unterscheiden.

  • Prerendering passt für Inhalte, die sich selten ändern, etwa Marketing-Seiten, Dokumentation oder Blog-Artikel.
  • Dynamic Rendering per User-Agent-Weiche gilt nicht mehr als empfohlene Dauerlösung, nur noch als Workaround für Bots ohne JS-Rendering.
  • Der Prerendering-Schritt gehört in die CI-Pipeline, nicht in einen manuellen Einmal-Export - sonst veralten die Snapshots.

SSR-Migration: wann sich der Aufwand lohnt

Prerendering stößt an seine Grenze, sobald Inhalt personalisiert, session-abhängig oder zu häufig aktualisiert ist, um bei jedem Deploy neu gebaut zu werden - etwa eine Produktsuche mit Filtern, ein Preisvergleich mit Live-Daten oder ein Marktplatz mit ständig neuen Angeboten. Für solche Fälle liefert Server-Side Rendering bei jedem Request frisch gerendertes HTML, statt einen Snapshot vom letzten Build auszuliefern. Der Unterschied ist relevant für die Entscheidung: Prerendering baut einmal, SSR rendert bei jedem Aufruf neu und kostet damit laufend Serverzeit statt nur Build-Zeit.

Eine SSR-Migration bedeutet in der Praxis fast immer den Wechsel zu einem Framework wie Next.js oder Remix, weil plain React ohne Framework kein Routing auf Serverseite kennt und eine eigene SSR-Pipeline aus Datenabruf, Hydration und Fehlerbehandlung selbst zu bauen deutlich mehr Aufwand ist, als es zunächst wirkt. Es gibt einen Zwischenweg, der bei kleineren Teams oft übersehen wird: Vite bringt einen eigenen SSR-Modus mit, der sich vor eine bestehende react-router-App setzen lässt, ohne das restliche Setup auf ein volles Framework umzustellen - dafür übernimmt man selbst, was ein Framework sonst mitliefert, etwa das Auflösen von Daten pro Route vor dem Rendern und das saubere Zusammenspiel mit der Hydration im Browser. Das lohnt sich, wenn nur eine Handvoll Routen SSR brauchen und eine volle Framework-Migration unverhältnismäßig wäre.

Die Entscheidung lohnt sich nicht pauschal für die ganze App: Ein eingeloggter Dashboard-Bereich hinter Login muss nicht crawlbar sein und profitiert von SSR höchstens bei der Ladezeit, nicht bei SEO. Sinnvoll ist eine harte Trennung - öffentliche, indexierbare Routen wie Landingpages, Kategorie- und Produktseiten oder der Blog bekommen Prerendering oder SSR, alles hinter Auth bleibt eine reine Client-Side-SPA, wie sie ist. Wer nur wegen zwanzig öffentlicher Marketing-Seiten die komplette App migriert, löst ein kleines Problem mit einem großen Umbau.

Titel, Meta-Description und Canonical pro Route setzen

Eine SPA hat technisch ein einziges HTML-Dokument, aber jede Route braucht ihren eigenen Title, ihre eigene Meta-Description und, wo relevant, ihren eigenen Canonical-Link. Mit React ab Version 19 lässt sich das direkt in der Komponente lösen: title-, meta- und link-Tags können einfach im JSX einer Routen-Komponente gerendert werden, React hebt sie automatisch in den head-Bereich des Dokuments. Für ältere React-Versionen oder wenn mehrere Komponenten auf verschiedenen Ebenen gleichzeitig Metadaten setzen wollen, ist react-helmet-async die verlässlichere Wahl - das ursprüngliche react-helmet wird nicht mehr aktiv gepflegt, react-helmet-async ist der Fork, der auch mit konkurrentem Rendering und SSR funktioniert.

Der Canonical-Link ist bei react-router-Apps besonders leicht zu vergessen, weil Client-Side-Routing Query-Parameter oft ohne Seitenreload verändert - eine Produktliste unter /produkte, die sich per Filter zu /produkte?sortierung=preis&farbe=blau ändert, ist im Router dieselbe Komponente, für den Crawler aber eine andere URL mit potenziell dünnerem, dupliziertem Inhalt. Ohne expliziten Canonical, der auf die kanonische Basis-URL verweist, entsteht bei jeder Filterkombination eine neue indexierbare Variante. Der Canonical muss außerdem eine vollständige, absolute URL sein, inklusive Protokoll und Domain, konsistent mit oder ohne abschließendem Slash - eine Abweichung zwischen der tatsächlich aufgerufenen URL und dem gesetzten Canonical wird von Google zwar als Signal gewertet, aber nicht immer wie beabsichtigt übernommen.

  • React ab Version 19: title-, meta- und link-Tags direkt in der Routen-Komponente rendern, kein zusätzliches Package nötig.
  • Ältere Versionen oder verschachtelte Routen: react-helmet-async statt des nicht mehr gepflegten react-helmet.
  • Canonical immer als vollständige, absolute URL, konsistent bei Slash und Query-Parametern.

Soft-404-Routen vermeiden

react-router braucht eine Catch-all-Route, meist unter dem Pfad Stern, die eine Nicht-gefunden-Komponente rendert, wenn keine definierte Route passt. Das Problem: Bei einer reinen Client-Side-SPA, die statisch von einem CDN oder über eine einfache Regel ausgeliefert wird, die jede Anfrage auf index.html umleitet, kennt der Server die react-router-Routen gar nicht. Jede beliebige, auch falsch geschriebene URL bekommt vom Server denselben Statuscode 200 und dieselbe index.html - React übernimmt danach im Browser und zeigt die Nicht-gefunden-Seite. Aus Sicht des Crawlers ist das ein klassischer Soft 404: Der Inhalt sagt "nicht gefunden", der Statuscode sagt "alles in Ordnung", und Google stuft solche URLs entsprechend als Soft-404 ein, statt sie sauber aus dem Index zu entfernen.

Die korrekte Lösung ist ein echter Statuscode. Läuft die App per SSR oder über eine Edge-Function, kann der Server für nicht erkannte Routen tatsächlich 404 oder, bei dauerhaft entfernten Inhalten, 410 zurückgeben, bevor React überhaupt rendert. Bei einer rein statischen SPA ohne Server-Logik ist das nicht möglich - hier ist die zweitbeste Option, der Nicht-gefunden-Komponente per Meta-Tag ein noindex mitzugeben, sobald sie gerendert wird. Der Statuscode bleibt zwar 200, aber Google indexiert den Inhalt dann nicht als eigenständige, dünne Seite. Wer weder den Statuscode korrigieren noch noindex setzen kann, sollte zumindest verhindern, dass solche URLs überhaupt intern verlinkt werden oder in der Sitemap landen - sonst sammeln sich Soft-404s an, die die Crawl-Effizienz der ganzen Domain belasten.

Ob eine Route tatsächlich als Soft-404 landet, lässt sich vorab prüfen, statt es zu vermuten: Ein curl-Aufruf mit dem User-Agent von Googlebot gegen eine unbekannte URL zeigt den rohen Statuscode, den der Server tatsächlich sendet, unabhängig davon, was React im Browser anschließend rendert. Die URL-Prüfung in der Search Console geht noch einen Schritt weiter und zeigt sowohl den von Google beobachteten Statuscode als auch das gerenderte HTML nach der zweiten Rendering-Welle - der zuverlässigste Weg, ein Soft-404-Problem an einer plain React SPA zu bestätigen, statt sich auf Annahmen über das eigene Routing zu verlassen.

Sitemap und interne Links für Client-Side-Routing

Links, die mit der react-router Link-Komponente gesetzt werden, rendern im DOM ein echtes a-Element mit href-Attribut - Googlebot kann sie wie jeden anderen Link finden und ihnen folgen, auch wenn der eigentliche Seitenwechsel per JavaScript und History-API ohne Reload passiert. Riskant wird es, wenn Navigation stattdessen über ein Element mit Klick-Handler und programmatischem navigate-Aufruf gebaut wird, ohne dass ein href im Markup steht. Visuell und funktional für Nutzer identisch, für den Crawler ist so ein Element schlicht kein Link, sondern anklickbarer Text - die Zielroute wird dadurch nicht entdeckt, sofern sie nicht anderswo verlinkt ist.

Für die Sitemap gilt: Bei statischen oder prerenderten Routen lässt sich sitemap.xml direkt aus der react-router-Routendefinition generieren, per Build-Skript, das die Routenliste durchläuft und daraus URLs baut - so bleiben Sitemap und tatsächliche Routing-Struktur automatisch synchron, ohne händisch gepflegte Liste, die veraltet. Für datengetriebene Routen, etwa einzelne Produkt- oder Artikelseiten mit dynamischem Parameter, muss die Sitemap dagegen aus der Datenquelle selbst erzeugt werden - aus der Datenbank oder dem CMS, nicht durch Durchklicken der SPA -, weil sich die konkreten IDs oder Slugs nicht aus dem Routing-Schema ableiten lassen.

  • Interne Navigation, die crawlbar sein soll, immer über Link statt über Klick-Handler mit navigate() bauen.
  • Sitemap für statische Routen aus der Routendefinition generieren, für dynamische Routen aus der Datenquelle.
  • Sitemap bei jedem Build neu erzeugen, nicht als einmalig exportierte Datei pflegen.

Typische Fehler beim React-SEO-Setup

Die folgenden Fehler tauchen in React-Projekten immer wieder auf, unabhängig davon, ob Prerendering oder SSR im Einsatz ist:

  • robots.txt blockiert versehentlich das JS-Bundle oder den kompletten Asset-Ordner - dann kann der Web Rendering Service gar nicht rendern, egal wie sauber die Routen sonst aufgesetzt sind.
  • OpenGraph- und Twitter-Card-Tags werden nur clientseitig gesetzt und funktionieren deshalb nie beim Teilen in sozialen Netzwerken, weil deren Crawler kein JavaScript ausführen - dafür braucht es Prerendering oder SSR, unabhängig von der Frage, wie Google die Seite indexiert.
  • Der document-Titel wird per useEffect aktualisiert, die Meta-Description dabei aber vergessen - beide gehören pro Route zusammen gesetzt, nicht nur der sichtbare Tab-Titel.
  • Create React App wird für neue Projekte weiterverwendet, obwohl das React-Team CRA offiziell nicht mehr als Standardweg empfiehlt und auf Vite oder ein Framework verweist - wer sowieso migriert, sollte SEO-Setup und Build-Tooling in einem Schritt erneuern statt zweimal umzubauen.
  • Änderungen an Title oder Canonical werden nicht über die URL-Prüfung in der Search Console kontrolliert - weil Google eine SPA oft erst in der zweiten Rendering-Welle sieht, zeigt die Live-Prüfung zuverlässiger, was tatsächlich ankommt, als eine Annahme über das, was der Code eigentlich tun sollte.

Häufige Fragen

Reicht es, wenn ich nur den document.title per JavaScript ändere?

Nein, das aktualisiert nur den Browser-Tab und beeinflusst weder die Meta-Description noch das, was Social-Media-Crawler beim Teilen sehen, da diese kein JavaScript ausführen. Für Google zählt zusätzlich, wann die zweite Rendering-Welle den geänderten Titel überhaupt erfasst - bis dahin sieht der Index eventuell noch den alten oder gar keinen Titel. Für Title, Meta-Description und Canonical pro Route ist deshalb ein systematischer Ansatz wie react-helmet-async oder die nativen Head-Tags aus React 19 nötig, keine punktuelle Änderung per useEffect.

Muss ich meine ganze React-App auf Next.js umstellen, um crawlbar zu sein?

Nicht zwingend. Prerendering reicht für überwiegend statische, öffentliche Inhalte wie Marketing-Seiten oder Blog-Artikel oft vollständig aus und lässt das restliche Setup unverändert. Eine SSR-Migration lohnt sich erst, wenn Inhalte zu häufig wechseln oder zu personalisiert sind, um sie bei jedem Build vorab zu rendern - und selbst dann kann sie sich auf die öffentlichen Routen beschränken, während ein Login-Bereich eine reine Client-Side-SPA bleibt.

Warum zeigt die Search Console eine andere Seite an, als ich erwarte?

Weil Google eine plain React SPA in zwei Schritten verarbeitet: zuerst das rohe HTML, dann - mit Verzögerung - das gerenderte Ergebnis nach Ausführung des JavaScripts. Die URL-Prüfung zeigt genau diesen gerenderten Zustand nach der zweiten Welle, der sich vom aktuellen Code-Stand unterscheiden kann, wenn seitdem ein Deploy passiert ist oder das Prerendering nicht aktuell lief.

Wie verhindere ich, dass Filterkombinationen als Duplicate Content indexiert werden?

Über einen Canonical-Link, der bei jeder Query-Parameter-Variante konsequent auf die kanonische Basis-URL der Route zeigt, statt auf die aktuell aufgerufene URL mit allen Filtern. Ergänzend hilft es, wirklich dünne Filterkombinationen ohne relevante eigene Nachfrage gar nicht erst intern zu verlinken oder in die Sitemap aufzunehmen.

Alle Artikel