~7 min de lecture
SSR Angular : 4 erreurs avec window qui plantent ton rendu serveur
Tu actives le SSR sur ton app Angular. Tout fonctionnait très bien en CSR. Puis, au premier pnpm run build, tu te prends ReferenceError: window is not defined en pleine face. Tu wrap dans un if (typeof window !== 'undefined'). Ça build. Tu passes à autre chose.
Sauf que six mois plus tard, ton bundle SSR pèse 300 ko de plus que nécessaire, ton IntersectionObserver reste actif côté serveur (spoiler : il ne fait rien mais il occupe le graphe d'objets), et ton localStorage.getItem('theme') renvoie null côté serveur alors que côté client il renvoie dark. Résultat : le HTML rendu est en light, l'hydration flip en dark, l'utilisateur voit un flash blanc de 200 ms sur chaque navigation dure.
Le typeof window !== 'undefined' marche. Mais c'est le pire pattern qui existe pour deux raisons : il est intestable (tu ne peux pas mocker un global JavaScript proprement) et il empêche Angular de faire son travail (le compilateur ne sait pas que ce bloc doit être stripped du bundle serveur).
Angular te donne trois primitives faites pour ça. On va voir les 4 erreurs les plus courantes et comment les corriger sans jamais toucher à typeof.
Valide Angular 17+.
afterNextRenderest disponible depuis la v16,PLATFORM_IDdepuis la v4,DOCUMENTdepuis la v4. Rien de récent : juste mal utilisé.
Les 3 primitives que tu dois connaître
Avant de dérouler les erreurs, voici le kit d'outils :
PLATFORM_ID: un token DI qui te dit sur quelle plateforme tu tournes. Combiné àisPlatformBrowser()/isPlatformServer(), tu obtiens un boolean testable.DOCUMENT: un token DI qui te donne unDocument(le vrai côté browser, un stub côté serveur via Domino). Tu ne fais plus jamaisdocument.querySelector, tu faisinject(DOCUMENT).querySelector.afterNextRender(fn): une callback qui s'exécute uniquement côté browser, après le premier render. Angular garantit qu'elle ne tourne jamais côté serveur, et le tree-shaker vire son contenu du bundle SSR.
Retiens : PLATFORM_ID pour brancher, DOCUMENT pour lire/écrire le DOM, afterNextRender pour tout ce qui touche à des APIs browser-only (IntersectionObserver, ResizeObserver, MutationObserver, matchMedia, localStorage, navigator).
Erreur 1 : accéder à window au top-level d'un module
C'est l'erreur qui te fait crash au premier build SSR. Tu importes un module qui déréférence window au niveau top-level (souvent une lib tierce mal conçue, ou ton propre code de config).
Avant
// analytics.config.ts
export const ANALYTICS_ENDPOINT = window.location.hostname.includes('staging')
? 'https://staging.analytics.example.com'
: 'https://analytics.example.com';
// app.config.ts
import { ANALYTICS_ENDPOINT } from './analytics.config';
export const appConfig: ApplicationConfig = {
providers: [
{ provide: 'ANALYTICS_ENDPOINT', useValue: ANALYTICS_ENDPOINT },
],
};
Résultat : ReferenceError: window is not defined au premier appel de renderApplication() dans main.server.ts. Le build ne passe pas.
Après
Tu remplaces la constante par un factory token qui n'évalue window qu'à l'injection, et uniquement côté browser.
// analytics.token.ts
import { DOCUMENT, inject, InjectionToken, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
export const ANALYTICS_ENDPOINT = new InjectionToken<string>('ANALYTICS_ENDPOINT', {
providedIn: 'root',
factory: () => {
const platformId = inject(PLATFORM_ID);
if (!isPlatformBrowser(platformId)) {
return 'https://analytics.example.com';
}
const hostname = inject(DOCUMENT).location.hostname;
return hostname.includes('staging')
? 'https://staging.analytics.example.com'
: 'https://analytics.example.com';
},
});
Trois choses ont changé :
- La factory ne s'exécute qu'au premier
inject(ANALYTICS_ENDPOINT), pas au chargement du module. Elle peut donc dépendre du contexte. - Côté serveur, on retourne un default et on n'accède jamais à
location. - Côté browser, on passe par
inject(DOCUMENT).locationau lieu dewindow.location. Testable, mockable via un provider dans les tests.
Règle d'or : aucune évaluation top-level qui déréférence window, document, navigator ou localStorage. Si tu as besoin d'une valeur calculée à partir d'une API browser, c'est une factory injectée, pas une constante.
Erreur 2 : localStorage dans un service providedIn: 'root' sans garde plateforme
C'est l'erreur qui te fait apparaître un flash blanc sur ton thème dark quand tu actives le SSR. Le service ne crash pas côté serveur (parce que tu as wrap dans un try/catch par sécurité), mais il retourne un default. Le HTML est rendu avec la mauvaise valeur. À l'hydration, le vrai localStorage est lu, la valeur change, le DOM re-render.
Avant
// theme.ts
import { Injectable, signal } from '@angular/core';
type Theme = 'light' | 'dark';
@Injectable({ providedIn: 'root' })
export class Theme {
readonly current = signal<Theme>(this.readInitial());
private readInitial(): Theme {
try {
return (localStorage.getItem('theme') as Theme) ?? 'light';
} catch {
return 'light';
}
}
set(theme: Theme): void {
this.current.set(theme);
try {
localStorage.setItem('theme', theme);
} catch {
/* ignore */
}
}
}
Le try/catch masque l'erreur côté serveur, mais tu perds l'info : ton HTML SSR est toujours en light. L'utilisateur qui a mis dark voit un flash.
Après
Deux corrections. Un : côté serveur, tu lis le cookie theme via REQUEST (Angular 19+), tu le transfères au client via TransferState pour que l'hydration reste cohérente. Deux : côté browser, afterNextRender réconcilie avec localStorage (utile si l'utilisateur a changé de préférence sur un autre onglet avant que le cookie soit réécrit) sans jamais bloquer le rendu.
// theme.ts
import {
afterNextRender,
DOCUMENT,
inject,
Injectable,
makeStateKey,
PLATFORM_ID,
REQUEST,
signal,
TransferState,
} from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
type Theme = 'light' | 'dark';
const THEME_KEY = makeStateKey<Theme>('theme');
@Injectable({ providedIn: 'root' })
export class Theme {
private readonly platformId = inject(PLATFORM_ID);
private readonly document = inject(DOCUMENT);
private readonly state = inject(TransferState);
readonly current = signal<Theme>(this.readInitial());
constructor() {
this.document.documentElement.dataset['theme'] = this.current();
afterNextRender(() => {
const stored = this.document.defaultView?.localStorage.getItem('theme') as Theme | null;
if ((stored === 'dark' || stored === 'light') && stored !== this.current()) {
this.current.set(stored);
this.document.documentElement.dataset['theme'] = stored;
}
});
}
private readInitial(): Theme {
if (isPlatformBrowser(this.platformId)) {
return this.state.get(THEME_KEY, 'light');
}
const request = inject(REQUEST, { optional: true });
const cookie = request?.headers.get('cookie') ?? '';
const theme: Theme = /(?:^|; )theme=dark/.test(cookie) ? 'dark' : 'light';
this.state.set(THEME_KEY, theme);
return theme;
}
set(theme: Theme): void {
this.current.set(theme);
this.document.documentElement.dataset['theme'] = theme;
if (isPlatformBrowser(this.platformId)) {
this.document.defaultView?.localStorage.setItem('theme', theme);
this.document.cookie = `theme=${theme}; path=/; max-age=31536000`;
}
}
}
Trois détails qui font que ça marche vraiment :
readInitial()est identique côté serveur et côté client au moment de l'hydration : le serveur calcule le thème depuis le cookie et le pousse dansTransferState, le client relit cette même valeur au lieu de recalculer la sienne. Sans ça, l'hydration détecterait un état différent de ce que le serveur a rendu et lèverait une erreur de mismatch (NG0500).- La mutation
this.document.documentElement.dataset['theme'] = ...passe par le tokenDOCUMENT, donc elle s'applique aussi bien côté serveur (le HTML sérialisé contient déjà l'attribut) que côté client. Pas besoin de script inline séparé qui lit le cookie en dehors d'Angular : c'est Angular lui-même qui pose l'attribut avant le premier paint. set()écrit maintenant le cookie en plus delocalStorage. Dans la version précédente, rien n'écrivait jamais le cookie : leREQUESTcôté serveur ne trouvait donc jamais de préférence, et le "fix" ne faisait rien en pratique.
Règle d'or : localStorage, sessionStorage, IndexedDB ne s'appellent que dans un afterNextRender ou derrière un isPlatformBrowser. Pour les valeurs qui doivent conditionner le premier render (thème, langue, feature flag), tu passes par un cookie lu via REQUEST côté serveur et propagé au client via TransferState, pas par un script inline qui devine.
Erreur 3 : IntersectionObserver dans un ngOnInit sans garde
Ton composant setup un IntersectionObserver pour du lazy loading, un scrollspy ou une animation d'apparition. En CSR, ça marche. En SSR, IntersectionObserver n'existe pas dans Domino. Tu wrap dans un if (typeof IntersectionObserver !== 'undefined'). Ça build. Sauf que le compilateur ne strip pas ce code du bundle serveur, tu payes 12 ko compressed dans ton chunk main.server.mjs pour du code qui ne s'exécute jamais.
Pire : si tu utilises une lib d'observers (genre @intersection-observer/polyfill ou un wrapper maison), le tree-shaker garde la lib entière côté serveur.
Avant
// visibility-tracker.ts
import { ChangeDetectionStrategy, Component, ElementRef, inject, OnDestroy, OnInit, signal } from '@angular/core';
@Component({
selector: 'app-visibility-tracker',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (visible()) {
<p>Visible !</p>
}
`,
})
export class VisibilityTracker implements OnInit, OnDestroy {
private readonly host = inject(ElementRef<HTMLElement>);
protected readonly visible = signal(false);
private observer?: IntersectionObserver;
ngOnInit(): void {
if (typeof IntersectionObserver === 'undefined') return;
this.observer = new IntersectionObserver((entries) => {
this.visible.set(entries[0].isIntersecting);
});
this.observer.observe(this.host.nativeElement);
}
ngOnDestroy(): void {
this.observer?.disconnect();
}
}
Après
Tu remplaces ngOnInit + garde typeof par afterNextRender + DestroyRef. Le compilateur voit un callback browser-only et strip le contenu de afterNextRender du bundle serveur. Bonus : tu peux virer OnInit et OnDestroy, c'est plus lisible.
// visibility-tracker.ts
import { afterNextRender, ChangeDetectionStrategy, Component, DestroyRef, ElementRef, inject, signal } from '@angular/core';
@Component({
selector: 'app-visibility-tracker',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (visible()) {
<p>Visible !</p>
}
`,
})
export class VisibilityTracker {
private readonly host = inject(ElementRef<HTMLElement>);
private readonly destroyRef = inject(DestroyRef);
protected readonly visible = signal(false);
constructor() {
afterNextRender(() => {
const observer = new IntersectionObserver((entries) => {
this.visible.set(entries[0].isIntersecting);
});
observer.observe(this.host.nativeElement);
this.destroyRef.onDestroy(() => observer.disconnect());
});
}
}
Trois gains :
- Le code entre les accolades de
afterNextRenderest stripped du bundle SSR par le compilateur Angular. Tonmain.server.mjsmaigrit d'autant. - Aucun risque de
IntersectionObserver is not defined: la callback n'est appelée que côté browser. - Le teardown via
DestroyRef.onDestroyreste attaché au lifecycle du composant. Pas de leak.
Le même pattern s'applique à ResizeObserver, MutationObserver, matchMedia, requestIdleCallback, Notification.
Erreur 4 : lire navigator.userAgent pour du responsive logic
Cas classique : tu veux afficher un bouton "Ouvrir dans l'app" uniquement sur mobile. Tu tapes navigator.userAgent.includes('Mobile') dans un computed. Côté serveur, navigator n'existe pas. Tu wrap dans un try/catch. Le rendu SSR affiche le bouton pour tout le monde parce que le default renvoie false. À l'hydration, le vrai navigator est lu, le bouton disparaît sur desktop, le layout shift casse ta CLS.
Avant
// download-cta.ts
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
@Component({
selector: 'app-download-cta',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (isMobile()) {
<a href="myapp://open">Ouvrir dans l'app</a>
}
`,
})
export class DownloadCta {
protected readonly isMobile = computed(() => {
try {
return navigator.userAgent.includes('Mobile');
} catch {
return false;
}
});
}
Après
Deux façons de corriger, selon si tu peux lire l'user-agent côté serveur.
Option A : côté serveur, tu lis REQUEST.headers (Angular 19+) et tu passes la valeur via TransferState. Le SSR rend le bon HTML, aucun layout shift.
// user-agent.token.ts
import { inject, InjectionToken, makeStateKey, PLATFORM_ID, REQUEST, TransferState } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
const USER_AGENT_KEY = makeStateKey<string>('userAgent');
export const USER_AGENT = new InjectionToken<string>('USER_AGENT', {
providedIn: 'root',
factory: () => {
const platformId = inject(PLATFORM_ID);
const state = inject(TransferState);
if (isPlatformBrowser(platformId)) {
return state.get(USER_AGENT_KEY, navigator.userAgent);
}
const request = inject(REQUEST, { optional: true });
const ua = request?.headers.get('user-agent') ?? '';
state.set(USER_AGENT_KEY, ua);
return ua;
},
});
// download-cta.ts
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { USER_AGENT } from './user-agent.token';
@Component({
selector: 'app-download-cta',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (isMobile()) {
<a href="myapp://open">Ouvrir dans l'app</a>
}
`,
})
export class DownloadCta {
private readonly userAgent = inject(USER_AGENT);
protected readonly isMobile = computed(() => this.userAgent.includes('Mobile'));
}
Côté serveur, on récupère le vrai user-agent via REQUEST, on le stocke dans TransferState. Côté browser, on récupère la même valeur via TransferState (donc pas de mismatch d'hydration). Le CTA s'affiche correctement dès le premier paint.
Option B : si tu ne peux pas accéder au header (edge case CDN qui strip), tu défers la décision au premier render browser.
// download-cta.ts
import { afterNextRender, ChangeDetectionStrategy, Component, signal } from '@angular/core';
@Component({
selector: 'app-download-cta',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (isMobile()) {
<a href="myapp://open">Ouvrir dans l'app</a>
}
`,
})
export class DownloadCta {
protected readonly isMobile = signal(false);
constructor() {
afterNextRender(() => {
this.isMobile.set(navigator.userAgent.includes('Mobile'));
});
}
}
Le HTML SSR ne contient jamais le CTA. Au premier render browser, il apparaît si l'utilisateur est mobile. Léger layout shift possible sur mobile, aucun sur desktop. Acceptable si l'élément est hors du viewport initial.
Règle d'or : tout ce qui doit être connu avant le premier paint passe par REQUEST + TransferState. Tout ce qui peut apparaître après le premier paint passe par afterNextRender.
Récap actionnable
Quatre erreurs, un seul principe : tu ne mets jamais typeof window !== 'undefined' dans du code Angular. Tu utilises les primitives faites pour ça.
| Situation | Primitive | Pourquoi |
|---|---|---|
| Constante calculée à partir d'une API browser | Factory InjectionToken + PLATFORM_ID |
Évaluation lazy, mockable en test |
Lecture/écriture du DOM (querySelector, location, cookie) |
inject(DOCUMENT) |
Un stub existe côté serveur, ton code est testable |
API browser-only (IntersectionObserver, localStorage, matchMedia) |
afterNextRender |
Callback stripped du bundle SSR, tree-shaking optimal |
| Info qui doit conditionner le premier render (theme, langue, user-agent) | REQUEST.headers + TransferState (Angular 19+) |
Le SSR rend le bon HTML, zéro layout shift |
| Info qui peut apparaître après le premier render | afterNextRender + signal |
Layout shift toléré, code testable |
Et si tu vois un typeof window !== 'undefined' dans une PR, refuse-la. C'est le smell qui trahit un dev qui n'a pas pris le temps de comprendre le SSR d'Angular. La stack est plus élégante que ça.