React Server Components Migration: Bestehende Komponenten vom Client auf den Server umziehen, ohne die App zu brechen

#react server components migration
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Gründer & Lead Developer

Experte für Softwareentwicklung und Legacy-Code-Optimierung

Die meisten Teams, die den Next.js App Router einführen, tun das in zwei Phasen, ob geplant oder nicht. Phase eins ist die Routing-Migration: Dateien wandern nach app/, Layouts ersetzen _app.tsx, und alles bekommt 'use client' obendrauf geklatscht, damit der Build durchläuft. Phase zwei ist die eigentliche React Server Components Migration: noch einmal durch diese Komponenten gehen und die, die auf den Server gehören, auf den Server verschieben. Viele Teams beenden Phase eins, shippen und fangen Phase zwei nie an. Sie betreiben den App Router mit einem komplett client-gerenderten Baum und wundern sich, warum das Bundle nicht geschrumpft ist.

In diesem Beitrag geht es um Phase zwei. Nicht um die Framework-Migration selbst (die haben wir in unserem Guide zur inkrementellen App-Router-Einführung behandelt), sondern um die Arbeit Komponente für Komponente: bestehende Client-Komponenten in einer bereits laufenden App zu Server-Komponenten konvertieren, ohne sie zu brechen.

Was sich für eine React Server Components Migration qualifiziert

Eine Komponente kann Server-Komponente werden, wenn sie nichts von Folgendem braucht:

  • Event-Handler (onClick, onChange, onSubmit)
  • State- oder Lifecycle-Hooks (useState, useReducer, useEffect, useLayoutEffect)
  • Browser-only APIs (window, localStorage, IntersectionObserver, matchMedia)
  • Custom Hooks, die von einem der obigen Punkte abhängen
  • Class-Component-Features (React.Component hat kein Server-Äquivalent)

Die Liste klingt simpel, aber in einer echten Codebase verstecken sich die Ausschlusskriterien zwei oder drei Ebenen tiefer. Eine ProductCard sieht statisch aus, bis dir auffällt, dass sie useAnalytics importiert, das intern useEffect aufruft. Die Importkette zählt genauso viel wie der Komponentenkörper: Eine Server-Komponente kann nichts importieren, das transitiv client-only Code berührt, es sei denn, dieser Import steht selbst hinter einer 'use client'-Grenze.

Also die erste praktische Regel einer React Server Components Migration: Auditiere Komponenten nicht, indem du ihr JSX liest. Auditiere ihre Imports.

Die schnelle Triage

Beantworte für jede Kandidaten-Komponente drei Fragen:

  1. Nutzt sie oder einer ihrer Nicht-Boundary-Imports Hooks oder Browser-APIs?
  2. Bekommt sie Funktionen als Props (Callbacks können die Server-Client-Grenze nicht überqueren)?
  3. Liest sie aus einem React Context, der von einer Client-Komponente bereitgestellt wird?

Drei Mal Nein heißt: Sie kann heute umziehen. Jedes Ja heißt: Sie bleibt client-seitig oder wird aufgeteilt, und genau darum geht es im Rest dieses Beitrags.

Mappe die Grenze, bevor du irgendetwas verschiebst

Der Fehler, den wir in Code-Audits am häufigsten sehen: Teams konvertieren Komponenten opportunistisch, eine Datei nach der anderen, überall dort, wo 'use client' entfernbar aussieht. Das produziert eine Grenze, die im Zickzack durch den Baum läuft, mit Client-Inseln, die Server-Löcher umschließen, die Client-Blätter umschließen. Jede Überquerung ist ein Serialisierungspunkt und eine Stelle, an der Props JSON-sicher sein müssen. Je mehr Überquerungen, desto fragiler der Baum.

Bevor du Code anfasst, mappe, wo die Grenze liegen sollte. Der React-DevTools-Profiler plus ein Bundle-Analyzer-Lauf liefern das Rohmaterial: welche Komponenten häufig rendern, welche schwere Dependencies ins Bundle tragen und welche Teilbäume nie Interaktion behandeln. Was du finden willst, sind große, überwiegend statische Regionen (Header, Produkt-Grids, Artikelinhalte, Footer, Settings-Zusammenfassungen), getrennt durch dünne interaktive Nähte (ein Suchfeld, eine Filterleiste, ein Add-to-Cart-Button).

Die Zielform ist fast immer dieselbe: Server-Komponenten besitzen den Stamm des Baums, und Client-Komponenten hängen als kleine Blätter daran. Interaktive Wrapper bleiben client-seitig; statischer Inhalt darin wird extrahiert und als children durchgereicht. Weil children, die von einer Server-Komponente gerendert werden, in eine Client-Komponente hineingegeben werden können, ohne selbst Client-Code zu werden, kann mit diesem Muster ein client-seitiges Accordion, Tab-Panel oder Modal server-gerenderten Inhalt enthalten. Dieser eine Trick (Server-Inhalt, der als Props durch Client-Wrapper gefädelt wird) tut mehr für die Bundle-Verkleinerung als jeder andere Schritt der Migration.

