Avec le SSR ou le prerendering, vos composants s’exécutent deux fois : une fois dans Node — à chaque requête ou au build — pour produire le HTML, et une fois de plus dans le navigateur, pendant l’hydration. Les constructeurs, les initialiseurs de champ, ngOnInit, effect(), les services, les intercepteurs et les guards s’exécutent sur les deux plateformes. Seules quelques API sont réservées au navigateur : afterNextRender, afterEveryRender, afterRenderEffect et, bien sûr, les événements de l’utilisateur.
Sur le serveur, il n’y a ni window, ni localStorage, ni layout, ni observers. Il y a un document — émulé par Domino — mais uniquement via inject(DOCUMENT). Le code écrit « pour le navigateur » fonctionne parfaitement avec ng serve sans SSR et n’échoue que dans le build serveur. Ou, pire, il n’échoue pas, mais envoie un HTML erroné : mauvais thème, données manquantes, chaque requête faite deux fois.
- Il n’y a ni
window, nidocument, nilocalStoragesur le serveur. Le code qui en a besoin s’exécute dansafterNextRender. - Ce que vous avez chargé sur le serveur arrive dans le navigateur via le cache de transfert HTTP :
HttpClientne refait pas la requête à l’hydratation. - Le serveur n’envoie la page que lorsque l’application n’a plus de tâches en attente (
PendingTasks). Un timer qui ne s’arrête jamais garde la requête ouverte. - Une page inexistante doit répondre avec le statut 404, pas seulement afficher « introuvable ».
- Les variables au niveau du module sont partagées par toutes les requêtes sur le serveur. L’état d’un utilisateur va dans les services, pas là.
Ce qui n’existe pas sur le serveur
Le serveur Angular s’exécute dans Node, avec un DOM émulé par Domino (inclus dans @angular/platform-server). La distinction importante : les globals du navigateur sont complètement absents et donnent une ReferenceError, alors que le document reçu via la DI a une structure mais pas de layout. Le tableau est vérifié sur Node 22/24 et sur le Domino de @angular/platform-server 22.2.2.
| API | Sur le serveur | À utiliser à la place |
|---|---|---|
window, document (global), location, history | n’existe pas : ReferenceError: window is not defined | inject(DOCUMENT), Router, Location |
localStorage, sessionStorage | n’existent pas | afterNextRender ; pour ce qui compte au moment du rendu, un cookie lu depuis REQUEST |
matchMedia, innerWidth | absents aussi de document.defaultView (le window de Domino) | media queries CSS ; BreakpointObserver du CDK (sur le serveur, les requêtes ne correspondent pas) ; afterNextRender |
IntersectionObserver, ResizeObserver, MutationObserver, requestAnimationFrame | n’existent pas | @defer (on viewport); afterNextRender + DestroyRef |
getBoundingClientRect(), offsetWidth, scrollHeight | les éléments Domino ne les ont pas (TypeError ou undefined) | afterNextRender({ read }) |
document.cookie | le getter lève Error: NotYetImplemented | inject(REQUEST)?.headers.get('cookie') |
navigator | existe dans Node 21+, mais appartient au serveur (userAgent: 'Node.js/22') | les en-têtes depuis REQUEST ; isPlatformBrowser |
setTimeout, setInterval, fetch | existent, mais les timers ne s’arrêtent pas d’eux-mêmes, et fetch n’accepte pas les URL relatives | afterNextRender + DestroyRef; HttpClient |
Ce qui s’exécute où
| Code | Serveur | Navigateur |
|---|---|---|
initialiseurs de champ, constructeur, ngOnInit, ngOnChanges | oui | oui |
effect(), computed(), le template, les pipes | oui | oui |
| services, intercepteurs, guards, resolvers | oui — nouvelles instances à chaque requête | oui |
ngOnDestroy, DestroyRef.onDestroy | oui — le serveur détruit l’application après le rendu | oui |
afterNextRender, afterEveryRender, afterRenderEffect | non (NOOP) | oui |
écouteurs d’événements : (click), (input)… | non — personne ne clique sur le serveur | oui |
@defer sans hydrate | uniquement le @placeholder | les triggers démarrent ici |
theme = 'light';
constructor() {
this.theme = localStorage.getItem('theme') ?? 'light';
}ReferenceError: localStorage is not definedPourquoi : Le constructeur s’exécute aussi sur le serveur, et dans Node il n’y a pas de localStorage. Même s’il y en avait un, le serveur n’a pas accès au stockage de l’utilisateur.
readonly theme = signal('light');
constructor() {
afterNextRender(() =>
this.theme.set(localStorage.getItem('theme') ?? 'light'));
}
// ou : un cookie lu sur le serveur depuis inject(REQUEST)ngOnInit ou effect(). Tout ce qui touche au DOM, au layout, au stockage ou à une bibliothèque navigateur va dans afterNextRender / afterRenderEffect — ou derrière un service avec une implémentation serveur distincte.afterNextRender et les phases
afterNextRender et afterEveryRender (stables depuis la v20, quand afterRender a été renommé afterEveryRender) enregistrent des callbacks qui s’exécutent une fois qu’Angular a fini le rendu — uniquement dans le navigateur. Sur le serveur, ce sont des NOOP : le callback n’est même pas enregistré. C’est pourquoi ils sont l’endroit par défaut pour tout code qui touche au DOM, au layout ou aux API de window. Ils doivent être appelés dans un contexte d’injection (constructeur, initialiseur de champ) ou avec l’option injector, et ils se nettoient tout seuls quand le composant est détruit.
Vous pouvez leur donner une fonction ou un objet avec des phases. Les phases s’exécutent dans l’ordre earlyRead → write → mixedReadWrite → read, et chacune reçoit la valeur renvoyée par la précédente. Cette séparation existe pour qu’Angular puisse regrouper les lectures et les écritures de toute l’application : une lecture du layout après une écriture force le navigateur à recalculer le layout sur-le-champ (layout thrashing). Une simple fonction s’exécute dans mixedReadWrite, la phase que la documentation vous demande d’éviter.
| Phase | Vous pouvez | Exemple |
|---|---|---|
earlyRead | uniquement lire, avant les écritures | vous mesurez quelque chose dont dépend une écriture (layout personnalisé) |
write | uniquement écrire | styles, initialisation d’une bibliothèque |
mixedReadWrite | lectures et écritures mélangées | seulement si le travail ne peut pas être découpé |
read | uniquement lire, après les écritures | vous mesurez le résultat final et le placez dans un signal |
import { Component, ElementRef, afterNextRender, input, viewChild } from '@angular/core';
/** Réduit le titre jusqu’à ce qu’il tienne sur une ligne. Sur le serveur, rien ne se passe. */
@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() {
// Une fois, après le premier rendu, UNIQUEMENT dans le navigateur (sur le serveur, c’est un no-op).
afterNextRender({
// 1. earlyRead : lit le layout avant toute écriture
earlyRead: () => {
const el = this.title().nativeElement;
return Math.min(1, el.clientWidth / el.scrollWidth); // 1 = ça tient
},
// 2. write : reçoit le résultat de la phase précédente et ne fait qu’écrire
write: (ratio) => {
this.title().nativeElement.style.fontSize = `${Math.floor(32 * ratio)}px`;
},
});
}
}| Expression | Ici (en direct) | Sur le serveur, ce serait |
|---|---|---|
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.1 msconstructorisConnected=false, width=0px, children=0, binding=null
- +0.2 msngOnInitisConnected=true, width=0px, children=0, binding=""
Sur le serveur, seules les lignes constructor et ngOnInit s’exécuteraient (pas de layout : les largeurs n’existeraient pas) ; afterNextRender et afterEveryRender sont ignorés.
afterEveryRender ou afterRenderEffect
afterEveryRender s’exécute après chaque rendu de l’application, y compris quand quelque chose a changé dans un tout autre composant. Si votre travail dépend de certains signals, afterRenderEffect est l’option la moins coûteuse : un effect qui s’exécute après le rendu uniquement quand ses dépendances changent (et là encore seulement dans le navigateur). La différence avec effect() : effect s’exécute aussi sur le serveur et n’a pas de phases read/write.
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() {
// ✗ s’exécuterait après CHAQUE rendu de l’application, même si la liste n’a pas changé :
// afterEveryRender({ write: () => this.scrollToBottom() });
// ✓ s’exécute après le rendu uniquement quand les signals qu’il lit ont changé (toujours seulement dans le navigateur)
afterRenderEffect({
write: () => {
this.messages(); // dépendance
this.scrollToBottom();
},
});
}
private scrollToBottom(): void {
const el = this.scroller().nativeElement;
el.scrollTop = el.scrollHeight;
}
}afterNextRender s’exécute. Lisez et écrivez le DOM avec précaution et ne déplacez pas les nœuds qu’Angular rend — sinon apparaissent les erreurs de mismatch décrites dans Hydration.Les bibliothèques qui touchent à window
De nombreuses bibliothèques (cartes, graphiques, éditeurs de texte riche) accèdent à window dès l’import. Le serveur plante au chargement du module, avant qu’aucun composant ne s’exécute, et aucune vérification dans le constructeur ne vous sauve plus. La solution est un import() dynamique dans afterNextRender ; en bonus, la bibliothèque se retrouve dans un chunk séparé. Pour des composants entiers, @defer fait la même chose de façon déclarative.
import { Component, DestroyRef, ElementRef, afterNextRender, inject, viewChild } from '@angular/core';
// ✗ import * as L from 'leaflet'; — le module touche à window dès qu’il est importé :
// le serveur plante au chargement du fichier, avant qu’aucun composant ne s’exécute.
@Component({
selector: 'app-store-map',
template: `<div #host class="map" aria-label="Carte des magasins"></div>`,
// une hauteur fixe réserve aussi la place dans le HTML du serveur → pas de layout shift
styles: `.map { height: 320px; }`,
})
export class StoreMap {
private readonly host = viewChild.required<ElementRef<HTMLElement>>('host');
private readonly destroyRef = inject(DestroyRef); // inject() uniquement en synchrone, pas après un await
constructor() {
afterNextRender(async () => {
const L = await import('leaflet'); // chunk séparé, téléchargé uniquement dans le navigateur
if (this.destroyRef.destroyed) return; // l’utilisateur est parti entre-temps
const map = L.map(this.host().nativeElement).setView([44.4268, 26.1025], 12);
this.destroyRef.onDestroy(() => map.remove());
});
}
}Si la bibliothèque réécrit le DOM qu’Angular a rendu, l’hydration se plaint de mismatches. ngSkipHydration sur le composant hôte arrête l’hydration uniquement pour lui — c’est un pansement, pas une solution.
DOCUMENT, PLATFORM_ID et les providers de plateforme
inject(DOCUMENT) — depuis @angular/core à partir de la v20 ; @angular/common le réexporte — vous donne le document courant sur n’importe quelle plateforme. Sur le serveur, c’est le document Domino de la requête : vous pouvez lire et écrire la structure (title, <meta>, <link rel="canonical">, attributs sur <html>), et les modifications se retrouvent dans le HTML envoyé. Vous n’avez ni layout, ni cookies, ni matchMedia.
import { DOCUMENT, Service, inject } from '@angular/core';
@Service()
export class CanonicalLink {
private readonly document = inject(DOCUMENT); // sur le serveur : le document Domino de la requête
/** S’exécute aussi sur le serveur : la balise se retrouve dans le HTML que voient les crawlers. */
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;
}
}
// Pour <title> et <meta>, il existe déjà des services SSR-safe dans @angular/platform-browser :
// inject(Title).setTitle('Phone X — Store');
// inject(Meta).updateTag({ name: 'description', content: '…' });inject(PLATFORM_ID) renvoie 'browser' ou 'server', et vous le testez avec isPlatformBrowser / isPlatformServer de @angular/common. Ça marche, mais le guide officiel recommande plutôt des providers de plateforme à des vérifications à l’exécution, pour deux raisons : les vérifications se multiplient dans tout le code, et, utilisées dans un @if du template, elles produisent un contenu différent sur le serveur et dans le navigateur, c’est-à-dire un layout shift et des erreurs d’hydration. Gardez-le pour les effets de bord qui ne changent pas ce qui est rendu.
import { isPlatformBrowser } from '@angular/common';
import { PLATFORM_ID, Service, inject } from '@angular/core';
@Service()
export class Analytics {
// PLATFORM_ID vaut 'browser' ou 'server' ; ne le comparez pas à la main, utilisez les helpers de @angular/common
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
track(event: string): void {
if (!this.isBrowser) return; // ok : un effet de bord qui ne change pas ce qui est rendu
navigator.sendBeacon('/collect', JSON.stringify({ event, at: Date.now() }));
}
}
// ✗ PAS comme ça — le serveur et le navigateur rendent un contenu différent → layout shift et erreurs d’hydration :
// @if (isBrowser) { <app-recommendations /> } @else { <p>Chargement…</p> }
// ✓ utilisez @defer (on viewport) ou afterNextRender pour ce qui n’a de sens que dans le navigateur.Quand les plateformes ont besoin de comportements différents, l’approche propre est un contrat avec deux implémentations : les composants injectent la classe abstraite, app.config.ts fournit l’implémentation navigateur, et app.config.server.ts la remplace. Aucun if dans le code métier — le même principe que dans Providers et Injection tokens.
import { Injectable } from '@angular/core';
// Le contrat : les composants ne dépendent que de la classe abstraite.
export abstract class AnalyticsService {
abstract trackEvent(name: string): void;
}
@Injectable()
export class BrowserAnalyticsService implements AnalyticsService {
trackEvent(name: string): void {
navigator.sendBeacon('/collect', name); // API navigateur — autorisée ici
}
}
@Injectable()
export class ServerAnalyticsService implements AnalyticsService {
trackEvent(_name: string): void {
// sur le serveur : rien (ou un log), pas d’API navigateur
}
}TransferState
Sans rien de particulier, une page SSR charge ses données deux fois : une fois sur le serveur, pour rendre le HTML, et de nouveau dans le navigateur, car pendant l’hydration les composants repartent de zéro, avec un état vide. Résultat : une requête en plus et le contenu du serveur brièvement remplacé par un skeleton.
TransferState est un dictionnaire clé → valeur qui voyage dans le HTML. Sur le serveur, vous y écrivez ; avant la sérialisation, Angular appelle toJson() et place le résultat dans un <script id="ng-state" type="application/json"> à la fin de body — seulement s’il n’est pas vide. Dans le navigateur, à la première injection, TransferState relit le script. Les clés se créent avec makeStateKey<T>('…'), qui lie aussi le type de la valeur. L’API complète : 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 }
// Clé typée : TransferState sait qu’un Article se trouve dessous
const articleKey = (slug: string) => makeStateKey<Article>(`article:${slug}`);
@Service()
export class ArticleStore {
private readonly cms = inject(CmsClient); // SDK avec son propre fetch → ne passe PAS par 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); // utilisé une seule fois : les navigations suivantes demandent des données fraîches
return cached;
}
const article = await this.cms.getArticle(slug);
if (this.isServer) this.state.set(key, article); // atterrit dans <script id="ng-state">
return article;
}
}HTML envoyé par le serveur
Rien pour l’instant. Lancez le rendu sur le serveur.
Ce que voit l’utilisateur
/api/products/3Les valeurs passent par JSON.stringify / JSON.parse : une Date devient une chaîne, une Map et un Set deviennent {}, les instances de classe perdent leurs méthodes. Transférez de simples DTO (voir Interfaces & models). Tout ce que vous y placez est visible dans le code source de la page et alourdit le HTML : la démo montre combien de caractères cela ajoute.
HttpClient sont transférés automatiquement (section suivante). Le TransferState manuel sert aux données qui ne passent pas par HttpClient : SDK (Firebase, un CMS), un fetch direct, des calculs coûteux faits une seule fois sur le serveur. Pour les resources, il existe aussi depuis la v22 resource({ id }).Le cache de transfert HTTP
provideClientHydration() démarre, par défaut, un intercepteur qui applique exactement le schéma ci-dessus à HttpClient. Sur le serveur, il enregistre les réponses dans TransferState, sous une clé calculée comme un hash SHA-256 de la méthode, du type de réponse, de l’URL, du corps et des paramètres. Dans le navigateur, il les sert depuis le cache — mais seulement jusqu’à ce que l’application devienne stable pour la première fois ; ensuite tout passe par le réseau, comme d’habitude. httpResource passe par HttpClient, donc il en bénéficie automatiquement, tout comme vos intercepteurs (voir HTTP).
En 22.2.2, seules les requêtes GET et HEAD qui remplissent ces conditions sont mises en cache par défaut :
- elles n’ont pas d’en-têtes
Authorization,Proxy-AuthorizationouCookie; - elles ne sont pas envoyées avec
withCredentialsni aveccredentials: 'include'/'same-origin'; - elles n’ont pas de
Cache-Controlcontenantno-store,no-cacheouprivate(ni dans la requête ni dans la réponse), et n’utilisent pascache: 'no-store'/'no-cache'; - leur réponse ne contient pas de
Set-Cookie.
import { ApplicationConfig } from '@angular/core';
import { provideClientHydration, withHttpTransferCacheOptions } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
// Le cache de transfert est ACTIVÉ par défaut avec provideClientHydration() ;
// withHttpTransferCacheOptions ne fait que le régler.
provideClientHydration(
withHttpTransferCacheOptions({
// quels en-têtes de réponse sont sauvegardés (par défaut : aucun)
includeHeaders: ['ETag'],
// false → la requête n’est pas mise en cache (données propres à l’utilisateur, données qui changent souvent)
filter: (req) => !req.url.includes('/api/cart'),
// POST en lecture seule (p. ex. GraphQL) — seulement s’ils sont idempotents
includePostRequests: true,
// false par défaut : les requêtes avec Authorization / Proxy-Authorization / Cookie sont ignorées
includeRequestsWithAuthHeaders: false,
}),
),
],
};| Option | Par défaut | Quand la changer |
|---|---|---|
filter(req) | toutes les requêtes éligibles | vous retirez les endpoints propres à un utilisateur ou très volatils (false = non mis en cache) |
includeHeaders | aucun en-tête | le navigateur a besoin de ETag, Link… Jamais d’en-têtes sensibles |
includePostRequests | false | un POST utilisé pour lire (GraphQL), seulement s’il est idempotent |
includeRequestsWithAuthHeaders | false | l’en-tête d’authentification ne change pas la réponse (une clé publique) |
includeRequestsWithCredentials | false | les réponses avec credentials sont sûrement publiques |
includeNonCacheableRequests | false | vous ignorez consciemment Cache-Control / Set-Cookie (rare) |
Les resources qui n’utilisent pas HttpClient v22
Depuis la v22, resource() et rxResource() acceptent un id. La valeur résolue sur le serveur est écrite dans TransferState sous cet id, et dans le navigateur la resource démarre directement à l’état resolved, sans rappeler le loader — là encore seulement tant que le cache d’hydration est actif, c’est-à-dire jusqu’à la première stabilité, et uniquement avec provideClientHydration() dans la configuration.
import { Component, inject, resource } from '@angular/core';
@Component({ selector: 'app-weather', template: `{{ forecast.value()?.summary }}` })
export class Weather {
private readonly sdk = inject(WeatherSdk); // pas HttpClient → le cache HTTP ne le voit pas
// v22 : avec un id, la valeur résolue sur le serveur est placée dans TransferState et réutilisée pendant l’hydration.
// L’id doit être unique sur la page et identique sur le serveur et dans le navigateur.
protected readonly forecast = resource({
id: 'weather-bucharest',
loader: () => this.sdk.forecast('Bucharest'),
});
}Des origines différentes sur le serveur et dans le navigateur
Si le serveur parle à l’API sur une adresse interne et le navigateur sur l’adresse publique, les URL diffèrent et les clés de cache ne correspondent plus. HTTP_TRANSFER_CACHE_ORIGIN_MAP (depuis @angular/common/http) indique au serveur de calculer la clé comme s’il avait utilisé l’origine publique. Il se déclare uniquement dans la configuration serveur : si Angular le trouve dans le navigateur, il lève l’erreur NG02803.
// api-base-url.ts — dans le navigateur : l’origine publique
export const API_BASE_URL = new InjectionToken<string>('API_BASE_URL', {
factory: () => 'https://api.magazin.ro',
});
// app.config.server.ts (fragment) — sur le serveur : le réseau interne, sans passer par le load balancer
providers: [
provideServerRendering(withRoutes(serverRoutes)),
{ provide: API_BASE_URL, useValue: 'http://catalog.internal:8080' },
// les mêmes chemins sur les deux origines → les clés du cache de transfert correspondent
// (uniquement dans la configuration serveur ; dans le navigateur, Angular lève une erreur s’il le trouve)
{
provide: HTTP_TRANSFER_CACHE_ORIGIN_MAP,
useValue: { 'http://catalog.internal:8080': 'https://api.magazin.ro' },
},
];Un détail de plus à partir de la v22, quand HttpClient utilise par défaut le backend fetch : sur le serveur, chaque corps de réponse est limité à 1 Mo (sinon l’erreur NG02825). La limite se change avec provideServerRendering({ maxResponseBodySize }, withRoutes(…)), mais le guide recommande de la garder petite : les gros téléchargements n’ont pas leur place dans le rendu serveur.
Stabilité et PendingTasks
Le serveur ne sait pas quand votre page est « prête ». Sa règle est simple : après le bootstrap, il attend ApplicationRef.whenStable() et ne sérialise qu’ensuite le DOM et TransferState. L’application est stable quand il n’y a plus de tâches dans PendingTasks. Angular ajoute des tâches de lui-même pour les requêtes HttpClient en cours, pour la navigation du routeur et pour les resources en chargement (resource, httpResource). Avec Zone.js, tout timer ou microtâche dans la zone Angular garde aussi l’application instable.
Les applications zoneless — le mode par défaut depuis la v21 — ne voient plus vos timers et vos promesses. Si vous chargez des données avec votre propre SDK, avec fetch ou après un setTimeout, le serveur sérialise avant l’arrivée des données : le HTML part vide, et le navigateur les redemande. Rendez-les visibles avec PendingTasks (stable depuis la v20 ; la méthode run et l’opérateur pendingUntilEvent sont encore en 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); // ses propres promesses, pas HttpClient
readonly price = signal<string | null>(null);
// 1. run() : la stabilité attend la fin de la fonction asynchrone (developer preview)
constructor() {
this.tasks.run(async () => this.price.set(await this.sdk.priceOf(3)));
}
// 2. add() : contrôle manuel — N’OUBLIEZ PAS le nettoyage, sinon le serveur n’arrête jamais d’attendre
async refresh(): Promise<void> {
const done = this.tasks.add();
try {
this.price.set(await this.sdk.priceOf(3));
} finally {
done();
}
}
// 3. pendingUntilEvent() : instable jusqu’à la première émission / complete / error (developer preview)
readonly stock = toSignal(this.sdk.stock$(3).pipe(pendingUntilEvent()), { initialValue: 0 });
}whenStable()): <p class="price"></p>
PendingTasks n’est jamais retirée (un add() sans finally, un Observable qui n’émet jamais avec pendingUntilEvent), la réponse reste bloquée jusqu’à ce que le proxy la coupe. Avec Zone.js, un setInterval démarré à l’initialisation produit le même effet. Le polling, les websockets et les timers récurrents n’ont pas leur place sur le serveur : démarrez-les dans afterNextRender.import { provideStabilityDebugging } from '@angular/core';
// Actif par défaut en mode dev quand vous utilisez provideClientHydration().
// Manuellement : pour du SSR sans hydration ou dans un build de production, temporairement.
bootstrapApplication(App, {
providers: [provideStabilityDebugging()],
});
// Si l’application ne devient pas stable en ~9 s, elle journalise dans la console les PendingTasks
// qui la tiennent occupée. Avec Zone.js, importez aussi 'zone.js/plugins/task-tracking' pour obtenir des stack traces.Requête, réponse et statut 404
@angular/core exporte trois tokens pour la requête courante. @angular/ssr ne les remplit que pendant le rendu à la requête (RenderMode.Server) ; dans le navigateur, au prerender, au build et lors de l’extraction des routes en dev, ils valent null — utilisez donc toujours ?..
| Token | Contient | Sert à |
|---|---|---|
REQUEST | la Request de la Web API : url, headers | cookies, Accept-Language, une URL absolue pour canonical / Open Graph |
RESPONSE_INIT | une ResponseInit modifiable : status, statusText, headers | 404 / 410 dynamique, en-têtes calculés au rendu |
REQUEST_CONTEXT | unknown : le deuxième argument de handle(request, context) | tenant, id de requête, l’utilisateur déjà authentifié par un middleware |
import { Component, RESPONSE_INIT, inject } from '@angular/core';
@Component({
selector: 'app-product-missing',
template: `<h1>Ce produit n’existe plus</h1><a href="/produse">Voir le catalogue</a>`,
})
export class ProductMissing {
constructor() {
// null dans le navigateur, pendant le prerender (SSG) et au build — d’où le ?.
const response = inject(RESPONSE_INIT);
if (response) response.status = 404; // sinon : un « soft 404 » est indexé comme une vraie page
}
}Pourquoi le statut compte : une page « Le produit n’existe pas » servie avec un 200 est un soft 404, que les moteurs de recherche peuvent indexer comme une vraie page. Le status de la ServerRoute couvre les cas statiques (la route **) ; pour « l’id n’existe pas en base », il faut RESPONSE_INIT. Cela fonctionne parce que @angular/ssr ne construit la réponse qu’après whenStable() : d’ici là, vous pouvez changer le statut.
import { isPlatformBrowser } from '@angular/common';
import { PLATFORM_ID, REQUEST, Service, inject } from '@angular/core';
@Service()
export class RequestInfo {
private readonly request = inject(REQUEST); // Request de la Web API ; null dans le navigateur / en SSG
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
/** Langue préférée : l’en-tête sur le serveur, navigator dans le navigateur. */
preferredLanguage(): string {
if (this.request) return this.request.headers.get('accept-language')?.split(',')[0].trim() || 'fr';
// pas de requête : navigateur OU prerender. NE testez PAS « typeof navigator » — Node 21+ l’a.
return this.isBrowser ? navigator.language : 'fr';
}
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;
}
}URL relatives et absolues
Sur le serveur, il n’y a pas de « page courante » par rapport à laquelle résoudre une URL relative. HttpClient règle le problème tout seul : @angular/platform-server ajoute un intercepteur qui transforme /api/… en URL absolue, à partir de l’URL de la requête et de <base href>. Un fetch() direct et les SDK qui l’utilisent n’en bénéficient pas.
// ✓ HttpClient sur le serveur : l’URL relative est résolue par rapport à l’URL de la requête courante
this.http.get('/api/products'); // → https://magazin.ro/api/products
// ✗ fetch() directement (ou un SDK qui l’utilise) — Node n’a pas de « page courante » :
await fetch('/api/products'); // TypeError: Failed to parse URL from /api/products
// ✓ si vous devez utiliser fetch() : construisez l’URL absolue à partir de la requête
const base = inject(REQUEST)?.url ?? inject(DOCUMENT).location.href;
await fetch(new URL('/api/products', base));Précisément parce que les URL sont construites à partir de l’hôte de la requête, AngularNodeAppEngine vérifie le nom d’hôte par rapport à allowedHosts (dans angular.json, à build.options.security.allowedHosts, ou via la variable NG_ALLOWED_HOSTS) et répond 400 pour un hôte inconnu. C’est la protection contre le SSRF : configurez-la avec vos domaines.
État qui fuit entre les requêtes
Dans le navigateur, un module JavaScript est évalué une fois par onglet, c’est-à-dire par utilisateur. Sur le serveur, le même module est évalué une fois par processus et sert des milliers de requêtes. Toute variable au niveau du module devient un état partagé par tous les utilisateurs. Un provider avec useValue a le même problème : l’objet est créé au chargement du module et vit jusqu’au redémarrage du serveur — le guide SSR le dit explicitement et recommande useFactory.
Les services sont sûrs : à chaque requête, @angular/ssr crée une nouvelle plateforme et une nouvelle application, donc un nouvel injecteur root. Chaque utilisateur a ses propres instances, détruites après la réponse.
// ✗ État au niveau du module : le fichier est évalué UNE SEULE FOIS par processus Node,
// donc les variables sont partagées par TOUTES les requêtes (et tous les utilisateurs).
let currentUser: User | null = null; // l’utilisateur B voit l’utilisateur A
const cart = new Map<number, number>(); // le panier grossit à chaque requête
// ✗ Pareil avec useValue : l’objet est créé une seule fois, au chargement du module
{ provide: CART_STATE, useValue: { items: [] } }
// ✓ Un service : sur le serveur, chaque requête démarre une nouvelle application,
// donc il obtient une nouvelle instance (détruite après la réponse).
@Service()
export class CartStore {
private readonly items = signal<CartItem[]>([]);
readonly count = computed(() => this.items().length);
}
// ✓ Ou useFactory : s’exécute pour chaque application créée
{ provide: CART_STATE, useFactory: () => ({ items: [] }) }const cache = new Map() au niveau du module ressemble à une optimisation. Sur le serveur, c’est une fuite mémoire (il grossit sans fin) et, si la clé n’inclut pas l’utilisateur, une fuite de données entre comptes. Si vous voulez un cache entre les requêtes, rendez-le explicite — dans server.ts ou au niveau du CDN — avec des clés, un TTL et une limite de taille.Étude de cas : le thème
Le thème est l’exemple classique : la préférence vit dans localStorage, et le CSS dépend d’un attribut sur <html>. Ce site est une SPA, sans SSR, donc son ThemeStore est correct ici. Mais c’est un bon exercice : si nous passions au SSR demain, qu’est-ce qui casserait ?
// Le ThemeStore de ce site (fragment). Le site est une SPA, donc il est correct ICI.
@Service()
export class ThemeStore {
private readonly document = inject(DOCUMENT);
// ✗ sur le serveur : defaultView est le window de Domino, qui n’a PAS de matchMedia → TypeError
private readonly media = this.document.defaultView?.matchMedia('(prefers-color-scheme: dark)');
// readStoredMode() lit localStorage dans un try/catch : sur le serveur, il ne plante pas,
// mais renvoie toujours 'system' — le serveur ne peut pas savoir ce que l’utilisateur a choisi.
readonly mode = signal<ThemeMode>(readStoredMode());
constructor() {
this.media?.addEventListener('change', (e) => this.systemDark.set(e.matches));
effect(() => {
// ✓ DOM via DOCUMENT : fonctionne aussi sur le serveur
// localStorage dans un try/catch : sur le serveur, il est ignoré en silence
});
}
}Deux problèmes. Le premier : la ligne avec matchMedia lève une exception à la création du service, et ?. n’aide pas, car sur le serveur defaultView existe (le fragment defaultView?.matchMedia(…) de la première démo reproduit exactement cela). Le second : le serveur rend toujours le thème system, car il ne voit pas localStorage. La variante SSR-safe déplace la préférence dans un cookie, que le serveur lit depuis REQUEST, et l’effect() écrit aussi l’attribut sur le serveur :
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));
// Le serveur ne sait pas si le système est en mode sombre : pour 'system', c’est le CSS qui décide
// (prefers-color-scheme), et le signal est corrigé dans le navigateur.
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)'); // navigateur uniquement
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();
// s’exécute aussi sur le serveur → <html data-theme="dark"> est déjà dans le HTML, pas de flash
const root = this.document.documentElement;
if (mode === 'system') root.removeAttribute('data-theme');
else root.setAttribute('data-theme', mode);
// cookie (pas localStorage) : le serveur le reçoit à la requête suivante
if (this.isBrowser) {
this.document.cookie = `${COOKIE}=${mode}; path=/; max-age=31536000; samesite=lax`;
}
});
}
private readCookie(): ThemeMode {
const raw = this.request?.headers.get('cookie') // serveur : l’en-tête de la requête
?? (this.isBrowser ? this.document.cookie : ''); // navigateur (sur le serveur, cookie lève une erreur)
const value = raw
.split('; ')
.find((c) => c.startsWith(`${COOKIE}=`))
?.slice(COOKIE.length + 1);
return value === 'light' || value === 'dark' ? value : 'system';
}
}L’alternative sans cookie : vous gardez localStorage et un script inline dans index.html qui définit data-theme avant le premier paint — exactement ce que fait ce site. Ça fonctionne aussi avec le SSR (le serveur envoie du HTML sans l’attribut, le script le place dans le navigateur avant l’application), mais le serveur ne connaît toujours pas le thème, donc il ne peut pas choisir, par exemple, les bonnes images ou theme-color.
<!-- L’alternative sans cookie (ce que fait ce site) : un script inline dans <head>,
qui s’exécute avant le premier paint, dans n’importe quel mode de rendu (CSR, SSR, prerender). -->
<script>
try {
var t = localStorage.getItem('atlas-theme');
if (t === 'light' || t === 'dark') document.documentElement.setAttribute('data-theme', t);
} catch (e) {}
</script>Quand OUI, quand NON
Un code SSR-safe ne signifie pas if (isBrowser) partout, mais chaque chose placée au bon endroit.
afterNextRender/afterRenderEffectpour tout ce qui touche au DOM, au layout, au stockage ou à une bibliothèque navigateur.inject(DOCUMENT),Title,Metapour tout ce qui doit être dans le HTML venant du serveur (SEO, partage).- Des providers de plateforme dans
app.config.server.tsquand le serveur et le navigateur font des choses différentes. - Le cache HTTP par défaut pour les GET publics ; un
TransferStatemanuel ouresource({ id })pour les données qui ne passent pas parHttpClient. PendingTasksouresource()pour tout travail asynchrone dont le résultat doit se retrouver dans le HTML.RESPONSE_INITpour les statuts dynamiques,REQUESTpour les cookies et la langue.
isPlatformBrowserdans un@ifdu template →@deferouafterNextRender, avec le même HTML sur les deux plateformes.typeof windowsaupoudré dans les composants → un service avec une implémentation serveur distincte.TransferStatepour de grandes listes ou des données propres à l’utilisateur sur des pages mises en cache au CDN → chargez-les dans le navigateur.PendingTaskspour le polling, les websockets, l’analytics → démarrez-les dansafterNextRender; le serveur ne doit pas les attendre. Un exemple complet se trouve sur la page WebSockets.- Cache HTTP pour des réponses sensibles →
filteroutransferCache: false. ngSkipHydrationcomme solution permanente → corrigez la manipulation directe du DOM.
Pièges fréquents
effect qui écrit dans localStorage, appelle matchMedia ou démarre un observer casse au rendu. Ce qui appartient au navigateur va dans afterRenderEffect ou sous afterNextRender.document.defaultView?.matchMedia(…) et typeof navigator !== 'undefined' semblent sûrs, mais sur le serveur defaultView existe (le window de Domino, sans matchMedia), et Node 21+ a un navigator. Le premier lève une TypeError, le second donne la mauvaise réponse. Utilisez afterNextRender ou isPlatformBrowser.- Mesures dans le constructeur ou
ngOnInit: elles cassent sur le serveur (il n’y a pas de layout). Dans le navigateur, le constructeur mesure 0, et dansngOnInitles bindings et les blocs@if/@fordu composant ne sont pas encore rendus. Correction :afterNextRender({ read }). - Timers et abonnements démarrés dans le constructeur : avec Zone.js ils bloquent la sérialisation, en zoneless ils continuent de tourner. Correction : démarrez-les dans
afterNextRenderet arrêtez-les avecDestroyRef. pendingTasks.add()sansfinally: une exception et la requête ne se termine jamais. Correction :try/finallyourun().- Asynchrone non suivi en zoneless :
fetch, SDK,setTimeout→ HTML vide + une requête de plus dans le navigateur. Correction :resource()ouPendingTasks. - Clés
TransferStategénériques ('data') ou unresource({ id })statique dans un composant utilisé deux fois sur la page : les valeurs s’écrasent mutuellement. Correction : des clés qui incluent les paramètres (article:${slug}), des ids uniques par page. - La clé n’est pas retirée après lecture : une navigation ultérieure sur la même page récupère les anciennes données du HTML. Correction :
state.remove(key)après la première utilisation. HTTP_TRANSFER_CACHE_ORIGIN_MAPdansapp.config.ts: erreur NG02803 dans le navigateur. Correction : uniquement dansapp.config.server.ts.- Un
fetch('/api/…')relatif sur le serveur :TypeError: Failed to parse URL. Correction :HttpClientou une URL absolue construite depuisREQUEST. - Une page « introuvable » avec le statut 200 : un soft 404 dans Google. Correction :
RESPONSE_INIToustatus: 404sur la route**. - Des valeurs qui diffèrent entre serveur et navigateur :
Date.now(),Math.random(), des dates formatées dans le fuseau horaire du serveur (souvent UTC). Le texte change à l’hydration. Correction : générez-les une fois sur le serveur et transférez-les, ou affichez-les uniquement dans le navigateur. - État au niveau du module et
useValueavec des objets : partagés entre utilisateurs. Correction : services ouuseFactory.
Aide-mémoire
| Vous voulez… | À utiliser |
|---|---|
| toucher au DOM / au layout une seule fois, après le rendu | afterNextRender({ earlyRead, write, read }) |
| synchroniser le DOM avec certains signals | afterRenderEffect(() => …) |
| mesurer après chaque rendu | afterEveryRender({ read }) |
charger une bibliothèque qui touche à window | await import('lib') dans afterNextRender, ou @defer |
lire / écrire title, meta, canonical | inject(DOCUMENT), Title, Meta |
| une implémentation différente sur le serveur | une classe abstraite + un provider dans app.config.server.ts (mergeApplicationConfig) |
| un effet de bord uniquement dans le navigateur | isPlatformBrowser(inject(PLATFORM_ID)) |
| ne pas répéter les GET dans le navigateur | provideClientHydration() (par défaut), withHttpTransferCacheOptions(…) |
| retirer une requête du cache | filter ou { transferCache: false } |
transférer des données qui ne passent pas par HttpClient | makeStateKey<T>() + TransferState, ou resource({ id }) (v22) |
| des origines d’API différentes sur le serveur et dans le navigateur | HTTP_TRANSFER_CACHE_ORIGIN_MAP, uniquement sur le serveur |
| que le serveur attende un traitement asynchrone | resource(), PendingTasks.run() / add(), pendingUntilEvent() |
| savoir pourquoi l’application ne devient pas stable | provideStabilityDebugging() |
| lire les cookies / en-têtes | inject(REQUEST)?.headers.get('cookie') |
| répondre avec un 404 | inject(RESPONSE_INIT) → status = 404, ou status: 404 sur la route ** |
recevoir du contexte depuis server.ts | handle(req, ctx) → inject(REQUEST_CONTEXT) |
| un état propre à l’utilisateur sur le serveur | services ou useFactory — jamais de variables de module |