React Server-Side Rendering mit PHP

Dennis AdamcykDennis Adamcyk15. April 202611 Min.

PHP mit Laravel ist ein richtig solides Framework. Es ist elegant, die Patterns sind klar, die Developer Experience stimmt. Solange man reine Backend-Logik schreibt, API-Endpoints baut oder einfache Blade-Views ausliefert, läuft alles reibungslos.

Aber moderne Webseiten brauchen mehr. Sie brauchen interaktive Frontends. Sie brauchen JavaScript.

Das Frontend-Dilemma

Für mich ist die beste Lösung, um ein gut strukturiertes Frontend zu schreiben, ganz klar React. Komponentenbasiert, wiederverwendbar, mit einem riesigen Ökosystem. In Laravel ist das an sich erstmal kein Problem. Die Welten sind sauber getrennt: Backend-Logik lebt in den .php-Dateien, Frontend-Code hat im resources-Ordner sein Zuhause. Laravel kümmert sich um Datenbank, Business-Logik und APIs. React kümmert sich um die UI.

Klingt nach einer sauberen Architektur, oder?

Aber dann kommt Performance ins Spiel. Und SEO. Eine reine Client-Side-React-App schickt zunächst nur ein leeres HTML-Gerüst zum Browser. Der Browser lädt JavaScript. JavaScript wird geparst. React wird initialisiert. Komponenten werden gemountet. Erst dann sieht der User etwas Sinnvolles.

Das ist langsam. Das ist schlecht für SEO. Und schlichtweg nicht akzeptabel für echte Webseiten.

Die Lösung? Server-Side Rendering. Wir müssen unser Frontend also irgendwie im Backend ausführen, damit wir direkt mit dem richtigen HTML antworten können. Das HTML ist sofort sichtbar, während JavaScript im Hintergrund nachgeladen wird und die Seite dann interaktiv macht.

Der knifflige Teil

Hier wird's kompliziert. Wir müssen die PHP- und JavaScript-Welt vereinen. React-Komponenten auf dem Server rendern. In PHP.

Irgendwie fühlt sich JavaScript immer wie ein Fremdkörper in der PHP-Welt an. Zwei verschiedene Runtimes, zwei verschiedene Package-Manager, zwei verschiedene Paradigmen. Man kann das ignorieren, solange Frontend und Backend sauber getrennt sind. Aber SSR zwingt uns, diese Grenze zu überqueren.

Deshalb hat es mich auch nicht losgelassen, als Fabian letzten Monat den Gedanken äußerte: "Kann man React nicht direkt mit PHP rendern?"

Das hat mich ins Grübeln gebracht. Warum eigentlich nicht?

Die Recherche beginnt

Ich fing an zu recherchieren und stieß schnell auf zwei Projekte: php-v8js und spatie/server-side-rendering. Letzteres fand ich besonders interessant. Spatie kenne ich mittlerweile ganz gut. Ihre Packages sind oft sehr ausgereift und werden von Tausenden Entwicklern genutzt. laravel-permission, laravel-data, laravel-backup – alles solide, produktionsreife Software.

Haben sie mein Problem also schon gelöst?

Zwei Wege, ein Ziel

spatie/server-side-rendering bietet zwei Adapter. Einmal tatsächlich php-v8js. Einmal Node.js. Das war interessant. Warum zwei Adapter, wenn einer reichen würde?

Der php-v8js Adapter

Der erste Ansatz klang verlockend. php-v8js ist eine PHP-Extension, welche die V8 JavaScript Engine, die auch von Google Chrome und Node.js verwendet wird, direkt in PHP einbettet. Keine separate Runtime, keine zusätzlichen Prozesse. Nur PHP. Man gibt JavaScript-Code rein, bekommt Ergebnisse raus. Technisch elegant.

Ich installierte das Package und baute ein simples Test-Setup. Eine React-Komponente. Nichts Komplexes, nur ein <div> mit etwas Text. Sollte funktionieren, oder?

Hier kam das erste Problem: React's Server-APIs haben harte Abhängigkeiten zu Node.js-Modulen. http, url, stream, buffer – alles Core-Module von Node.js, die tief im react/server Package verankert sind. Gerade mit den neusten Updates von React mit Suspense, Streaming, usw. geht es nicht mehr ohne. V8js gibt uns aber nur die JavaScript-Engine. Die V8. Roh und pur.

Aber die Node.js-Runtime? Die fehlt komplett.

Das ist wie ein Auto-Motor ohne das restliche Auto. Der Motor läuft vielleicht. Aber fahren kannst du damit nicht.

Man könnte jetzt theoretisch Polyfills schreiben. Node.js-Module nachbauen. Aber wie realistisch ist das? Die Node.js-Standardbibliothek ist riesig. Jedes React-Update könnte neue Dependencies einführen. Man würde einen Wartungsalptraum kreieren, nur um die Notwendigkeit von Node.js zu vermeiden.

