Bei SSR oder Prerendering laufen deine Komponenten zweimal: einmal in Node — bei jedem Request oder zur Build-Zeit — um HTML zu erzeugen, und noch einmal im Browser, während der Hydration. Konstruktoren, Feld-Initialisierungen, ngOnInit, effect(), Services, Interceptors und Guards laufen auf beiden Plattformen. Nur wenige APIs sind dem Browser vorbehalten: afterNextRender, afterEveryRender, afterRenderEffect und natürlich die Events des Nutzers.
Auf dem Server gibt es kein window, kein localStorage, kein Layout und keine Observer. Es gibt ein Dokument — von Domino emuliert — aber nur über inject(DOCUMENT). Code, der „für den Browser“ geschrieben wurde, funktioniert in ng serve ohne SSR einwandfrei und scheitert erst im Server-Build. Oder, schlimmer, er scheitert nicht, sondern liefert falsches HTML: das falsche Theme, fehlende Daten, jeder Request doppelt ausgeführt.
- Auf dem Server gibt es kein
window,documentoderlocalStorage. Code, der sie braucht, läuft inafterNextRender. - Was du auf dem Server geladen hast, kommt über den HTTP-Transfer-Cache in den Browser, sodass
HttpClientden Request bei der Hydration nicht wiederholt. - Der Server schickt die Seite erst, wenn die App keine offenen Tasks mehr hat (
PendingTasks). Ein Timer, der nie stoppt, hält den Request offen. - Eine nicht existierende Seite muss mit Status 404 antworten, nicht nur „nicht gefunden“ anzeigen.
- Variablen auf Modulebene teilen sich alle Requests auf dem Server. Der Zustand eines Nutzers gehört in Services, nicht dorthin.
Was es auf dem Server nicht gibt
Der Angular-Server läuft in Node, mit einem von Domino emulierten DOM (enthalten in @angular/platform-server). Der wichtige Unterschied: Die Globals des Browsers fehlen komplett und lösen einen ReferenceError aus, während das per DI bezogene Dokument zwar eine Struktur, aber kein Layout hat. Die Tabelle wurde mit Node 22/24 und mit dem Domino aus @angular/platform-server 22.2.2 verifiziert.
| API | Auf dem Server | Stattdessen verwenden |
|---|---|---|
window, document (global), location, history | existiert nicht: ReferenceError: window is not defined | inject(DOCUMENT), Router, Location |
localStorage, sessionStorage | existieren nicht | afterNextRender; für alles, was beim Rendern zählt, ein Cookie aus REQUEST |
matchMedia, innerWidth | fehlen auch auf document.defaultView (dem Domino-Window) | CSS-Media-Queries; BreakpointObserver aus dem CDK (auf dem Server treffen die Queries nicht zu); afterNextRender |
IntersectionObserver, ResizeObserver, MutationObserver, requestAnimationFrame | existieren nicht | @defer (on viewport); afterNextRender + DestroyRef |
getBoundingClientRect(), offsetWidth, scrollHeight | Domino-Elemente haben sie nicht (TypeError oder undefined) | afterNextRender({ read }) |
document.cookie | der Getter wirft Error: NotYetImplemented | inject(REQUEST)?.headers.get('cookie') |
navigator | existiert ab Node 21+, gehört aber dem Server (userAgent: 'Node.js/22') | die Header aus REQUEST; isPlatformBrowser |
setTimeout, setInterval, fetch | existieren, aber Timer stoppen nicht von selbst, und fetch akzeptiert keine relativen URLs | afterNextRender + DestroyRef; HttpClient |
Was wo läuft
| Code | Server | Browser |
|---|---|---|
Feld-Initialisierungen, Konstruktor, ngOnInit, ngOnChanges | ja | ja |
effect(), computed(), das Template, Pipes | ja | ja |
| Services, Interceptors, Guards, Resolver | ja — bei jedem Request neue Instanzen | ja |
ngOnDestroy, DestroyRef.onDestroy | ja — der Server zerstört die Anwendung nach dem Rendern | ja |
afterNextRender, afterEveryRender, afterRenderEffect | nein (NOOP) | ja |
Event-Listener: (click), (input)… | nein — auf dem Server klickt niemand | ja |
@defer ohne hydrate | nur der @placeholder | hier starten die Trigger |
theme = 'light';
constructor() {
this.theme = localStorage.getItem('theme') ?? 'light';
}ReferenceError: localStorage is not definedWarum: Der Konstruktor läuft auch auf dem Server, und in Node gibt es kein localStorage. Selbst wenn es eines gäbe, hätte der Server keinen Zugriff auf den Storage des Nutzers.
readonly theme = signal('light');
constructor() {
afterNextRender(() =>
this.theme.set(localStorage.getItem('theme') ?? 'light'));
}
// oder: ein auf dem Server aus inject(REQUEST) gelesenes CookiengOnInit oder in effect(). Alles, was das DOM, das Layout, den Storage oder eine Browser-Bibliothek berührt, gehört in afterNextRender / afterRenderEffect — oder hinter einen Service mit separater Server-Implementierung.afterNextRender und die Phasen
afterNextRender und afterEveryRender (stabil seit v20, als afterRender in afterEveryRender umbenannt wurde) registrieren Callbacks, die laufen, nachdem Angular das Rendern abgeschlossen hat — nur im Browser. Auf dem Server sind sie NOOPs: Der Callback wird nicht einmal registriert. Deshalb sind sie der Standardort für jeden Code, der das DOM, das Layout oder die Window-APIs berührt. Sie müssen in einem Injection Context (Konstruktor, Feld-Initialisierung) oder mit der Option injector aufgerufen werden, und sie räumen von selbst auf, wenn die Komponente zerstört wird.
Du kannst ihnen eine Funktion oder ein Objekt mit Phasen übergeben. Die Phasen laufen in der Reihenfolge earlyRead → write → mixedReadWrite → read, und jede erhält den Wert, den die vorherige Phase zurückgibt. Die Trennung gibt es, damit Angular die Lese- und Schreibzugriffe der ganzen Anwendung bündeln kann: Ein Layout-Lesezugriff nach einem Schreibzugriff zwingt den Browser, das Layout sofort neu zu berechnen (Layout Thrashing). Eine einfache Funktion läuft in mixedReadWrite, der Phase, die du laut Dokumentation vermeiden sollst.
| Phase | Erlaubt | Beispiel |
|---|---|---|
earlyRead | nur Lesen, vor den Schreibzugriffen | du misst etwas, wovon ein Schreibzugriff abhängt (eigenes Layout) |
write | nur Schreiben | Styles, Initialisierung einer Bibliothek |
mixedReadWrite | Lesen und Schreiben gemischt | nur, wenn sich die Arbeit nicht aufteilen lässt |
read | nur Lesen, nach den Schreibzugriffen | du misst das Endergebnis und legst es in einem Signal ab |
import { Component, ElementRef, afterNextRender, input, viewChild } from '@angular/core';
/** Verkleinert den Titel, bis er in eine Zeile passt. Auf dem Server passiert nichts. */
@Component({
selector: 'app-fit-title',
template: `<h2 #title class="fit">{{ text() }}</h2>`,
})
export class FitTitle {
readonly text = input.required<string>();
private readonly title = viewChild.required<ElementRef<HTMLElement>>('title');
constructor() {
// Einmal, nach dem ersten Rendern, NUR im Browser (auf dem Server ein No-op).
afterNextRender({
// 1. earlyRead: das Layout lesen, bevor geschrieben wird
earlyRead: () => {
const el = this.title().nativeElement;
return Math.min(1, el.clientWidth / el.scrollWidth); // 1 = passt
},
// 2. write: erhält das Ergebnis der vorherigen Phase und schreibt nur
write: (ratio) => {
this.title().nativeElement.style.fontSize = `${Math.floor(32 * ratio)}px`;
},
});
}
}| Ausdruck | Hier (live) | Auf dem Server wäre es |
|---|---|---|
inject(PLATFORM_ID) | 'server' | 'server' |
isPlatformBrowser(id) | false | false |
isPlatformServer(id) | true | true |
inject(REQUEST) | null | Request (url, headers, cookie) |
inject(RESPONSE_INIT) | null | { status, headers } |
- +0.2 msconstructorisConnected=false, Breite=0px, Kinder=0, Binding=null
- +0.5 msngOnInitisConnected=true, Breite=0px, Kinder=0, Binding=""
Auf dem Server würden nur die Zeilen constructor und ngOnInit laufen (ohne Layout: die Breiten gäbe es nicht); afterNextRender und afterEveryRender werden ignoriert.
afterEveryRender oder afterRenderEffect
afterEveryRender läuft nach jedem Rendern der Anwendung, auch wenn sich etwas in einer ganz anderen Komponente geändert hat. Hängt deine Arbeit von bestimmten Signals ab, ist afterRenderEffect die günstigere Option: ein Effect, der nach dem Rendern nur läuft, wenn sich seine Abhängigkeiten ändern (und wieder nur im Browser). Der Unterschied zu effect(): effect läuft auch auf dem Server und hat keine Lese-/Schreib-Phasen.
import { Component, ElementRef, afterRenderEffect, input, viewChild } from '@angular/core';
@Component({
selector: 'app-chat-log',
template: `
<div #scroller class="messages">
@for (m of messages(); track m.id) { <p>{{ m.text }}</p> }
</div>
`,
})
export class ChatLog {
readonly messages = input.required<readonly { id: number; text: string }[]>();
private readonly scroller = viewChild.required<ElementRef<HTMLElement>>('scroller');
constructor() {
// ✗ würde nach JEDEM Rendern der Anwendung laufen, auch wenn sich die Liste nicht geändert hat:
// afterEveryRender({ write: () => this.scrollToBottom() });
// ✓ läuft nach dem Rendern nur, wenn sich die gelesenen Signals geändert haben (weiterhin nur im Browser)
afterRenderEffect({
write: () => {
this.messages(); // Abhängigkeit
this.scrollToBottom();
},
});
}
private scrollToBottom(): void {
const el = this.scroller().nativeElement;
el.scrollTop = el.scrollHeight;
}
}afterNextRender-Callback läuft. Lies und schreibe das DOM vorsichtig und verschiebe keine Knoten, die Angular rendert — sonst treten die Mismatch-Fehler auf, die in Hydration beschrieben sind.Bibliotheken, die window berühren
Viele Bibliotheken (Karten, Diagramme, Rich-Text-Editoren) greifen schon beim Import auf window zu. Der Server stürzt ab, wenn er das Modul lädt, noch bevor eine Komponente läuft, und keine Prüfung im Konstruktor rettet dich mehr. Die Lösung ist ein dynamischer import() in afterNextRender; als Bonus landet die Bibliothek in einem eigenen Chunk. Für ganze Komponenten macht @defer dasselbe deklarativ.
import { Component, DestroyRef, ElementRef, afterNextRender, inject, viewChild } from '@angular/core';
// ✗ import * as L from 'leaflet'; — das Modul berührt window schon beim Import:
// der Server stürzt beim Laden der Datei ab, noch bevor eine Komponente läuft.
@Component({
selector: 'app-store-map',
template: `<div #host class="map" aria-label="Karte der Geschäfte"></div>`,
// eine feste Höhe reserviert den Platz auch im Server-HTML → kein Layout Shift
styles: `.map { height: 320px; }`,
})
export class StoreMap {
private readonly host = viewChild.required<ElementRef<HTMLElement>>('host');
private readonly destroyRef = inject(DestroyRef); // inject() nur synchron, nicht nach await
constructor() {
afterNextRender(async () => {
const L = await import('leaflet'); // eigener Chunk, nur im Browser geladen
if (this.destroyRef.destroyed) return; // der Nutzer ist inzwischen weg
const map = L.map(this.host().nativeElement).setView([44.4268, 26.1025], 12);
this.destroyRef.onDestroy(() => map.remove());
});
}
}Schreibt die Bibliothek das von Angular gerenderte DOM um, meldet die Hydration Mismatches. ngSkipHydration an der Host-Komponente stoppt die Hydration nur für diese — das ist ein Pflaster, keine Lösung.
DOCUMENT, PLATFORM_ID und Platform-Provider
inject(DOCUMENT) — seit v20 aus @angular/core; @angular/common exportiert es erneut — liefert dir auf jeder Plattform das aktuelle Dokument. Auf dem Server ist es das Domino-Dokument des Requests: Du kannst die Struktur lesen und schreiben (title, <meta>, <link rel="canonical">, Attribute auf <html>), und die Änderungen landen im gesendeten HTML. Layout, Cookies oder matchMedia gibt es nicht.
import { DOCUMENT, Service, inject } from '@angular/core';
@Service()
export class CanonicalLink {
private readonly document = inject(DOCUMENT); // auf dem Server: das Domino-Dokument des Requests
/** Läuft auch auf dem Server: Das Tag landet im HTML, das Crawler sehen. */
set(url: string): void {
let link = this.document.head.querySelector<HTMLLinkElement>('link[rel="canonical"]');
if (!link) {
link = this.document.createElement('link');
link.rel = 'canonical';
this.document.head.appendChild(link);
}
link.href = url;
}
}
// Für <title> und <meta> gibt es in @angular/platform-browser bereits SSR-sichere Services:
// inject(Title).setTitle('Phone X — Store');
// inject(Meta).updateTag({ name: 'description', content: '…' });inject(PLATFORM_ID) liefert 'browser' oder 'server', und du prüfst es mit isPlatformBrowser / isPlatformServer aus @angular/common. Das funktioniert, aber der offizielle Leitfaden empfiehlt Platform-Provider statt Laufzeitprüfungen, aus zwei Gründen: Die Prüfungen vervielfachen sich im ganzen Code, und in einem @if im Template eingesetzt erzeugen sie auf dem Server und im Browser unterschiedliche Inhalte, also Layout Shift und Hydration-Fehler. Heb es dir für Seiteneffekte auf, die nicht ändern, was gerendert wird.
import { isPlatformBrowser } from '@angular/common';
import { PLATFORM_ID, Service, inject } from '@angular/core';
@Service()
export class Analytics {
// PLATFORM_ID ist 'browser' oder 'server'; vergleiche es nicht von Hand, nimm die Helfer aus @angular/common
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
track(event: string): void {
if (!this.isBrowser) return; // ok: ein Seiteneffekt, der nicht ändert, was gerendert wird
navigator.sendBeacon('/collect', JSON.stringify({ event, at: Date.now() }));
}
}
// ✗ NICHT so — Server und Browser rendern unterschiedliche Inhalte → Layout Shift und Hydration-Fehler:
// @if (isBrowser) { <app-recommendations /> } @else { <p>Lädt…</p> }
// ✓ Nimm @defer (on viewport) oder afterNextRender für das, was nur im Browser Sinn ergibt.Brauchen die Plattformen unterschiedliches Verhalten, ist der saubere Weg ein Vertrag mit zwei Implementierungen: Die Komponenten injizieren die abstrakte Klasse, app.config.ts stellt die Browser-Implementierung bereit, und app.config.server.ts überschreibt sie. Kein if im Geschäftscode — dasselbe Prinzip wie in Providers und Injection Tokens.
import { Injectable } from '@angular/core';
// Der Vertrag: Komponenten hängen nur von der abstrakten Klasse ab.
export abstract class AnalyticsService {
abstract trackEvent(name: string): void;
}
@Injectable()
export class BrowserAnalyticsService implements AnalyticsService {
trackEvent(name: string): void {
navigator.sendBeacon('/collect', name); // Browser-API — hier erlaubt
}
}
@Injectable()
export class ServerAnalyticsService implements AnalyticsService {
trackEvent(_name: string): void {
// auf dem Server: nichts (oder ein Log), keine Browser-APIs
}
}TransferState
Ohne besondere Maßnahmen lädt eine SSR-Seite ihre Daten zweimal: einmal auf dem Server, um das HTML zu rendern, und noch einmal im Browser, weil die Komponenten bei der Hydration bei null anfangen, mit leerem Zustand. Das Ergebnis: ein zusätzlicher Request und der Inhalt vom Server, der kurz durch ein Skeleton ersetzt wird.
TransferState ist ein Schlüssel-Wert-Dictionary, das im HTML mitreist. Auf dem Server schreibst du hinein; vor der Serialisierung ruft Angular toJson() auf und legt das Ergebnis in ein <script id="ng-state" type="application/json"> am Ende von body — nur, wenn es nicht leer ist. Im Browser liest TransferState beim ersten Injizieren das Script wieder ein. Die Schlüssel entstehen mit makeStateKey<T>('…'), was zugleich den Typ des Werts bindet. Die vollständige API: get(key, default), set, remove, hasKey, isEmpty, onSerialize, toJson.
import { isPlatformServer } from '@angular/common';
import { PLATFORM_ID, Service, TransferState, inject, makeStateKey } from '@angular/core';
export interface Article { slug: string; title: string; body: string }
// Typisierter Schlüssel: TransferState weiß, dass darunter ein Article liegt
const articleKey = (slug: string) => makeStateKey<Article>(`article:${slug}`);
@Service()
export class ArticleStore {
private readonly cms = inject(CmsClient); // SDK mit eigenem fetch → läuft NICHT über HttpClient
private readonly state = inject(TransferState);
private readonly isServer = isPlatformServer(inject(PLATFORM_ID));
async load(slug: string): Promise<Article> {
const key = articleKey(slug);
const cached = this.state.get(key, null);
if (cached) {
this.state.remove(key); // nur einmal verwendet: spätere Navigationen holen frische Daten
return cached;
}
const article = await this.cms.getArticle(slug);
if (this.isServer) this.state.set(key, article); // landet in <script id="ng-state">
return article;
}
}Vom Server gesendetes HTML
Noch nichts. Starte das Rendern auf dem Server.
Was der Nutzer sieht
/api/products/3Die Werte laufen durch JSON.stringify / JSON.parse: Ein Date wird zum String, eine Map und ein Set werden zu {}, Klasseninstanzen verlieren ihre Methoden. Übertrage einfache DTOs (siehe Interfaces & Models). Alles, was du dort ablegst, ist im Seitenquelltext sichtbar und macht das HTML schwerer: Die Demo zeigt, wie viele Zeichen es hinzufügt.
HttpClient werden automatisch übertragen (nächster Abschnitt). Der manuelle TransferState ist für Daten, die nicht über HttpClient laufen: SDKs (Firebase, ein CMS), ein direktes fetch, teure Berechnungen, die einmal auf dem Server erledigt werden. Für Resources gibt es ab v22 außerdem resource({ id }).Der HTTP-Transfer-Cache
provideClientHydration() startet standardmäßig einen Interceptor, der genau das obige Muster auf HttpClient anwendet. Auf dem Server speichert er die Responses im TransferState, unter einem Schlüssel, der als SHA-256-Hash aus Methode, Response-Typ, URL, Body und Parametern berechnet wird. Im Browser liefert er sie aus dem Cache aus — aber nur, bis die Anwendung zum ersten Mal stabil wird; danach geht alles wie gewohnt ans Netzwerk. httpResource läuft über HttpClient, profitiert also automatisch, ebenso deine Interceptors (siehe HTTP).
In 22.2.2 werden standardmäßig nur GET- und HEAD-Requests gecacht, die diese Bedingungen erfüllen:
- sie haben keine Header
Authorization,Proxy-AuthorizationoderCookie; - sie werden nicht mit
withCredentialsoder mitcredentials: 'include'/'same-origin'gesendet; - sie haben kein
Cache-Controlmitno-store,no-cacheoderprivate(weder im Request noch in der Response) und verwenden nichtcache: 'no-store'/'no-cache'; - ihre Response enthält kein
Set-Cookie.
import { ApplicationConfig } from '@angular/core';
import { provideClientHydration, withHttpTransferCacheOptions } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
// Der Transfer-Cache ist mit provideClientHydration() standardmäßig AN;
// withHttpTransferCacheOptions justiert ihn nur.
provideClientHydration(
withHttpTransferCacheOptions({
// welche Response-Header gespeichert werden (Standard: keine)
includeHeaders: ['ETag'],
// false → der Request wird nicht gecacht (nutzerbezogene oder oft wechselnde Daten)
filter: (req) => !req.url.includes('/api/cart'),
// lesende POSTs (z. B. GraphQL) — nur wenn sie idempotent sind
includePostRequests: true,
// Standard false: Requests mit Authorization / Proxy-Authorization / Cookie werden übersprungen
includeRequestsWithAuthHeaders: false,
}),
),
],
};| Option | Standard | Wann ändern |
|---|---|---|
filter(req) | alle infrage kommenden Requests | du nimmst nutzerbezogene oder sehr flüchtige Endpoints heraus (false = nicht gecacht) |
includeHeaders | kein Header | der Browser braucht ETag, Link… Niemals sensible Header |
includePostRequests | false | ein POST für Lesezugriffe (GraphQL), nur wenn er idempotent ist |
includeRequestsWithAuthHeaders | false | der Auth-Header ändert die Response nicht (ein öffentlicher Schlüssel) |
includeRequestsWithCredentials | false | Responses mit Credentials sind garantiert öffentlich |
includeNonCacheableRequests | false | du ignorierst Cache-Control / Set-Cookie bewusst (selten) |
Resources, die HttpClient nicht verwenden v22
Ab v22 akzeptieren resource() und rxResource() eine id. Der auf dem Server aufgelöste Wert wird unter dieser id in den TransferState geschrieben, und im Browser startet die Resource direkt im Zustand resolved, ohne den Loader erneut aufzurufen — wieder nur, solange der Hydration-Cache aktiv ist, also bis zur ersten Stabilität, und nur mit provideClientHydration() in der Konfiguration.
import { Component, inject, resource } from '@angular/core';
@Component({ selector: 'app-weather', template: `{{ forecast.value()?.summary }}` })
export class Weather {
private readonly sdk = inject(WeatherSdk); // nicht HttpClient → der HTTP-Cache sieht es nicht
// v22: Mit einer id wird der auf dem Server aufgelöste Wert in den TransferState gelegt und bei der Hydration wiederverwendet.
// Die id muss auf der Seite eindeutig und auf dem Server und im Browser identisch sein.
protected readonly forecast = resource({
id: 'weather-bucharest',
loader: () => this.sdk.forecast('Bucharest'),
});
}Unterschiedliche Origins auf dem Server und im Browser
Spricht der Server die API unter einer internen Adresse an und der Browser unter der öffentlichen, unterscheiden sich die URLs, und die Cache-Schlüssel passen nicht mehr zusammen. HTTP_TRANSFER_CACHE_ORIGIN_MAP (aus @angular/common/http) sagt dem Server, er soll den Schlüssel so berechnen, als hätte er die öffentliche Origin verwendet. Er wird ausschließlich in der Server-Konfiguration deklariert: Findet Angular ihn im Browser, wirft es den Fehler NG02803.
// api-base-url.ts — im Browser: die öffentliche Origin
export const API_BASE_URL = new InjectionToken<string>('API_BASE_URL', {
factory: () => 'https://api.magazin.ro',
});
// app.config.server.ts (Fragment) — auf dem Server: das interne Netz, ohne Umweg über den Load Balancer
providers: [
provideServerRendering(withRoutes(serverRoutes)),
{ provide: API_BASE_URL, useValue: 'http://catalog.internal:8080' },
// dieselben Pfade auf beiden Origins → die Schlüssel im Transfer-Cache stimmen überein
// (nur in der Server-Konfiguration; im Browser wirft Angular einen Fehler, wenn es ihn findet)
{
provide: HTTP_TRANSFER_CACHE_ORIGIN_MAP,
useValue: { 'http://catalog.internal:8080': 'https://api.magazin.ro' },
},
];Noch ein Detail ab v22, wenn HttpClient standardmäßig das fetch-Backend verwendet: Auf dem Server ist jeder Response-Body auf 1 MB begrenzt (sonst der Fehler NG02825). Das Limit änderst du mit provideServerRendering({ maxResponseBodySize }, withRoutes(…)), aber der Leitfaden empfiehlt, es klein zu halten: Große Downloads gehören nicht ins Server-Rendering.
Stabilität und PendingTasks
Der Server weiß nicht, wann deine Seite „fertig“ ist. Seine Regel ist einfach: Nach dem Bootstrap wartet er auf ApplicationRef.whenStable() und serialisiert erst dann das DOM und den TransferState. Die Anwendung ist stabil, wenn keine Tasks mehr in PendingTasks sind. Angular fügt selbst Tasks hinzu für laufende HttpClient-Requests, für die Router-Navigation und für ladende Resources (resource, httpResource). Mit Zone.js hält außerdem jeder Timer oder Microtask in der Angular-Zone die Anwendung instabil.
Zoneless-Anwendungen — seit v21 der Standard — sehen deine Timer und Promises nicht mehr. Lädst du Daten mit einem eigenen SDK, mit fetch oder nach einem setTimeout, serialisiert der Server bevor die Daten ankommen: Das HTML geht leer raus, und der Browser fragt sie erneut an. Mach sie mit PendingTasks sichtbar (stabil seit v20; die Methode run und der Operator pendingUntilEvent sind noch Developer Preview):
import { Component, PendingTasks, inject, signal } from '@angular/core';
import { pendingUntilEvent, toSignal } from '@angular/core/rxjs-interop';
@Component({ selector: 'app-price', template: `{{ price() }} · {{ stock() }}` })
export class Price {
private readonly tasks = inject(PendingTasks);
private readonly sdk = inject(PricingSdk); // eigene Promises, nicht HttpClient
readonly price = signal<string | null>(null);
// 1. run(): Die Stabilität wartet, bis die async-Funktion fertig ist (Developer Preview)
constructor() {
this.tasks.run(async () => this.price.set(await this.sdk.priceOf(3)));
}
// 2. add(): manuelle Kontrolle — das Aufräumen NICHT vergessen, sonst hört der Server nie auf zu warten
async refresh(): Promise<void> {
const done = this.tasks.add();
try {
this.price.set(await this.sdk.priceOf(3));
} finally {
done();
}
}
// 3. pendingUntilEvent(): instabil bis zur ersten Emission / complete / error (Developer Preview)
readonly stock = toSignal(this.sdk.stock$(3).pipe(pendingUntilEvent()), { initialValue: 0 });
}whenStable()): <p class="price"></p>
PendingTasks nie entfernt (ein add() ohne finally, ein Observable, das mit pendingUntilEvent nie etwas emittiert), hängt die Response, bis der Proxy sie abbricht. Mit Zone.js bewirkt ein bei der Initialisierung gestartetes setInterval dasselbe. Polling, Websockets und wiederkehrende Timer haben auf dem Server nichts verloren: Starte sie in afterNextRender.import { provideStabilityDebugging } from '@angular/core';
// Im Dev-Modus standardmäßig aktiv, wenn du provideClientHydration() verwendest.
// Manuell: für SSR ohne Hydration oder im Produktions-Build, vorübergehend.
bootstrapApplication(App, {
providers: [provideStabilityDebugging()],
});
// Wird die Anwendung nicht innerhalb von ~9 s stabil, loggt sie in die Konsole die PendingTasks,
// die sie beschäftigt halten. Mit Zone.js zusätzlich 'zone.js/plugins/task-tracking' für Stack Traces importieren.Request, Response und der Status 404
@angular/core exportiert drei Tokens für den aktuellen Request. @angular/ssr füllt sie nur beim Rendern auf Anfrage (RenderMode.Server); im Browser, beim Prerendering, beim Build und beim Extrahieren der Routen im Dev-Modus sind sie null — verwende also immer ?..
| Token | Enthält | Verwendet für |
|---|---|---|
REQUEST | den Request der Web API: url, headers | Cookies, Accept-Language, eine absolute URL für Canonical / Open Graph |
RESPONSE_INIT | ein veränderbares ResponseInit: status, statusText, headers | dynamisches 404 / 410, beim Rendern berechnete Header |
REQUEST_CONTEXT | unknown: das zweite Argument von handle(request, context) | Tenant, Request-ID, der bereits von einer Middleware authentifizierte Nutzer |
import { Component, RESPONSE_INIT, inject } from '@angular/core';
@Component({
selector: 'app-product-missing',
template: `<h1>Dieses Produkt gibt es nicht mehr</h1><a href="/produse">Zum Katalog</a>`,
})
export class ProductMissing {
constructor() {
// null im Browser, beim Prerendering (SSG) und zur Build-Zeit — deshalb das ?.
const response = inject(RESPONSE_INIT);
if (response) response.status = 404; // sonst wird ein „Soft 404“ als echte Seite indexiert
}
}Warum der Status wichtig ist: Eine Seite „Produkt existiert nicht“, die mit 200 ausgeliefert wird, ist ein Soft 404, den Suchmaschinen als echte Seite indexieren können. Der status der ServerRoute deckt die statischen Fälle ab (die Route **); für „die id existiert nicht in der Datenbank“ brauchst du RESPONSE_INIT. Das funktioniert, weil @angular/ssr die Response erst nach whenStable() baut, du den Status bis dahin also ändern kannst.
import { isPlatformBrowser } from '@angular/common';
import { PLATFORM_ID, REQUEST, Service, inject } from '@angular/core';
@Service()
export class RequestInfo {
private readonly request = inject(REQUEST); // Request der Web API; null im Browser / bei SSG
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
/** Bevorzugte Sprache: der Header auf dem Server, navigator im Browser. */
preferredLanguage(): string {
if (this.request) return this.request.headers.get('accept-language')?.split(',')[0].trim() || 'de';
// kein Request: Browser ODER Prerendering. NICHT "typeof navigator" prüfen — Node 21+ hat es.
return this.isBrowser ? navigator.language : 'de';
}
cookie(name: string): string | null {
const raw = this.request?.headers.get('cookie') ?? '';
const match = raw.split('; ').find((c) => c.startsWith(`${name}=`));
return match ? decodeURIComponent(match.slice(name.length + 1)) : null;
}
}Relative und absolute URLs
Auf dem Server gibt es keine „aktuelle Seite“, gegen die sich eine relative URL auflösen ließe. HttpClient löst das Problem selbst: @angular/platform-server fügt einen Interceptor hinzu, der /api/… anhand der Request-URL und von <base href> in eine absolute URL umwandelt. Ein direktes fetch() und SDKs, die es nutzen, profitieren davon nicht.
// ✓ HttpClient auf dem Server: Die relative URL wird gegen die URL des aktuellen Requests aufgelöst
this.http.get('/api/products'); // → https://magazin.ro/api/products
// ✗ fetch() direkt (oder ein SDK, das es nutzt) — Node hat keine „aktuelle Seite“:
await fetch('/api/products'); // TypeError: Failed to parse URL from /api/products
// ✓ Wenn du fetch() verwenden musst: Baue die absolute URL aus dem Request
const base = inject(REQUEST)?.url ?? inject(DOCUMENT).location.href;
await fetch(new URL('/api/products', base));Gerade weil die URLs aus dem Host des Requests gebaut werden, prüft AngularNodeAppEngine den Hostnamen gegen allowedHosts (in angular.json unter build.options.security.allowedHosts oder über die Variable NG_ALLOWED_HOSTS) und antwortet bei einem unbekannten Host mit 400. Das ist der Schutz gegen SSRF: Konfiguriere es mit deinen Domains.
Zustand, der zwischen Requests durchsickert
Im Browser wird ein JavaScript-Modul einmal pro Tab ausgewertet, also pro Nutzer. Auf dem Server wird dasselbe Modul einmal pro Prozess ausgewertet und bedient Tausende Requests. Jede Variable auf Modulebene wird zu Zustand, den alle Nutzer teilen. Ein Provider mit useValue hat dasselbe Problem: Das Objekt wird beim Laden des Moduls erzeugt und lebt, bis der Server neu startet — der SSR-Leitfaden sagt das ausdrücklich und empfiehlt useFactory.
Services sind sicher: Bei jedem Request erzeugt @angular/ssr eine neue Plattform und eine neue Anwendung, also einen neuen Root-Injector. Jeder Nutzer bekommt seine eigenen Instanzen, die nach der Response zerstört werden.
// ✗ Zustand auf Modulebene: Die Datei wird EINMAL pro Node-Prozess ausgewertet,
// also teilen ALLE Requests (und alle Nutzer) die Variablen.
let currentUser: User | null = null; // Nutzer B sieht Nutzer A
const cart = new Map<number, number>(); // der Warenkorb wächst mit jedem Request
// ✗ Dasselbe mit useValue: Das Objekt wird einmal erzeugt, beim Laden des Moduls
{ provide: CART_STATE, useValue: { items: [] } }
// ✓ Ein Service: Auf dem Server startet jeder Request eine neue Anwendung,
// also bekommt er eine neue Instanz (die nach der Response zerstört wird).
@Service()
export class CartStore {
private readonly items = signal<CartItem[]>([]);
readonly count = computed(() => this.items().length);
}
// ✓ Oder useFactory: läuft für jede erzeugte Anwendung
{ provide: CART_STATE, useFactory: () => ({ items: [] }) }const cache = new Map() auf Modulebene sieht nach einer Optimierung aus. Auf dem Server ist es ein Speicherleck (es wächst endlos) und, wenn der Schlüssel den Nutzer nicht enthält, ein Datenleck zwischen Konten. Willst du einen Cache zwischen Requests, mach ihn explizit — in server.ts oder am CDN — mit Schlüsseln, TTL und Größenlimit.Fallstudie: das Theme
Das Theme ist das klassische Beispiel: Die Einstellung lebt in localStorage, und das CSS hängt von einem Attribut auf <html> ab. Diese Seite ist eine SPA ohne SSR, deshalb ist ihr ThemeStore hier korrekt. Aber es ist eine gute Übung: Wenn wir morgen mit SSR anfingen, was würde kaputtgehen?
// ThemeStore dieser Seite (Fragment). Die Seite ist eine SPA, also ist er HIER korrekt.
@Service()
export class ThemeStore {
private readonly document = inject(DOCUMENT);
// ✗ auf dem Server: defaultView ist das Domino-Window, das KEIN matchMedia hat → TypeError
private readonly media = this.document.defaultView?.matchMedia('(prefers-color-scheme: dark)');
// readStoredMode() liest localStorage in einem try/catch: Auf dem Server stürzt es nicht ab,
// liefert aber immer 'system' — der Server kann nicht wissen, was der Nutzer gewählt hat.
readonly mode = signal<ThemeMode>(readStoredMode());
constructor() {
this.media?.addEventListener('change', (e) => this.systemDark.set(e.matches));
effect(() => {
// ✓ DOM über DOCUMENT: funktioniert auch auf dem Server
// localStorage in try/catch: Auf dem Server wird es stillschweigend ignoriert
});
}
}Zwei Probleme. Das erste: Die Zeile mit matchMedia wirft beim Erzeugen des Service, und ?. hilft nicht, weil auf dem Server defaultView existiert (das Fragment defaultView?.matchMedia(…) in der ersten Demo reproduziert genau das). Das zweite: Der Server rendert immer das Theme system, weil er localStorage nicht sehen kann. Die SSR-sichere Variante verlegt die Einstellung in ein Cookie, das der Server aus REQUEST liest, und der effect() schreibt das Attribut auch auf dem Server:
import { isPlatformBrowser } from '@angular/common';
import {
DOCUMENT, DestroyRef, PLATFORM_ID, REQUEST, Service,
afterNextRender, computed, effect, inject, signal,
} from '@angular/core';
export type ThemeMode = 'system' | 'light' | 'dark';
const COOKIE = 'atlas-theme';
@Service()
export class ThemeStore {
private readonly document = inject(DOCUMENT);
private readonly request = inject(REQUEST);
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
// Der Server weiß nicht, ob das System dunkel ist: Bei 'system' entscheidet das CSS
// (prefers-color-scheme), und das Signal wird im Browser korrigiert.
private readonly systemDark = signal(false);
readonly mode = signal<ThemeMode>(this.readCookie());
readonly isDark = computed(
() => this.mode() === 'dark' || (this.mode() === 'system' && this.systemDark()),
);
constructor() {
const destroyRef = inject(DestroyRef);
afterNextRender(() => {
const media = matchMedia('(prefers-color-scheme: dark)'); // nur Browser
const onChange = (e: MediaQueryListEvent) => this.systemDark.set(e.matches);
this.systemDark.set(media.matches);
media.addEventListener('change', onChange);
destroyRef.onDestroy(() => media.removeEventListener('change', onChange));
});
effect(() => {
const mode = this.mode();
// läuft auch auf dem Server → <html data-theme="dark"> steht schon im HTML, kein Flackern
const root = this.document.documentElement;
if (mode === 'system') root.removeAttribute('data-theme');
else root.setAttribute('data-theme', mode);
// Cookie (nicht localStorage): Der Server erhält es beim nächsten Request
if (this.isBrowser) {
this.document.cookie = `${COOKIE}=${mode}; path=/; max-age=31536000; samesite=lax`;
}
});
}
private readCookie(): ThemeMode {
const raw = this.request?.headers.get('cookie') // Server: der Header des Requests
?? (this.isBrowser ? this.document.cookie : ''); // Browser (auf dem Server wirft cookie)
const value = raw
.split('; ')
.find((c) => c.startsWith(`${COOKIE}=`))
?.slice(COOKIE.length + 1);
return value === 'light' || value === 'dark' ? value : 'system';
}
}Die Alternative ohne Cookie: Du behältst localStorage und ein Inline-Script in index.html, das data-theme vor dem ersten Paint setzt — genau das, was diese Seite macht. Das funktioniert auch mit SSR (der Server sendet HTML ohne das Attribut, das Script setzt es im Browser vor der Anwendung), aber der Server kennt das Theme trotzdem nicht und kann daher zum Beispiel nicht die passenden Bilder oder theme-color wählen.
<!-- Die Alternative ohne Cookie (so macht es diese Seite): ein Inline-Script in <head>,
das vor dem ersten Paint läuft, in jedem Render-Modus (CSR, SSR, Prerendering). -->
<script>
try {
var t = localStorage.getItem('atlas-theme');
if (t === 'light' || t === 'dark') document.documentElement.setAttribute('data-theme', t);
} catch (e) {}
</script>Wann ja, wann nein
SSR-sicherer Code heißt nicht überall if (isBrowser), sondern dass jede Sache an der richtigen Stelle sitzt.
afterNextRender/afterRenderEffectfür alles, was das DOM, das Layout, den Storage oder eine Browser-Bibliothek berührt.inject(DOCUMENT),Title,Metafür alles, was vom Server aus im HTML stehen muss (SEO, Sharing).- Platform-Provider in
app.config.server.ts, wenn Server und Browser Unterschiedliches tun. - Der Standard-HTTP-Cache für öffentliche GETs; ein manueller
TransferStateoderresource({ id })für Daten, die nicht überHttpClientlaufen. PendingTasksoderresource()für jede asynchrone Arbeit, deren Ergebnis im HTML stehen muss.RESPONSE_INITfür dynamische Status,REQUESTfür Cookies und Sprache.
isPlatformBrowserin einem@ifim Template →@deferoderafterNextRender, mit demselben HTML auf beiden Plattformen.typeof windowquer durch die Komponenten verstreut → ein Service mit separater Server-Implementierung.TransferStatefür große Listen oder nutzerbezogene Daten auf am CDN gecachten Seiten → im Browser laden.PendingTasksfür Polling, Websockets, Analytics → inafterNextRenderstarten; der Server soll nicht darauf warten. Ein vollständiges Beispiel steht auf der Seite WebSockets.- HTTP-Cache für sensible Responses →
filterodertransferCache: false. ngSkipHydrationals Dauerlösung → die direkte DOM-Manipulation beheben.
Häufige Stolperfallen
effect, der in localStorage schreibt, matchMedia aufruft oder einen Observer startet, bricht beim Rendern. Was dem Browser gehört, kommt in afterRenderEffect oder unter afterNextRender.document.defaultView?.matchMedia(…) und typeof navigator !== 'undefined' sehen sicher aus, aber auf dem Server existiert defaultView (das Domino-Window, ohne matchMedia), und Node 21+ hat ein navigator. Das erste wirft einen TypeError, das zweite liefert die falsche Antwort. Verwende afterNextRender oder isPlatformBrowser.- Messungen im Konstruktor oder in
ngOnInit: Sie brechen auf dem Server (es gibt kein Layout). Im Browser misst der Konstruktor 0, und inngOnInitsind die Bindings und die@if/@for-Blöcke der Komponente noch nicht gerendert. Lösung:afterNextRender({ read }). - Im Konstruktor gestartete Timer und Subscriptions: Mit Zone.js blockieren sie die Serialisierung, zoneless laufen sie einfach weiter. Lösung: in
afterNextRenderstarten und mitDestroyRefstoppen. pendingTasks.add()ohnefinally: Eine Exception, und der Request endet nie. Lösung:try/finallyoderrun().- Nicht verfolgtes Async in zoneless:
fetch, SDKs,setTimeout→ leeres HTML + ein weiterer Request im Browser. Lösung:resource()oderPendingTasks. - Generische
TransferState-Schlüssel ('data') oder eine statischeresource({ id })in einer Komponente, die zweimal auf der Seite verwendet wird: Die Werte überschreiben sich gegenseitig. Lösung: Schlüssel, die die Parameter enthalten (article:${slug}), eindeutige IDs pro Seite. - Der Schlüssel wird nach dem Lesen nicht entfernt: Eine spätere Navigation auf derselben Seite bekommt die alten Daten aus dem HTML. Lösung:
state.remove(key)nach der ersten Verwendung. HTTP_TRANSFER_CACHE_ORIGIN_MAPinapp.config.ts: Fehler NG02803 im Browser. Lösung: nur inapp.config.server.ts.- Ein relatives
fetch('/api/…')auf dem Server:TypeError: Failed to parse URL. Lösung:HttpClientoder eine ausREQUESTgebaute absolute URL. - Eine „Nicht gefunden“-Seite mit Status 200: ein Soft 404 bei Google. Lösung:
RESPONSE_INIToderstatus: 404auf der Route**. - Werte, die sich zwischen Server und Browser unterscheiden:
Date.now(),Math.random(), Datumsangaben in der Zeitzone des Servers (oft UTC). Der Text ändert sich bei der Hydration. Lösung: einmal auf dem Server erzeugen und übertragen oder nur im Browser anzeigen. - Zustand auf Modulebene und
useValuemit Objekten: werden von den Nutzern geteilt. Lösung: Services oderuseFactory.
Spickzettel
| Du willst… | Verwende |
|---|---|
| das DOM / das Layout einmal nach dem Rendern berühren | afterNextRender({ earlyRead, write, read }) |
| das DOM mit bestimmten Signals synchronisieren | afterRenderEffect(() => …) |
| nach jedem Rendern messen | afterEveryRender({ read }) |
eine Bibliothek laden, die window berührt | await import('lib') in afterNextRender, oder @defer |
title, Meta, Canonical lesen / schreiben | inject(DOCUMENT), Title, Meta |
| eine andere Implementierung auf dem Server | eine abstrakte Klasse + ein Provider in app.config.server.ts (mergeApplicationConfig) |
| ein Seiteneffekt nur im Browser | isPlatformBrowser(inject(PLATFORM_ID)) |
| die GETs im Browser nicht wiederholen | provideClientHydration() (Standard), withHttpTransferCacheOptions(…) |
| einen Request aus dem Cache nehmen | filter oder { transferCache: false } |
Daten übertragen, die nicht über HttpClient kommen | makeStateKey<T>() + TransferState, oder resource({ id }) (v22) |
| unterschiedliche API-Origins auf dem Server und im Browser | HTTP_TRANSFER_CACHE_ORIGIN_MAP, nur auf dem Server |
| dass der Server auf Asynchrones wartet | resource(), PendingTasks.run() / add(), pendingUntilEvent() |
| herausfinden, warum die Anwendung nicht stabil wird | provideStabilityDebugging() |
| Cookies / Header lesen | inject(REQUEST)?.headers.get('cookie') |
| mit einem 404 antworten | inject(RESPONSE_INIT) → status = 404, oder status: 404 auf der Route ** |
Kontext aus server.ts erhalten | handle(req, ctx) → inject(REQUEST_CONTEXT) |
| nutzerbezogener Zustand auf dem Server | Services oder useFactory — niemals Modulvariablen |