Standardmäßig liefert Angular die Anwendung als CSR (Client-Side Rendering) aus: Der Server schickt ein leeres <app-root></app-root>, und der Browser muss erst das gesamte JavaScript herunterladen, parsen und ausführen — und danach auch noch die Daten laden —, bevor er etwas Brauchbares anzeigt. SSR (Rendering auf dem Server, bei jedem Request) und SSG (Prerendering, beim Build) verlagern das erste Rendering woandershin: Der Nutzer bekommt sofort HTML mit Inhalt.
Mit @angular/ssr entscheidest du nicht einmal für die ganze Anwendung, sondern pro Route: Die Datei app.routes.server.ts legt für jeden Pfad fest, ob er RenderMode.Prerender, RenderMode.Server oder RenderMode.Client ist. Das nennt man Hybrid Rendering. Die API für Server-Routen kam mit v19 und ist seit v20 stabil.
- Rendern im Browser (CSR), auf dem Server (SSR) oder beim Build (Prerender) wählst du pro Route, mit
RenderModeinapp.routes.server.ts. - Prerender für Seiten, die für alle gleich aussehen, SSR für personalisierte Seiten oder Daten, die sich oft ändern, CSR für Bereiche hinter dem Login.
- SSR verbessert die erste Anzeige und das, was ein Crawler sieht, bringt aber einen Server zum Betreiben und Code, der auch auf dem Server laufen muss.
- Routen mit Parametern lassen sich prerendern, wenn du ihnen die Liste der Werte gibst, mit
getPrerenderParams. - Das Caching am CDN zählt oft mehr als die Geschwindigkeit des Servers.
SSR ist kein kostenloser Performance-Schalter. Du gewinnst LCP, SEO und korrekte Vorschauen in sozialen Netzwerken; du zahlst mit einem Node-Server, den du betreiben musst, mit Code, der auch ohne window laufen muss, und mit einem Zeitfenster, in dem die Seite sichtbar ist, aber noch nicht auf Klicks reagiert. Die richtige Entscheidung triffst du pro Route, nicht pro Projekt.
Drei Rendering-Modi
CSR · Client | SSR · Server | SSG · Prerender | |
|---|---|---|---|
| Das HTML entsteht | im Browser, nach dem JS | auf dem Server, bei jedem Request | beim Build, einmalig |
| Der Server, pro Request | liefert eine statische Datei (die leere Shell) | startet die Anwendung, holt die Daten, rendert | liefert eine statische Datei |
| TTFB | klein | enthält das Rendering und die dabei aufgerufenen APIs | minimal, besonders von einem CDN |
| LCP | nach JS + Bootstrap + API-Request | sobald das HTML ankommt | sobald das HTML ankommt |
| SEO, Vorschauen | schwach: hängt vom JS ab | ausgezeichnet | ausgezeichnet |
| Inhalt pro Nutzer | ja | ja | nein — für alle identisches HTML |
| Hosting | jedes statische Hosting / CDN | eine Node-Runtime (oder eine andere JS-Runtime) | jedes statische Hosting / CDN |
| Kosten pro Request | ≈ 0 | CPU + Speicher für ein vollständiges Rendering | ≈ 0 |
Was der Server im Server-Modus konkret tut: Für jeden Request erzeugt @angular/ssr eine neue Plattform, ruft die Bootstrap-Funktion aus main.server.ts auf, lässt den Router zur URL navigieren, wartet, bis die Anwendung stabil ist (alle HTTP-Requests und PendingTasks abgeschlossen), serialisiert das DOM zu HTML und zerstört die Anwendung. Die praktische Folge: Der TTFB einer SSR-Seite ist mindestens so lang wie die langsamste API, die beim Rendering aufgerufen wird.
In einer echten Hybrid-Anwendung existieren die Modi nebeneinander: Die Startseite und die Marketing-Seiten werden prerendert, die Suche wird auf dem Server gerendert, und der Kontobereich läuft nur im Browser.
Was bei einem Request passiert
Fünf Momente zählen, in der Reihenfolge, in der der Nutzer sie erlebt:
- TTFB (Time to First Byte) — das erste Byte der HTML-Antwort.
- FCP (First Contentful Paint) — der erste Text oder das erste Bild auf dem Bildschirm.
- LCP (Largest Contentful Paint) — das Hauptelement erscheint (der Titel, das Produktbild). Das ist die Metrik, die der Nutzer als „es ist geladen“ wahrnimmt.
- Hydration — das JS ist angekommen, Angular übernimmt das vorhandene HTML und hängt die Listener an.
- Interaktiv — ein Klick bewirkt tatsächlich etwas. Ab jetzt zählt INP (wie schnell die Seite reagiert).
Bei SSR und SSG gibt es zwischen LCP und „interaktiv“ ein Zeitfenster, in dem die Seite fertig aussieht, die Buttons aber noch keinen Listener haben. Auf einem durchschnittlichen Smartphone mit einem Bundle von einigen hundert KB dauert dieses Fenster leicht 1–3 Sekunden. Starte die Simulation und vergleiche die drei Modi im selben Netzwerk.
SSR: Der TTFB enthält die Arbeit des Servers (der auf die API wartet), danach erscheint der Inhalt sofort. Gelbes Band = Inhalt sichtbar, aber ohne Listener bis zur Hydration.
Beachte, was sich nicht ändert: main.js ist in allen drei Modi gleich groß. SSR verkleinert das JavaScript nicht, die Interaktivität kommt also etwa zur gleichen Zeit wie bei CSR — manchmal sogar etwas später, weil das HTML größer ist und auch die Hydration CPU kostet. Der eigentliche Gewinn ist der LCP. Um das nicht interaktive Fenster zu verkürzen: weniger initiales JS (Lazy Routes, @defer), Incremental Hydration und Event Replay, das die im Fenster gemachten Klicks nicht verliert.
Was ein Crawler sieht
Ein Crawler geht vom empfangenen HTML aus (das, was du mit Quelltext anzeigen siehst, nicht im Elements-Tab). Bei CSR ist dieses HTML für jede URL identisch. Googlebot kann JavaScript ausführen, aber in einer späteren Phase und mit Einschränkungen; Bots, die Vorschauen in Chats und sozialen Netzwerken erzeugen, lesen meist nur das initiale HTML. Ein Detail wird oft übersehen: der Statuscode. Ein statischer Server weiß nicht, dass /produse/9999 nicht existiert, und antwortet deshalb mit 200 — einem „Soft 404“.
HTTP/1.1 200 OK<!doctype html><html lang="de"><head> <title>Shop</title> <link rel="stylesheet" href="styles-5INURTSO.css"></head><body> <app-root></app-root> <script src="main-HG7KQWZB.js" type="module"></script></body></html>
- ✓Korrekter HTTP-Status200
- ✕seitenspezifischer <title>Shop
- ✕meta description / og:*fehlt
- ✕Sichtbarer Text im HTML0 Wörter
- ✕Links <a href> zum Verfolgen0
Dasselbe HTML für jede URL: Der gesamte Inhalt erscheint erst, nachdem JavaScript ausgeführt wurde. Vorschau-Bots führen es nicht aus; Googlebot schon, aber in einer späteren Phase und mit Einschränkungen.
Titel und Meta-Tags setzt du mit den Services Title und Meta aus @angular/platform-browser. Sie arbeiten über DOCUMENT und funktionieren deshalb auch auf dem Server: Was du während des Renderings setzt, landet im gesendeten HTML. Für Seiten ohne Daten reicht die title-Eigenschaft der Route (oder eine eigene TitleStrategy, wenn du ein einheitliches Format willst).
import { Component, computed, effect, inject, input } from '@angular/core';
import { httpResource } from '@angular/common/http';
import { Meta, Title } from '@angular/platform-browser';
interface Product { id: string; name: string; description: string; imageUrl: string }
@Component({
selector: 'app-product-page',
template: `
@if (product.hasValue()) {
<h1>{{ product.value().name }}</h1>
<p>{{ product.value().description }}</p>
}
`,
})
export default class ProductPage {
readonly id = input.required<string>(); // aus der Route produse/:id (withComponentInputBinding)
private readonly title = inject(Title);
private readonly meta = inject(Meta);
protected readonly product = httpResource<Product>(() => `/api/products/${this.id()}`);
constructor() {
// Läuft auch auf dem Server: Die Tags landen im HTML, das an die Crawler geht.
effect(() => {
if (!this.product.hasValue()) return;
const p = this.product.value();
this.title.setTitle(`${p.name} — Shop`);
this.meta.updateTag({ name: 'description', content: p.description });
this.meta.updateTag({ property: 'og:title', content: p.name });
this.meta.updateTag({ property: 'og:description', content: p.description });
this.meta.updateTag({ property: 'og:image', content: p.imageUrl });
});
}
}Packe die Logik in einen Service SeoService.update({ title, description, image, canonical }) und rufe ihn von den Detailseiten aus auf. Für <link rel="canonical"> brauchst du DOCUMENT — Details auf der Seite Server-kompatibler Code.
Setup: was ng add generiert
# neues Projekt, mit SSR + Prerendering von Anfang an konfiguriert
ng new magazin --ssr
# bestehendes Projekt: @angular/ssr, express und die Server-Dateien hinzufügen
ng add @angular/ssr
# Produktions-Build → dist/magazin/browser + dist/magazin/server
ng build
# den Node-Server starten (Script, das ng add in package.json einträgt)
npm run serve:ssr:magazin # = node dist/magazin/server/server.mjsDer Befehl fügt die Abhängigkeiten (@angular/ssr, express), vier Dateien und einige Optionen in angular.json hinzu. Es lohnt sich zu wissen, was jede davon tut, denn du wirst sie anpassen:
server.ts— der Express-Server. Er liefert die statischen Dateien ausdist/…/browseraus und gibt alle übrigen Requests anAngularNodeAppEngine.handle()weiter, das eineResponse(odernull, wenn die URL keine Angular-Route ist) zurückgibt. Der ExportreqHandlerwird vom CLI verwendet:ng serverendert ebenfalls auf dem Server.main.server.ts— die Bootstrap-Funktion, die der Server bei jedem Rendering aufruft.app.config.server.ts—provideServerRendering(withRoutes(serverRoutes)), übermergeApplicationConfigmit der Browser-Konfiguration kombiniert.app.routes.server.ts— der Render-Modus pro Route. Standardmäßig ist allesPrerender.app.config.tserhältprovideClientHydration(), damit der Browser das HTML wiederverwendet, statt es neu aufzubauen.
import {
AngularNodeAppEngine,
createNodeRequestHandler,
isMainModule,
writeResponseToNodeResponse,
} from '@angular/ssr/node';
import express from 'express';
import { join } from 'node:path';
const browserDistFolder = join(import.meta.dirname, '../browser');
const app = express();
const angularApp = new AngularNodeAppEngine(); // eine Instanz für den ganzen Server
// 1. (optional) deine APIs: app.get('/api/{*splat}', (req, res) => { ... });
// 2. Statische Dateien (JS/CSS/Bilder haben einen Hash im Namen → Cache für ein Jahr)
app.use(express.static(browserDistFolder, { maxAge: '1y', index: false, redirect: false }));
// 3. Alles andere: Angular entscheidet — prerendertes HTML, SSR oder die CSR-Shell
app.use((req, res, next) => {
angularApp
.handle(req)
.then((response) => (response ? writeResponseToNodeResponse(response, res) : next()))
.catch(next);
});
if (isMainModule(import.meta.url) || process.env['pm_id']) {
const port = process.env['PORT'] || 4000;
app.listen(port, (error) => {
if (error) throw error;
console.log(`Node Express server listening on http://localhost:${port}`);
});
}
// wird von ng serve / ng build verwendet (und von manchen Serverless-Plattformen)
export const reqHandler = createNodeRequestHandler(app);Ein Detail in server.ts: express.static hat index: false, daher wird nicht einmal / direkt von der Festplatte ausgeliefert. Jede Seite läuft durch Angular, das den für die Route konfigurierten Render-Modus, die Header und den Status anwendet.
Zum Schutz vor SSRF validiert der Server den Host-Header. Ab v22 bekommt ein unbekannter Host 400 Bad Request (vorher fiel der Server auf CSR zurück). ng add setzt "allowedHosts": []: Trage deine Domains in angular.json ein (Wildcards wie *.magazin.ro werden akzeptiert), in die Optionen von AngularNodeAppEngine oder in die Umgebungsvariable NG_ALLOWED_HOSTS. Hinter einem Load Balancer werden die X-Forwarded-*-Header ignoriert, bis du sie mit trustProxyHeaders ausdrücklich erlaubst. Details unter Sicherheit.
Wenn du ein älteres Projekt hast, dessen server.ts CommonEngine verwendet: Diese API ist seit v22 deprecated, der Ersatz ist AngularNodeAppEngine (Node) oder AngularAppEngine (Runtimes auf Basis der Web-API Request/Response).
RenderMode pro Route
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
// Für alle gleich, ändert sich nur beim Deployment → HTML wird beim Build erzeugt (SSG)
{ path: '', renderMode: RenderMode.Prerender },
{ path: 'despre', renderMode: RenderMode.Prerender },
// Öffentlich, indexiert, Daten ändern sich oft → bei jedem Request gerendert (SSR)
{ path: 'cautare', renderMode: RenderMode.Server },
// Hinter dem Login: kein SEO, persönliches HTML → nur im Browser (CSR)
{ path: 'cont/**', renderMode: RenderMode.Client },
// Das Sicherheitsnetz für jede Angular-Route, die oben nicht aufgeführt ist
{ path: '**', renderMode: RenderMode.Server },
];Die Regeln, die in der Praxis zählen:
- Matching: Ein exaktes Segment schlägt einen Parameter (
:id), der wiederum**schlägt.**ist also das Sicherheitsnetz, keine Regel, die „überschreibt“. - Validierung in beide Richtungen: Jede Route im Router muss zu einer Server-Route passen, und jede Server-Route (außer denen, die auf
/**enden) muss im Router existieren. Sonst bricht der Build mit einer eindeutigen Meldung ab. - Der Pfad beginnt nicht mit „/“ —
'/despre'ist ein Build-Fehler. - Was der Browser erhält:
Prerender→ die beim Build erzeugte Datei (mit einemETag);Server→ jetzt gerendertes HTML;Client→index.csr.html, die leere Shell, ebenfalls von deinem Server ausgeliefert.
- Das HTML ist für alle identisch und stabil: Du erzeugst es einmal, beim Build.
- Als statische Datei ausgeliefert (idealerweise von einem CDN): minimaler TTFB, fast keine Kosten pro Request.
- Die Daten ändern sich täglich → du brauchst einen geplanten Build (z. B. nächtlich in der CI), der die Seiten neu erzeugt.
- Tausende Seiten verlängern den Build um Minuten; miss die Build-Dauer und die Größe des Deployments.
import { PrerenderFallback, RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{
path: 'produse/:id',
renderMode: RenderMode.Prerender,
async getPrerenderParams() {
const api = inject(CatalogApi); // synchron, vor dem await
const items = await api.mostVisited(500);
return items.map((x) => ({ id: String(x.id) }));
},
fallback: PrerenderFallback.Server,
},
// ...
];Header, Status und Redirects
Jede ServerRoute kann headers und status haben. Die Ausnahme: Prerender-Routen akzeptieren headers, aber nicht status — der Typ ServerRoutePrerender schließt ihn aus, weil die Datei nur einmal erzeugt wird. Die Header einer prerenderten Seite gelten nur, wenn sie über den Node-Server ausgeliefert wird, nicht von einem statischen Hosting.
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{
// Öffentliches HTML: Das CDN darf es 60 s behalten und die alte Version ausliefern, während es aktualisiert
path: 'cautare',
renderMode: RenderMode.Server,
headers: { 'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300' },
},
{
// permanenter Redirect: { path: 'oferte', redirectTo: 'promotii' } in app.routes.ts
path: 'oferte',
renderMode: RenderMode.Server,
status: 301, // ohne status: 302
},
{
// Prerender: headers ja, status NEIN (der Typ ServerRoutePrerender schließt ihn aus)
path: 'despre',
renderMode: RenderMode.Prerender,
headers: { 'X-Frame-Options': 'DENY' },
},
{ path: '**', renderMode: RenderMode.Server },
];Ein redirectTo aus dem Router wird auf dem Server zu einem echten HTTP-Redirect (standardmäßig 302; du kannst status: 301, 303, 307 oder 308 setzen), beantwortet, ohne die Anwendung zu rendern. Ebenso unterscheidet sich die finale URL von der angefragten, wenn ein Guard während des Renderings umleitet (siehe Guards & Resolver), und der Server antwortet mit einem Redirect. Mit outputMode: 'static', wo es keinen Server gibt, werden Redirects als „Soft Redirects“ mit einem <meta http-equiv="refresh"> im HTML prerendert.
Status und Header zur Laufzeit festlegen
Hängt der Status von Daten ab (das Produkt existiert nicht), setzt du ihn in der Komponente über RESPONSE_INIT. Für Cookies, Sprache oder User-Agent liest du den Request über REQUEST. Beide stammen aus @angular/core und sind im Browser, beim Prerendering und beim Build null — behandle den null-Fall immer.
import { Component, RESPONSE_INIT, inject } from '@angular/core';
@Component({
selector: 'app-not-found',
template: `<h1>Seite nicht gefunden</h1>`,
})
export default class NotFound {
constructor() {
// RESPONSE_INIT ist im Browser, beim Prerendering und beim Build null → immer prüfen.
const response = inject(RESPONSE_INIT);
if (response) response.status = 404; // der Crawler sieht 404, nicht „200 mit einem Fehlertext“
}
}Prerendering mit Parametern
Routen ohne Parameter werden automatisch erkannt und prerendert. Bei produse/:id kann Angular nicht erraten, welche IDs existieren, also gibst du ihm eine Funktion getPrerenderParams. Sie läuft beim Build, in einem Injection-Kontext (du kannst inject() verwenden), und liefert eine Liste von Objekten Parameter → Wert. Eine Prerender-Route mit Parametern, aber ohne getPrerenderParams, bricht den Build ab.
import { inject } from '@angular/core';
import { PrerenderFallback, RenderMode, ServerRoute } from '@angular/ssr';
import { ProductApi } from './products/product-api';
export const serverRoutes: ServerRoute[] = [
{
path: 'produse/:id',
renderMode: RenderMode.Prerender,
async getPrerenderParams() {
const api = inject(ProductApi); // ✓ synchrones inject(), VOR jedem await
const ids = await api.topProductIds(500); // läuft beim BUILD, nicht beim Request
return ids.map((id) => ({ id: String(id) })); // die Werte müssen Strings sein
},
// /produse/9999 wurde nicht prerendert → was tun wir beim Request?
fallback: PrerenderFallback.Server, // Standard: SSR beim Request
},
{
path: 'blog/:slug/**', // Parameter + Catch-all
renderMode: RenderMode.Prerender,
async getPrerenderParams() {
return [{ slug: 'ssr', '**': 'partea/1' }]; // → /blog/ssr/partea/1
},
},
{ path: '**', renderMode: RenderMode.Server },
];Was mit einer URL passiert, die beim Build nicht erzeugt wurde, hängt von fallback ab:
| PrerenderFallback | Für /produse/9999 (nicht prerendert) | Wann du ihn wählst |
|---|---|---|
Server (Standard) | auf Anfrage auf dem Server gerendert | großer Katalog: die Top N prerendern, den Rest auf Anfrage |
Client | erhält die CSR-Shell, im Browser gerendert | du willst SEO nur für die erzeugten Seiten und kein SSR |
None | Angular antwortet nicht (der Request läuft weiter, meist ein 404) | die Liste aus dem Build ist per Definition vollständig |
inject() funktioniert nur synchron, am Anfang der Funktion. Ein inject() nach einem await befindet sich nicht mehr in einem Injection-Kontext und wirft einen Fehler. Hole alle Abhängigkeiten in den ersten Zeilen und führe erst danach die Requests aus.
Die Kosten fallen beim Build an: Jede URL ist ein vollständiges Rendering. Zehntausend Produkte bedeuten zehntausend Renderings bei jedem Deployment und zehntausend hochzuladende Dateien. Ändern sich die Daten täglich, brauchst du außerdem einen geplanten Build, der sie neu erzeugt.
Deployment, Cache und Kosten
outputMode in angular.json bestimmt, was der Build erzeugt. Mit "server" (was ng add setzt) erhältst du dist/app/browser plus dist/app/server/server.mjs und brauchst einen Node-Prozess. Mit "static" erhältst du nur Dateien: prerenderte Seiten und die CSR-Shell, auf jedem CDN deploybar. Eine RenderMode.Server-Route im statischen Modus ist ein Build-Fehler.
{
"projects": {
"magazin": {
"architect": {
"build": {
"options": {
"outputMode": "static"
}
}
}
}
}
}Die Cache-Schichten
| Was | Wie | Achtung |
|---|---|---|
| JS, CSS, Bilder mit Hash | maxAge: '1y' in express.static / CDN | die Namen ändern sich bei jedem Build, ein langer Cache ist also sicher |
| Prerendertes HTML | statische Datei, ETag → 304 Not Modified | ändert sich nur beim Deployment |
| Öffentliches SSR-HTML | headers pro Route: public, s-maxage=60, stale-while-revalidate=300 | nur für Inhalte, die für alle identisch sind |
| Persönliches SSR-HTML | private, no-store | niemals public: das CDN würde es einem anderen Nutzer ausliefern |
| HTTP-Daten beim Rendering | Transfer Cache: GET/HEAD-Antworten landen im HTML und werden bei der Hydration wiederverwendet | Requests mit Authorization/Cookie sind standardmäßig ausgeschlossen |
Der Transfer Cache ist der Grund, warum SSR in der Timeline den API-Request nicht mehr vom Browser aus macht: Die vom HttpClient auf dem Server geholten Daten werden in <script id="ng-state"> serialisiert und beim Start gelesen. Seine Optionen (withHttpTransferCacheOptions, withNoHttpTransferCache) stehen auf der Seite Hydration. Außerdem gilt: Auf dem Server ist mit dem Standard-Fetch-Backend jede HTTP-Antwort auf 1 MB begrenzt (Fehler NG02825); das Limit änderst du mit provideServerRendering({ maxResponseBodySize }, …).
Betriebskosten
- CPU pro Request: Jedes SSR-Rendering ist ein vollständiger Bootstrap. Ohne CDN-Cache bezahlst du den Bot-Traffic in Servern.
- TTFB an das Backend gekoppelt: Der Server wartet auf die Stabilität, daher verlangsamt eine langsame API alle deine Seiten. Setze Timeouts und verschiebe sekundäre Daten in @defer (auf dem Server rendert ein Block ohne
hydrate-Trigger nur den Platzhalter). - Zwischen Requests geteilter Speicher: Der Code wird einmal pro Prozess ausgewertet, daher gelten Variablen auf Modulebene und
useValue-Provider für alle Nutzer gemeinsam. - Ein System mehr in Produktion: Logs, Monitoring, Skalierung, Node-Version, Cold Start bei Serverless.
import { InjectionToken } from '@angular/core';
// ✗ Zustand auf Modulebene: Auf dem Server wird das Modul EINMAL ausgewertet,
// die Variable gilt also für alle Requests (und alle Nutzer).
let currentUser: string | null = null;
// ✗ useValue auf Anwendungsebene: derselbe Wert bis zum Neustart des Servers.
export const RENDERED_AT = new InjectionToken<number>('RENDERED_AT');
const bad = { provide: RENDERED_AT, useValue: Date.now() };
// ✓ useFactory: läuft bei jedem Rendering (jeder Request hat seinen eigenen Injector).
const good = { provide: RENDERED_AT, useFactory: () => Date.now() };
// ✓ Zustand pro Nutzer gehört in Services (pro Request instanziiert), nicht in globale Variablen.Wann ja, wann nein
- Öffentliche Seiten, die indexiert und geteilt werden sollen: Produkte, Artikel, Landing Pages.
Prerender, wenn du sie beim Build erzeugen kannst, sonstServer. - Der LCP schlägt sich in Geld nieder (E-Commerce, Medien), und manche Nutzer haben einfache Smartphones und Netze.
- Stabile Inhalte (Dokumentation, Blog, Marketing):
Prerender+outputMode: 'static', ganz ohne Server, den du warten musst. - Öffentliche Inhalte mit frischen Daten und viel Traffic:
Server+ ein kurzer Cache im CDN. - Du brauchst korrekte Statuscodes (404, 301) für SEO.
- Anwendungen hinter dem Login (Dashboard, Admin, Backoffice):
Client. Investiere in ein kleines Bundle, Lazy Routes und @defer. - Du hast niemanden, der einen Node-Server betreiben kann: Bleib bei CSR oder bei
PrerendermitoutputMode: 'static'. - „Die Anwendung ist nach dem Laden langsam“: SSR hilft nach der Hydration nicht. Das Problem liegt im JS oder in der Change Detection → Performance, Change Detection.
- Routen, die auf Browser-APIs aufbauen (Karten, Canvas, Editoren):
RenderMode.Clientfür diese Route. - Personalisiertes HTML bei viel Traffic: nicht pro Nutzer rendern. Den gemeinsamen Teil prerendern und die persönlichen Teile im Browser laden.
Häufige Stolperfallen
Code, der im Konstruktor, in ngOnInit oder auf Modulebene auf window, document oder localStorage zugreift, stürzt auf dem Server ab. Die Lösung: afterNextRender, das DOCUMENT-Token und getrennte Service-Implementierungen für Server und Browser — alles auf der Seite Server-kompatibler Code.
Eine Variable let currentUser auf Modulebene oder ein beim Start berechnetes useValue lebt so lange wie der Node-Prozess, nicht wie der Request. Lege den Zustand in Services ab (pro Rendering instanziiert) und nutze useFactory für Werte, die pro Request berechnet werden.
- Öffentlicher Cache auf personalisiertem HTML → ein Nutzer sieht die Seite eines anderen. Verwende bei
Server-Routen mit persönlichen DatenCache-Control: private, no-store. - Leeres
allowedHostsin Produktion → 400 auf allen Seiten (v22). Konfiguriere die Domains oderNG_ALLOWED_HOSTS. - „Soft 404“: Die „Not found“-Komponente antwortet mit 200. Setze
status = 404überRESPONSE_INIT. - Falscher SPA-Fallback bei statischem Hosting: Mit Prerendering ist
index.htmldie Seite der Route/.Client-Routen müssen aufindex.csr.htmlzurückfallen. - Die Anwendung wird nicht stabil: Ein
PendingTaskohne Cleanup (oder, mit Zone.js, ein in der Angular-Zone gestartetessetInterval) hält das Rendering an, und der Request endet nie. - Ein CDN, das das HTML minifiziert und Kommentare entfernt → die Hydration scheitert mit NG0507. Die Kommentare von Angular werden gebraucht; deaktiviere die Option im CDN.
- „Ich habe SSR eingebaut, und die TTI ist gleich geblieben“ — das ist normal: Das JS ist dasselbe. Miss den LCP, nicht die TTI, und reduziere das initiale JS separat.
Spickzettel
// ng new app --ssr | ng add @angular/ssr | ng build | node dist/app/server/server.mjs
// app.config.server.ts
provideServerRendering(withRoutes(serverRoutes));
provideServerRendering({ maxResponseBodySize: 5 * 1024 * 1024 }, withRoutes(serverRoutes)); // Standard 1 MB
// app.config.ts (browser)
provideClientHydration();
// app.routes.server.ts — import { RenderMode, PrerenderFallback, ServerRoute } from '@angular/ssr'
export const serverRoutes: ServerRoute[] = [
{ path: 'despre', renderMode: RenderMode.Prerender }, // HTML beim Build (SSG)
{
path: 'produse/:id',
renderMode: RenderMode.Prerender,
getPrerenderParams: async () => [{ id: '1' }, { id: '2' }], // läuft beim Build
fallback: PrerenderFallback.Server, // | Client | None — Standard: Server
},
{ path: 'cautare', renderMode: RenderMode.Server, headers: { 'Cache-Control': 'no-store' } },
{ path: 'cont/**', renderMode: RenderMode.Client }, // leere Shell, im Browser gerendert
{ path: '**', renderMode: RenderMode.Server }, // das Sicherheitsnetz
];
// @angular/core — alle null im Browser, beim Prerendering und beim Build
inject(REQUEST); // Request: Header, Cookies, URL
inject(RESPONSE_INIT); // ResponseInit: dynamisch gesetzter Status / Header
inject(REQUEST_CONTEXT); // das zweite Argument von angularApp.handle(req, context)
// angular.json → architect.build.options
// "outputMode": "server" | "static" static = kein Node: nur Prerender + Client
// "security": { "allowedHosts": ["exemplu.ro"] } unbekannter Host → 400 Bad Request (v22)