Und selbst wenn man das durchzieht, würde es schnell sein? Wahrscheinlich nicht. V8js ist ein Bindings-Layer, der zwischen zwei verschiedenen Welten vermittelt. Da entstehen zwangsläufig Overheads.

Der Node.js Adapter

Der zweite Adapter von Spatie nimmt einen anderen Ansatz. Er akzeptiert die Realität: React braucht Node.js. Also startet er Node.js aus PHP heraus.

Klingt erstmal sauber, oder? PHP behält die volle Kontrolle. Es kreiert die Prozesse dynamisch und beendet sie danach wieder. Aber hier beginnen die technischen Herausforderungen.

Node.js kann nicht einfach beliebig dynamischen Code ausführen. Es braucht eine Datei. Eine tatsächliche JavaScript-Datei auf der Festplatte. Man kann Node.js nicht einfach einen String übergeben und sagen "führe das mal aus". Der Adapter muss also bei jeder Request eine temporäre Datei erstellen.

Aber es wird komplizierter. Diese Datei braucht Wrapper-Code. Wie gibt man Input rein? Welche Seite soll überhaupt gerendert werden? Welche Props braucht die Komponente? Wie erfasst man den Output, also das gerenderte HTML? Das alles muss in die temporäre Datei gepackt werden.

Der Flow sieht ungefähr so aus:

Flowchart: Der SSR-Prozess mit dem Node.js-Adapter in 8 Schritten. Fünf von acht Schritten sind reiner Overhead (JS-Datei generieren, auf Festplatte schreiben, Node.js-Prozess starten, Prozess beenden, Temp-Datei löschen). Das eigentliche Rendern der React-Komponente dauert nur ca. 50 ms, während allein der Cold Start von Node.js rund eine Sekunde kostet.

Das funktioniert. Aber betrachten wir, was hier eigentlich passiert. Bei jeder Request – wenn die Requests also tatsächlich dynamisch sind, also pro User individuell – muss eine neue Datei auf die Festplatte geschrieben werden. Das ist schonmal der erste unschöne Workaround und Overhead. Disk I/O ist langsam. Selbst auf SSDs.

Dann kommt der eigentliche Performance-Killer: Node.js braucht Zeit zum Starten. Die Runtime muss initialisiert werden, Module geladen, Code geparst. Diese "Cold Start"-Zeit liegt bei etwa einer Sekunde. Jedes Mal.

Ich testete es mit einer einfachen Komponente. Die erste Request dauerte über 1,2 Sekunden. Die zweite auch. Die dritte auch. Jede einzelne Request brauchte über eine Sekunde.

Eine Sekunde ist eine Ewigkeit im Web. Das ist inakzeptabel für Production. Selbst wenn deine React-Komponente in 50ms gerendert ist, der Prozess-Overhead frisst dir jede Performance-Verbesserung weg.

Der bessere Weg: Persistente Prozesse

An diesem Punkt wurde mir klar: Man kann natürlich auch einen anderen Ansatz wählen. Einen persistenten Node.js-Prozess. Statt bei jeder Request einen neuen Prozess zu starten, läuft ein Prozess dauerhaft im Hintergrund. Immer da. Immer bereit.

Das eliminiert die Cold-Start-Zeit komplett. Keine Disk I/O für temporäre Dateien. Keine Runtime-Initialisierung. Nur das eigentliche Rendering.

Ein Blick zu Inertia.js

Ich nutze Inertia.js schon als Brücke zwischen Laravel und React. Es dient als Router und erspart mir APIs. Ich kann einfach Page Props direkt von Laravel an meine React-Seiten übergeben. Sauber und typsicher.

Und ich wusste natürlich auch, dass Inertia SSR implementiert hat. Das hatte ich mir auch schon genauer angeschaut. Sie haben nur ein "Gateway" (so nennen sie es in diesem Kontext) und das funktioniert mit einem persistenten Node.js-Prozess.

Das bedeutet: Man muss den Prozess erstmal starten, bevor SSR funktioniert. Man muss sich um diesen Prozess kümmern: Process-Management, Monitoring, Restarts bei Crashes. Das ist zusätzliche Komplexität.

Aber es ist der richtige Weg.

Warum? Weil man damit keinen Cold Start erzeugt. Der Prozess ist bereits warm. Alle Module sind geladen. Die React-Komponenten sind im Speicher. Man kann einfach schnell die Seiten rendern lassen.

Die Kommunikation zwischen PHP und Node.js ist überraschend simpel. Kein komplexes IPC (Inter-Process Communication). Kein Shared Memory. Einfach normales HTTP.

Der Node.js-Prozess startet einen internen HTTP-Server. PHP macht einen HTTP-Request dorthin. "Hier sind meine Props, bitte render das." Node.js rendert. Gibt HTML zurück. Fertig.

Ja, es gibt den Overhead, dass Node.js einen eigenen Server starten muss. Aber ehrlich? Ein HTTP-Server in Node.js ist leichtgewichtig. Das ist fast vernachlässigbar.

Cluster-Mode: Multi-Threading von Haus aus