Data Fetching handhaben: Töte den Waterfall, bevor er dich tötet

Eine Komponente auf den Server zu verschieben heißt meistens, auch ihr Data Fetching zu verschieben, von useEffect oder React Query in eine asynchrone Server-Komponente. Genau hier verschlechtern Migrationen still die Performance. In der Client-Version feuerten drei Komponenten ihre Fetches parallel nach dem Mount. In der naiven Server-Version awaiten drei verschachtelte asynchrone Komponenten sequenziell, und deine Time to First Byte enthält jetzt einen Waterfall, den es vorher nie gab.

Zwei Regeln verhindern das:

Hoiste Fetches, nicht nur Komponenten. Starte Requests auf der höchsten sinnvollen Ebene und reiche Promises oder aufgelöste Daten nach unten. Promise.all in einer Server-Komponente auf Seitenebene hält Geschwister-Requests parallel:

export default async function ProductPage({ params }) {
  const [product, reviews, related] = await Promise.all([
    getProduct(params.id),
    getReviews(params.id),
    getRelated(params.id),
  ]);
  return <ProductView product={product} reviews={reviews} related={related} />;
}

Nutze Suspense, um langsame Daten von schnellen zu entkoppeln. Wenn Reviews langsam sind und der Produktkern schnell, wickle den Review-Teilbaum in <Suspense> und lass ihn hereinstreamen. Die Seitenhülle rendert sofort, und die langsame Sektion füllt sich, ohne irgendetwas anderes zu blockieren.

Dedupliziere außerdem auf der Datenschicht. Wenn zwei Server-Komponenten unabhängig voneinander den aktuellen Nutzer brauchen, wickle den Lookup in Reacts cache(), damit beide Aufrufe aus einem Request aufgelöst werden. Das ersetzt, was React Querys Cache client-seitig für dich getan hat; vergiss es, und du verdoppelst oder verdreifachst deinen Backend-Traffic. Für eine tiefere Betrachtung, wann Server-Fetching, React Query oder beides sinnvoll ist, siehe unseren Vergleich der React-Data-Fetching-Ansätze.

Context-Provider migrieren

Context ist der Teil einer React Server Components Migration, den Teams unterschätzen. Das klassische SPA-Setup hat einen Stapel Provider an der Wurzel: Theme, Auth, Feature Flags, Analytics, i18n. Provider nutzen Context, Context braucht Client-Code, und eine Client-Komponente an der Wurzel zieht den gesamten Baum zurück auf den Client.

Der Fix ist eine dedizierte Providers-Datei, die die Grenze dünn hält:

// app/providers.tsx
'use client';
export function Providers({ children }) {
  return (
    <ThemeProvider>
      <AnalyticsProvider>{children}</AnalyticsProvider>
    </ThemeProvider>
  );
}

Das Root-Layout bleibt eine Server-Komponente und rendert <Providers>{children}</Providers>. Weil der Seiteninhalt über children ankommt, wird er nicht zu Client-Code, nur weil er durch den Provider-Stack läuft.

Reduziere dann, wofür Context überhaupt da ist. Server-Komponenten können React Context gar nicht konsumieren, also müssen Daten, die deine Server-Komponenten brauchen, anders ankommen: Lies die Session direkt in der Server-Komponente über einen gecachten Helper, reiche Feature Flags als Props aus dem Layout durch, löse die Locale aus dem Request auf. In den meisten migrierten Apps dient Context am Ende nur noch echten Client-Anliegen wie Theme-Umschaltung und Analytics, und der Provider-Stack schrumpft auf zwei oder drei Einträge.

Ein durchgearbeitetes Beispiel: Die Produktlisten-Seite

Hier das Vorher und Nachher aus einer Migration, die wir für eine B2B-Commerce-App durchgeführt haben, vereinfacht, aber strukturell originalgetreu.

Vorher. ProductListPage war eine einzige Client-Komponente: useEffect holte /api/products beim Mount, useState hielt Produkte, Loading- und Filter-State, und die Datei importierte eine Datumsbibliothek, einen Währungsformatierer und einen Markdown-Renderer für Produkt-Kurztexte. Client-Bundle-Beitrag: 87 kB gzipped. Nutzer sahen bei jedem Besuch einen Spinner, während der Fetch seine Runde drehte.

