v2.0.0 — Der komplette Rewrite auf Next.js 15 & React 19
Warum wir die App-Router-Migration gewagt haben, was wir aus 18 Monaten Pages-Router gelernt haben, und welche Architekturentscheidungen wir nicht bereut haben.
Mit v2.0.0 haben wir das gesamte Frontend neu geschrieben: vom Pages-Router auf den App Router, von React 18 auf React 19, von getServerSideProps auf RSC-Streaming. Dieser Artikel dokumentiert, warum wir den Schritt gewagt haben, was schief ging, und welche Entscheidungen sich im Nachhinein als richtig herausgestellt haben.
Warum ein Rewrite?
Unser v1.x-Stack lief stabil — aber die App war an einem Punkt, an dem jede neue Feature-Flag-Auslieferung mit „hätten wir das nicht im App Router einfacher?" begann. Konkret die Reibungspunkte:
- Daten-Layer war fragmentiert: getServerSideProps, Client-SWR, REST-Refetch — drei Wege für drei Use-Cases.
- Layouts ließen sich nur über _app.tsx global definieren; pro-Route-Layouts waren ein Hack mit Wrapper-Komponenten.
- Streaming + Suspense war im Pages-Router ein Workaround, im App Router ein First-Class-Feature.
- Middleware lief am Rand der App, aber ohne echten Edge-Support für Auth-Header.
Die ersten drei Punkte waren Produktivitäts-Reibung. Der vierte Punkt — Auth an der Edge — war der eigentliche Auslöser: Wir wollten Discord-OAuth-Tokens in der Middleware validieren, bevor irgendein Server-Side-Props-Lauf die Request zu Gesicht bekam.
Rewrites sind nie technisch begründet. Sie sind immer ein Trade-off zwischen aktueller Reibung und Migrations-Kosten. Unser Trade-off: 6 Wochen Vollzeit-Rewrite gegen 6 Monate weitere Reibung.
Was wir behalten haben
Der Rewrite war radikal im Frontend, konservativ im Backend. Konkret: API-Endpunkte, Datenbank-Schema, Bot-Architektur — alles unangetastet. Der Rewrite war eine reine Konsumenten-Migration: derselbe API-Vertrag, dieselben Daten, neues Frontend.
Diese Entscheidung war die wichtigste des gesamten Projekts. Ohne sie hätten wir ein halbes Jahr für Datenbank-Migrationen verbrannt, statt uns auf die eigentlichen Frontend-Fragen zu konzentrieren.
Die fünf Architekturentscheidungen
Im Verlauf der Migration haben wir fünf Entscheidungen getroffen, die das Projekt geprägt haben — manche davon kontrovers, manche davon offensichtlich rückblickend:
1. Server Components first
Wir sind konsequent davon ausgegangen, dass jede Komponente eine Server Component ist, bis das Gegenteil bewiesen ist. „use client“ wurde zum Sonderfall, nicht zur Default. Das hat den Client-Bundle um ~40 % reduziert, weil die meisten Komponenten keinen JS-Code an den Browser senden.
Die wenigen Komponenten, die „use client“ brauchen — die Hero-Animationen, der Live-Status-Bar, die FAQ-Accordion-Logik — sind explizit als Client-Komponenten markiert. Das macht den Bundle-Boundary sichtbar.
2. Route Groups für Layout-Varianten
Wir haben drei Layout-Varianten: öffentliche Marketing-Seiten (mit Hero und Footer), Dashboard (mit Sidebar), Auth-Flow (minimal). Statt das in einer _app.tsx mit Conditions zu lösen, nutzen wir Route Groups: (marketing), (dashboard), (auth). Jede Gruppe hat ihr eigenes Layout, ihre eigene Metadata-Default, ihre eigene Loading-Strategie.
Vorteil: Layouts sind deklarativ, nicht konditional. Nachteil: Tiefe Verzeichnisstruktur. Wir haben den Trade-off akzeptiert, weil deklarative Layouts langfristig wartbarer sind.
3. Colocation von Server Actions
Server Actions leben in der gleichen Datei wie die Komponente, die sie aufruft. Statt eine zentrale `app/actions/`-Hierarchie zu pflegen, definieren wir Aktionen dort, wo sie semantisch hingehören. Beispiel: `app/dashboard/servers/[id]/actions.ts` enthält die Server Actions für genau diese Server-Detail-Seite.
Das ist kontrovers — viele Teams schwören auf zentrale Actions. Wir haben mit Colocation angefangen, weil wir gemerkt haben, dass Aktionen fast immer zu genau einer Seite gehören. Wenn eine Aktion von zwei Seiten geteilt wird, wandert sie in `lib/actions/`. Pragmatisch statt dogmatisch.
4. Streaming + Suspense für Daten
Anstatt alle Daten in einem einzigen Server-Component-Lauf zu laden, haben wir jede Datendomäne in eine eigene Suspense-Boundary gekapselt. Das Dashboard lädt die Server-Liste sofort, Mitglieder-Stats in einem zweiten Stream, Aktivitäts-Feed in einem dritten.
Effekt: TTFB bleibt niedrig (weil nur die kritischen Daten den initialen Render blockieren), aber die Seite ist früher interaktiv. Lighthouse-Score: +18 Punkte im Performance-Index, nur durch diese eine Änderung.
5. Edge-Middleware für Auth
Die Discord-OAuth-Token-Validierung läuft jetzt in der Next.js-Middleware am Edge. Vorteil: Ungültige Tokens werden blockiert, bevor irgendein Server-Component-Lauf startet. Das spart sowohl Compute (kein unnötiges Server-Side-Rendering) als auch Latenz (Antwort kommt direkt vom Edge).
Die Middleware ist absichtlich dünn: nur Token-Validierung, keine Authorization. Authorization (kann dieser User diesen Server sehen?) passiert weiter in der Server Component der jeweiligen Seite. Edge für schnelle Filterung, Server für komplexe Logik.
Was schief ging
Es lief nicht alles glatt. Drei Probleme, die uns echte Zeit gekostet haben:
Cache-Staleness bei dynamischen Routen
Der App Router cacht standardmäßig aggressiver als der Pages Router. Wir hatten eine Stunde, in der Server-Einstellungen, die im Dashboard geändert wurden, im öffentlichen Bereich als veraltet angezeigt wurden. Lösung: dynamische Routen explizit mit `export const dynamic = "force-dynamic"` markieren, wo Echtzeit-Daten erwartet werden. Die Liste wird jetzt in den öffentlichen Bereichen ISR-gecacht, im Dashboard ist sie dynamisch.
Server Action Revalidation
`revalidatePath()` und `revalidateTag()` haben eine Lernkurve, die nicht offensichtlich ist. Wir hatten mehrfach den Fall, dass eine Server Action den Cache der falschen Route invalidiert hat. Lösung: ein zentrales `lib/cache-tags.ts`, das Tag-Konstanten exportiert — keine String-Literals verstreut im Code.
Hydration Mismatch bei Animationen
Framer Motion + Server Components + LazyMount: eine Kombination, die Hydration-Mismatches geradezu einlädt. Lösung: alle Motion-Komponenten sind explizit als „use client“ markiert, und die initialen `initial`-States sind so konservativ, dass der Server-Render mit dem ersten Client-Frame übereinstimmt.
Was wir nicht bereut haben
Drei Entscheidungen, die sich im Nachhinein klar bewährt haben:
- API-Vertrag stabil halten: Die Trennung von Frontend-Migration und Backend-Migration hat uns erlaubt, jederzeit zu pausieren oder zurückzurollen.
- Route Groups statt Layout-Hacks: Die Verzeichnisstruktur ist tiefer, aber jeder neue Entwickler versteht die Architektur in unter einer Stunde.
- Edge-Middleware für Auth: Die Latenz-Verbesserung war messbar (P50-Login-Flow: 1.2s → 0.4s) und die Architektur ist klar.
Lessons für andere Teams
Wenn du mit dem Gedanken spielst, eine bestehende App auf den App Router zu migrieren:
- Trenne Backend-Migration von Frontend-Migration. Mache immer nur eine zur Zeit.
- Identifiziere die Top-3-Reibungspunkte, die du lösen willst. Wenn der Rewrite sie nicht löst, lohnt er sich nicht.
- Plane die Cache-Strategie explizit. Der App Router ist nicht „Pages Router mit anderem JSX" — das Caching-Verhalten ist fundamental anders.
- Halte Server Actions lokal. Hebe sie nur dann in eine zentrale Datei, wenn zwei Seiten sie wirklich teilen.
- Messe früh. Lighthouse vor und nach dem Rewrite. Wenn der Score nicht signifikant steigt, ist etwas faul.
Unser Rewrite hat sechs Wochen gedauert und den Lighthouse-Score um 22 Punkte verbessert, das Client-Bundle halbiert und die Time-to-Interactive um 1.4 Sekunden reduziert. Die Migration war nicht günstig, aber sie hat sich gelohnt — und das nächste Feature werden wir in Wochen statt Monaten ausliefern.
Themen
Geschrieben von
DerStr1k3r
Fullstack Developer · Germany
Mehr aus dieser Kategorie
Starte jetzt mit xUCP Systems V2 und bringe deinen Discord-Server auf das nächste Level. Vollständig kostenlos, keine Kreditkarte nötig.
Jetzt starten