21 KiB
BizMatch SSR - Technische Dokumentation
Was ist Server-Side Rendering (SSR)?
Server-Side Rendering bedeutet, dass die Angular-Anwendung nicht nur im Browser, sondern auch auf dem Server läuft und HTML vorab generiert.
Unterschied: SPA vs. SSR vs. Prerendering
1. Single Page Application (SPA) - OHNE SSR
Ablauf:
Browser → lädt index.html
→ index.html enthält nur <app-root></app-root>
→ lädt JavaScript-Bundles
→ JavaScript rendert die Seite
HTML-Response:
<!doctype html>
<html>
<head><title>BizMatch</title></head>
<body>
<app-root></app-root> <!-- LEER! -->
<script src="main.js"></script>
</body>
</html>
Nachteile:
- ❌ Suchmaschinen sehen leeren Content
- ❌ Langsamer "First Contentful Paint"
- ❌ Schlechtes SEO
- ❌ Kein Social-Media-Preview (Open Graph)
2. Server-Side Rendering (SSR)
Ablauf:
Browser → fragt Server nach /business/123
→ Server rendert Angular-App mit Daten
→ Server sendet vollständiges HTML
→ Browser zeigt sofort Inhalt
→ JavaScript lädt im Hintergrund
→ Anwendung wird "hydrated" (interaktiv)
HTML-Response:
<!doctype html>
<html>
<head>
<title>Restaurant "Zum Löwen" | BizMatch</title>
<meta name="description" content="Restaurant in München...">
</head>
<body>
<app-root>
<div class="listing-page">
<h1>Restaurant "Zum Löwen"</h1>
<p>Traditionelles deutsches Restaurant...</p>
<!-- Kompletter gerendeter Content! -->
</div>
</app-root>
<script src="main.js"></script>
</body>
</html>
Vorteile:
- ✅ Suchmaschinen sehen vollständigen Inhalt
- ✅ Schneller First Contentful Paint
- ✅ Besseres SEO
- ✅ Social-Media-Previews funktionieren
Nachteile:
- ⚠️ Komplexere Konfiguration
- ⚠️ Server-Ressourcen erforderlich
- ⚠️ Code muss browser- und server-kompatibel sein
3. Prerendering (Static Site Generation)
Ablauf:
Build-Zeit → Rendert ALLE Seiten zu statischen HTML-Dateien
→ /business/123.html, /business/456.html, etc.
→ HTML-Dateien werden auf CDN deployed
Unterschied zu SSR:
- Prerendering: HTML wird zur Build-Zeit generiert
- SSR: HTML wird zur Request-Zeit generiert
BizMatch nutzt SSR, NICHT Prerendering, weil:
- Listings dynamisch sind (neue Einträge täglich)
- Benutzerdaten personalisiert sind
- Suche und Filter zur Laufzeit erfolgen
Wie funktioniert SSR in BizMatch?
Architektur-Überblick
┌─────────────────────────────────────────────────────────────┐
│ Browser Request │
│ GET /business/restaurant-123 │
└────────────────────────────┬────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Express Server │
│ (server.ts:30-41) │
├─────────────────────────────────────────────────────────────┤
│ 1. Empfängt Request │
│ 2. Ruft AngularNodeAppEngine auf │
│ 3. Rendert Angular-Komponente serverseitig │
│ 4. Sendet HTML zurück │
└────────────────────────────┬────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ AngularNodeAppEngine │
│ (@angular/ssr/node) │
├─────────────────────────────────────────────────────────────┤
│ 1. Lädt main.server.ts │
│ 2. Bootstrapped Angular in Node.js │
│ 3. Führt Routing aus (/business/restaurant-123) │
│ 4. Rendert Component-Tree zu HTML-String │
│ 5. Injiziert Meta-Tags, Titel │
└────────────────────────────┬────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Angular Application │
│ (Browser-Code im Server) │
├─────────────────────────────────────────────────────────────┤
│ • Komponenten werden ausgeführt │
│ • API-Calls werden gemacht (TransferState) │
│ • DOM wird SIMULIERT (ssr-dom-polyfill.ts) │
│ • HTML-Output wird generiert │
└─────────────────────────────────────────────────────────────┘
Wichtige Dateien und ihre Rolle
1. server.ts - Express Server
const angularApp = new AngularNodeAppEngine();
server.get('*', (req, res, next) => {
angularApp.handle(req) // ← Rendert Angular serverseitig
.then((response) => {
if (response) {
writeResponseToNodeResponse(response, res);
}
});
});
Rolle:
- HTTP-Server (Express)
- Nimmt Requests entgegen
- Delegiert an Angular SSR Engine
- Sendet gerenderte HTML-Responses zurück
2. src/main.server.ts - Server Entry Point
import './ssr-dom-polyfill'; // ← WICHTIG: DOM-Mocks laden
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { config } from './app/app.config.server';
const bootstrap = () => bootstrapApplication(AppComponent, config);
export default bootstrap;
Rolle:
- Entry Point für SSR
- Lädt DOM-Polyfills VOR allen anderen Imports
- Bootstrapped Angular im Server-Kontext
3. dist/bizmatch/server/index.server.html - Server Template
WICHTIG: Diese Datei wird beim Build erstellt, nicht manuell geschrieben!
# Build-Prozess erstellt automatisch:
npm run build:ssr
→ dist/bizmatch/server/index.server.html ✅
→ dist/bizmatch/server/server.mjs ✅
→ dist/bizmatch/browser/index.csr.html ✅
Quelle:
- Angular nimmt
src/index.htmlals Vorlage - Fügt SSR-spezifische Meta-Tags hinzu
- Generiert
index.server.htmlfür serverseitiges Rendering - Generiert
index.csr.htmlfür clientseitiges Rendering (Fallback)
Warum nicht im Git?
- Build-Artefakte werden nicht eingecheckt (
.gitignore) - Jeder Build erstellt sie neu
- Verhindert Merge-Konflikte bei generierten Dateien
Fehlerquelle bei neuem Laptop:
git clone → dist/ Ordner fehlt
→ index.server.html fehlt
→ npm run serve:ssr crasht ❌
Lösung: → npm run build:ssr
→ index.server.html wird erstellt ✅
4. src/ssr-dom-polyfill.ts - DOM-Mocks
const windowMock = {
document: { createElement: () => ({ ... }) },
localStorage: { getItem: () => null },
navigator: { userAgent: 'node' },
// ... etc
};
if (typeof window === 'undefined') {
(global as any).window = windowMock;
}
Rolle:
- Simuliert Browser-APIs in Node.js
- Verhindert
ReferenceError: window is not defined - Ermöglicht die Ausführung von Browser-Code im Server
- Kritisch für Libraries wie Leaflet, die
windowerwarten
Warum notwendig?
- Angular-Code nutzt
window,document,localStorage, etc. - Node.js hat diese APIs nicht
- Ohne Polyfills: Crash beim Server-Start
4. ssr-dom-preload.mjs - Node.js Preload Script
import { isMainThread } from 'node:worker_threads';
if (!isMainThread) {
// Skip polyfills in worker threads (sass, esbuild)
} else {
globalThis.window = windowMock;
globalThis.document = documentMock;
}
Rolle:
- Wird beim
dev:ssrverwendet - Lädt DOM-Mocks VOR allen anderen Modulen
- Nutzt Node.js
--importFlag - Vermeidet Probleme mit early imports
Verwendung:
NODE_OPTIONS='--import ./ssr-dom-preload.mjs' ng serve
5. app.config.server.ts - Server-spezifische Config
Enthält Provider, die nur im Server-Kontext geladen werden:
provideServerRendering()- Server-spezifische HTTP-Interceptors
- TransferState für API-Daten
Rendering-Ablauf im Detail
Phase 1: Server-Side Rendering
1. Request kommt an: GET /business/restaurant-123
2. Express Router:
→ server.get('*', ...)
3. AngularNodeAppEngine:
→ bootstrapApplication(AppComponent, serverConfig)
→ Angular läuft in Node.js
4. Angular Router:
→ Route /business/:slug matched
→ ListingDetailComponent wird aktiviert
5. Component Lifecycle:
→ ngOnInit() wird ausgeführt
→ API-Call: fetch('/api/listings/restaurant-123')
→ Daten werden geladen
→ Template wird mit Daten gerendert
6. TransferState:
→ API-Response wird in HTML injiziert
→ <script>window.__NG_STATE__ = {...}</script>
7. Meta-Tags:
→ Title-Service setzt <title>
→ Meta-Service setzt <meta name="description">
8. HTML-Output:
→ Komplettes HTML mit Daten
→ Wird an Browser gesendet
Server-Output:
<!doctype html>
<html>
<head>
<title>Restaurant "Zum Löwen" | BizMatch</title>
<meta name="description" content="Traditionelles Restaurant...">
</head>
<body>
<app-root>
<!-- Vollständig gerenderte Component -->
<div class="listing-detail">
<h1>Restaurant "Zum Löwen"</h1>
<p>Adresse: Hauptstraße 1, München</p>
<!-- etc. -->
</div>
</app-root>
<!-- TransferState: verhindert doppelte API-Calls -->
<script id="ng-state" type="application/json">
{"listings":{"restaurant-123":{...}}}
</script>
<script src="main.js" defer></script>
</body>
</html>
Phase 2: Client-Side Hydration
1. Browser empfängt HTML:
→ Zeigt sofort gerenderten Content an ✅
→ User sieht Inhalt ohne Verzögerung
2. JavaScript lädt:
→ main.js wird heruntergeladen
→ Angular-Runtime startet
3. Hydration beginnt:
→ Angular scannt DOM
→ Vergleicht Server-HTML mit Client-Template
→ Attachiert Event Listener
→ Aktiviert Interaktivität
4. TransferState wiederverwenden:
→ Liest window.__NG_STATE__
→ Überspringt erneute API-Calls ✅
→ Daten sind bereits vorhanden
5. App ist interaktiv:
→ Buttons funktionieren
→ Routing funktioniert
→ SPA-Verhalten aktiviert
Wichtig:
- Kein Flickern (Server-HTML = Client-HTML)
- Keine doppelten API-Calls (TransferState)
- Schneller First Contentful Paint (HTML sofort sichtbar)
SSR vs. Non-SSR: Was wird wann gerendert?
Ohne SSR (npm start)
| Zeitpunkt | Server | Browser |
|---|---|---|
| T0: Request | Sendet leere index.html |
- |
| T1: HTML empfangen | - | Leeres <app-root></app-root> |
| T2: JS geladen | - | Angular startet |
| T3: API-Call | - | Lädt Daten |
| T4: Rendering | - | Erst jetzt sichtbar ❌ |
Time to First Contentful Paint: ~2-3 Sekunden
Mit SSR (npm run serve:ssr)
| Zeitpunkt | Server | Browser |
|---|---|---|
| T0: Request | Angular rendert + API-Call | - |
| T1: HTML empfangen | - | Inhalt sofort sichtbar ✅ |
| T2: JS geladen | - | Hydration beginnt |
| T3: Interaktiv | - | Event Listener attached |
Time to First Contentful Paint: ~200-500ms
Prerendering vs. SSR: Wann wird gerendert?
Prerendering (Static Site Generation)
Build-Zeit (npm run build):
→ ng build
→ Rendert /business/1.html
→ Rendert /business/2.html
→ Rendert /business/3.html
→ ...
→ Alle HTML-Dateien auf Server deployed
Request-Zeit:
→ Nginx sendet vorgefertigte HTML-Datei
→ KEIN Server-Side Rendering
Vorteile:
- Extrem schnell (statisches HTML)
- Kein Node.js-Server erforderlich
- Günstig (CDN-Hosting)
Nachteile:
- Nicht für dynamische Daten geeignet
- Re-Build bei jeder Änderung nötig
- Tausende Seiten = lange Build-Zeit
SSR (Server-Side Rendering)
Build-Zeit (npm run build:ssr):
→ ng build (Client-Bundles)
→ ng build (Server-Bundles)
→ KEINE HTML-Dateien generiert
Request-Zeit:
→ Node.js Server empfängt Request
→ Angular rendert HTML on-the-fly
→ Frische Daten aus DB
→ Sendet HTML zurück
Vorteile:
- Immer aktuelle Daten
- Personalisierte Inhalte
- Keine lange Build-Zeit
Nachteile:
- Server-Ressourcen erforderlich
- Langsamer als Prerendering (Rendering kostet Zeit)
- Komplexere Infrastruktur
BizMatch: Warum SSR statt Prerendering?
Gründe:
-
Dynamische Listings:
- Neue Businesses werden täglich hinzugefügt
- Prerendering würde tägliche Re-Builds erfordern
-
Personalisierte Daten:
- Benutzer sehen unterschiedliche Inhalte (Favoriten, etc.)
- Prerendering kann nicht personalisieren
-
Suche und Filter:
- Unendliche Kombinationen von Filtern
- Unmöglich, alle Varianten vorzurendern
-
Skalierung:
- 10.000+ Listings → Prerendering = 10.000+ HTML-Dateien
- SSR = 1 Server, rendert on-demand
Client-Side Hydration im Detail
Was ist Hydration?
Hydration = Angular "erweckt" das Server-HTML zum Leben.
Ohne Hydration:
- HTML ist statisch
- Buttons funktionieren nicht
- Routing funktioniert nicht
- Kein JavaScript-Event-Handling
Nach Hydration:
- Angular übernimmt Kontrolle
- Event Listener werden attached
- SPA-Routing funktioniert
- Interaktivität aktiviert
Hydration-Ablauf
// 1. Server rendert HTML
<button (click)="openModal()">Details</button>
// 2. Browser empfängt HTML
// → Button ist sichtbar, aber (click) funktioniert NICHT
// 3. Angular-JavaScript lädt
// → main.js wird ausgeführt
// 4. Hydration scannt DOM
angular.hydrate({
serverHTML: '<button>Details</button>',
clientTemplate: '<button (click)="openModal()">Details</button>',
// Vergleich: HTML matches Template? ✅
// → Reuse DOM node
// → Attach Event Listener
});
// 5. Button ist jetzt interaktiv
// → (click) funktioniert ✅
Probleme bei Hydration
Problem 1: Mismatch zwischen Server und Client
Ursache:
// Server rendert:
<div>Server Time: {{ serverTime }}</div>
// Client rendert:
<div>Server Time: {{ clientTime }}</div> // ← Unterschiedlich!
Folge:
- Angular erkennt Mismatch
- Wirft Warnung in Console
- Re-rendert Component (Performance-Verlust)
Lösung:
- TransferState nutzen für gemeinsame Daten
isPlatformServer()für unterschiedliche Logik
Problem 2: Browser-only Code wird im Server ausgeführt
Ursache:
ngOnInit() {
window.scrollTo(0, 0); // ← CRASH: window ist undefined im Server
}
Lösung:
import { isPlatformBrowser } from '@angular/common';
import { PLATFORM_ID } from '@angular/core';
constructor(@Inject(PLATFORM_ID) private platformId: Object) {}
ngOnInit() {
if (isPlatformBrowser(this.platformId)) {
window.scrollTo(0, 0); // ← Nur im Browser
}
}
TransferState: Verhindert doppelte API-Calls
Problem ohne TransferState
Server:
→ GET /api/listings/123 ← API-Call 1
→ Rendert HTML mit Daten
Browser (nach JS-Load):
→ GET /api/listings/123 ← API-Call 2 (doppelt!)
→ Re-rendert Component
Problem:
- Doppelter Netzwerk-Traffic
- Langsamere Hydration
- Flickern beim Re-Render
Lösung: TransferState
Server-Side:
import { TransferState, makeStateKey } from '@angular/platform-browser';
const LISTING_KEY = makeStateKey<Listing>('listing-123');
ngOnInit() {
this.http.get('/api/listings/123').subscribe(data => {
this.transferState.set(LISTING_KEY, data); // ← Speichern
this.listing = data;
});
}
HTML-Output:
<script id="ng-state" type="application/json">
{"listing-123": {"name": "Restaurant", "address": "..."}}
</script>
Client-Side:
ngOnInit() {
const cachedData = this.transferState.get(LISTING_KEY, null);
if (cachedData) {
this.listing = cachedData; // ← Wiederverwenden ✅
} else {
this.http.get('/api/listings/123').subscribe(...); // ← Nur wenn nicht cached
}
this.transferState.remove(LISTING_KEY); // ← Cleanup
}
Ergebnis:
- ✅ Nur 1 API-Call (serverseitig)
- ✅ Kein Flickern
- ✅ Schnellere Hydration
Performance-Vergleich
Metriken
| Metrik | Ohne SSR | Mit SSR | Verbesserung |
|---|---|---|---|
| Time to First Byte (TTFB) | 50ms | 200ms | -150ms ❌ |
| First Contentful Paint (FCP) | 2.5s | 0.5s | -2s ✅ |
| Largest Contentful Paint (LCP) | 3.2s | 0.8s | -2.4s ✅ |
| Time to Interactive (TTI) | 3.5s | 2.8s | -0.7s ✅ |
| SEO Score (Lighthouse) | 60 | 95 | +35 ✅ |
Wichtig:
- TTFB ist langsamer (Server muss rendern)
- Aber FCP viel schneller (HTML sofort sichtbar)
- User-Wahrnehmung: SSR fühlt sich schneller an
SEO-Vorteile
Google Crawler
Ohne SSR:
<!-- Google sieht nur: -->
<app-root></app-root>
<script src="main.js"></script>
→ ❌ Kein Content indexiert → ❌ Kein Ranking → ❌ Keine Rich Snippets
Mit SSR:
<!-- Google sieht: -->
<title>Restaurant "Zum Löwen" | BizMatch</title>
<meta name="description" content="Traditionelles Restaurant in München">
<h1>Restaurant "Zum Löwen"</h1>
<p>Adresse: Hauptstraße 1, 80331 München</p>
<div itemscope itemtype="https://schema.org/Restaurant">
<span itemprop="name">Restaurant "Zum Löwen"</span>
<span itemprop="address">München</span>
</div>
→ ✅ Vollständiger Content indexiert → ✅ Besseres Ranking → ✅ Rich Snippets (Sterne, Adresse, etc.)
Social Media Previews (Open Graph)
Ohne SSR:
<!-- Facebook/Twitter sehen nur: -->
<title>BizMatch</title>
→ ❌ Kein Preview-Bild → ❌ Keine Beschreibung
Mit SSR:
<meta property="og:title" content="Restaurant 'Zum Löwen'" />
<meta property="og:description" content="Traditionelles Restaurant..." />
<meta property="og:image" content="https://bizmatch.net/images/restaurant.jpg" />
<meta property="og:url" content="https://bizmatch.net/business/restaurant-123" />
→ ✅ Schönes Preview beim Teilen → ✅ Mehr Klicks → ✅ Bessere User Experience
Zusammenfassung
SSR in BizMatch bedeutet:
- Server rendert HTML vorab (nicht erst im Browser)
- Browser zeigt sofort Inhalt (schneller First Paint)
- JavaScript hydrated im Hintergrund (macht HTML interaktiv)
- Kein Flickern, keine doppelten API-Calls (TransferState)
- Besseres SEO (Google sieht vollständigen Content)
- Social-Media-Previews funktionieren (Open Graph Tags)
Technischer Stack:
- @angular/ssr: SSR-Engine
- Express: HTTP-Server
- AngularNodeAppEngine: Rendert Angular in Node.js
- ssr-dom-polyfill.ts: Simuliert Browser-APIs
- TransferState: Verhindert doppelte API-Calls
Wann wird was gerendert?
- Build-Zeit: Nichts (kein Prerendering)
- Request-Zeit: Server rendert HTML on-the-fly
- Nach JS-Load: Hydration macht HTML interaktiv
Best Practices:
- Browser-Code mit
isPlatformBrowser()schützen - TransferState für API-Daten nutzen
- DOM-Polyfills für Third-Party-Libraries
- Meta-Tags serverseitig setzen
- Server-Build vor Deployment testen