Noch besser: Inertia's SSR-Server unterstützt einen Cluster-Mode. Der startet automatisch Subprozesse entsprechend der Anzahl der CPU-Kerne. Acht Kerne? Acht Prozesse.

Ich wusste vorher gar nicht, dass das tatsächlich einfach ein Feature der Node.js-Standard-Server-Bibliothek ist. Das Cluster-Modul ist built-in. Man importiert es, wrappt seinen Server-Code damit und Node.js kümmert sich um den Rest. Load-Balancing zwischen den Worker-Prozessen? Automatisch. Ein Worker crashed? Der Master-Prozess startet einen neuen.

Das ist Multi-Threading für JavaScript. Oder genauer: Multi-Processing, aber mit geteiltem Port. Jeder Worker kann Requests annehmen. Das skaliert hervorragend auf Multi-Core-Maschinen.

typescript
import { createServer } from 'node:http'
import cluster from 'node:cluster'
import { availableParallelism } from 'node:os'

if (cluster.isPrimary) {
  const cpus = availableParallelism()
  console.log(`Primary process started. Forking ${cpus} workers...`)

  for (let i = 0; i < cpus; i++) {
    cluster.fork()
  }

  return
}

// Jeder Worker startet seinen eigenen HTTP-Server
createServer(async (request, response) => {
  // React-Komponente rendern und HTML zurückgeben
  const html = await renderComponent(request)

  response.writeHead(200, { 'Content-Type': 'application/json' })
  response.end(JSON.stringify({ body: html }))
}).listen(13714)

Die Performance-Realität

Ich baute ein Test-Setup mit Inertia's SSR-Server. Die erste Anfrage dauerte etwa 120ms. Okay, etwas langsam. Aber die zweite? 28ms. Die dritte? 15ms. Die vierte? 9ms.

Das ist ein massiver Unterschied. Der persistente Prozess eliminiert die Cold-Start-Zeit komplett. Nach dem ersten Request ist alles warm gelaufen, gecacht und optimiert. Die eigentliche Render-Zeit von React liegt meist unter 30ms.

Das ist schnell genug für Production. Das ist schneller als viele Datenbankabfragen.

Horizontales Balkendiagramm: Renderzeit des Node.js-Adapters (1.200 ms pro Request) im Vergleich zum persistenten Prozess, der sich von 120 ms beim ersten Request auf 9 ms beim vierten Request aufwärmt. Der Adapter-Balken ist dabei um ein Vielfaches länger als alle persistenten Balken zusammen.

Ich testete auch bun statt Node.js als Runtime. Bun ist schneller beim Starten, beim Module-Loading, beim Code-Parsing. Die Zahlen waren tatsächlich etwas besser – etwa 5-10ms schneller im Durchschnitt. Aber ehrlich? Der Unterschied ist marginal. Node.js ist hier schon gut genug.

Was bedeutet das praktisch?

Die technische Realität ist klar: React SSR mit PHP direkt zu machen ist ein Kampf gegen Windmühlen. React's Architektur ist fundamental mit Node.js verknüpft. Das ist keine künstliche Beschränkung. Es sind echte Code-Dependencies, die sich durch die Server-Codebase ziehen.

Aber ist ein persistenter Node.js-Prozess wirklich so schlimm?

Nein. Nicht wirklich. Du brauchst ein zusätzliches Process-Management – PM2, Supervisor, oder einfach systemd. Aber das hast du bei modernen Laravel-Deployments sowieso meist schon für Queue-Worker/Horizon. Ein weiterer Prozess ändert das Bild kaum.

Der Node.js-Prozess ist stabil, performt hervorragend und integriert sich nahtlos. Er ist kein Fremdkörper, der deine Architektur zerstört. Er ist ein spezialisierter Service für einen spezifischen Job: JavaScript server-seitig ausführen.

Genau wie Redis ein spezialisierter Service für Caching ist. Genau wie Datenbanken ein spezialisierter Service für relationale Daten ist.

Fazit

Fabians Frage war berechtigt. "Kann man React nicht direkt mit PHP rendern?" Die ehrliche Antwort: Nicht wirklich. Nicht ohne massive Workarounds, die am Ende langsamer und fragiler sind als die etablierte Lösung.

Die etablierte Lösung – ein persistenter Node.js-Prozess – ist nicht perfekt. Sie fügt einen weiteren Moving Part hinzu. Aber sie funktioniert. Sie ist schnell. Sie ist bewährt.

Manchmal ist die pragmatische Lösung die beste Lösung. Und manchmal bedeutet "best practice" einfach: Das, was tatsächlich in Production funktioniert.

Dennis Adamcyk

Systemarchitekt für Webplattformen

Über den Autor

Ich bin Fullstack Developer bei den nauten und arbeite am liebsten mit Laravel, React und Next.js. Mich begeistern saubere Architekturen, durchdachte APIs und Lösungen, die technisch solide sind und im Alltag zuverlässig funktionieren.

Verwandte Artikel

zur Blogübersicht