Nachher. Die Seite wurde in drei Teile aufgeteilt:

  1. ProductListPage (Server): asynchron, holt Produkte direkt vom Backend-Service, kein HTTP-Umweg über eine API-Route. Rendert das Grid.
  2. ProductCard (Server): reine Präsentation. Datumsbibliothek, Währungsformatierer und Markdown-Renderer laufen jetzt nur noch auf dem Server und haben das Bundle komplett verlassen.
  3. FilterBar (Client): das eine interaktive Stück. Sie schreibt Filterauswahlen via router.replace in die URL-Suchparameter, und die Server-Seite rendert mit gefilterten Ergebnissen neu.

Die interessante Entscheidung war der Filter-State. Die Client-Version filterte im Speicher. Filter in URL-Params zu verlagern heißt, jede Änderung trifft den Server, also haben wir ein bewusstes client-seitiges Zugeständnis behalten: Die Textsuche wird debounced, bevor sie die URL berührt. Kategorie- und Preisfilter gehen direkt in die Params. Gefilterte Ansichten wurden verlinkbar und teilbar, was die alte In-Memory-Version nie konnte.

Gemessenes Ergebnis: Das Client-JavaScript der Route fiel von 87 kB auf 9 kB gzipped, und der First Contentful Paint verbesserte sich auf Mittelklasse-Mobilgeräten um rund 40 Prozent, weil der Fetch-nach-Mount-Spinner-Zyklus wegfiel. Die Zahlen werden bei deiner App anders ausfallen, aber die Form des Gewinns (Dependencies verlassen das Bundle, Daten kommen vor dem ersten Paint an) ist typisch.

Verifizieren, dass nichts gebrochen ist

Eine solche Migration ist erfolgreich, wenn Nutzer nichts bemerken außer Geschwindigkeit. Die Verifikationsstrategie hat drei Ebenen:

Output-Äquivalenz. Snapshotte für jede konvertierte Komponente das gerenderte HTML vorher und nachher gegen dieselben Fixture-Daten. Das DOM sollte identisch oder trivial verschieden sein (Whitespace, Attributreihenfolge). Wir skripten das, statt es per Auge zu prüfen: beide Versionen in einem Test-Harness rendern, die serialisierte Ausgabe diffen und jedes nicht-triviale Delta von Hand reviewen. Hydration-Mismatch-Warnungen in der Konsole sind in diesem Durchgang Fehlschläge, kein Rauschen.

Verhaltenstests an den Nähten. Jede Server-Client-Grenze, die du geschaffen hast, ist eine Stelle, an der Props serialisieren. Integrationstests (Playwright oder Cypress) sollten jede interaktive Naht abdecken: Die Filterleiste aktualisiert Ergebnisse, das Accordion öffnet sich mit server-gerendertem Inhalt darin, das Formular submittet weiterhin. Unit-Tests der alten Client-Komponenten lassen sich größtenteils auf die extrahierten Client-Blätter übertragen; die Server-Teile sind besser durch Rendering-Tests abgedeckt als durch das Mocken von Hooks, die nicht mehr existieren.

Production Canary. Shippe konvertierte Routen hinter einem graduellen Rollout und beobachte drei Metriken: Hydration-Error-Rate, p95 TTFB (der Waterfall-Detektor) und Backend-Request-Volumen (der Detektor für fehlendes cache()). Jede davon fängt eine Fehlerklasse, die lokales Testen zuverlässig übersieht.

Migriere in der Reihenfolge, die sich auszahlt

Konvertiere nicht alphabetisch. Ranke Routen nach Client-Bundle-Größe multipliziert mit Traffic und starte dort, wo das Produkt aus beidem am höchsten ist. Eine schwere Listing-Seite mit viel Traffic ist es wert, diesen Sprint migriert zu werden; eine Settings-Seite, die zweimal am Tag pro Nutzer rendert, kann ihr 'use client' für immer behalten, ohne dass es jemand merkt. Server-Komponenten sind ein Werkzeug mit Auszahlungskurve, kein Reinheitsstandard. Unser früherer Beitrag zu RSC-Patterns und Fallstricken in Produktion geht tiefer auf das Laufzeitverhalten ein, sobald du auf der anderen Seite bist.

Wenn du auf eine App-Router-Codebase starrst, in der jede Datei noch mit 'use client' beginnt, oder ein Migrationsversuch auf halber Strecke mit einer Zickzack-Grenze stecken geblieben ist: Das ist Arbeit, die wir regelmäßig im Rahmen von Legacy-Code-Optimierungs-Engagements machen. Schreib an hello@wolf-tech.io oder wirf einen Blick auf wolf-tech.io, dann mappen wir deinen Komponentenbaum und geben dir eine Migrationsreihenfolge, die sich schon im ersten Sprint bezahlt macht.