SSR & Hydration — @angular/ssr
TL;DR — Le rendu côté serveur (SSR) génère le HTML initial sur Node avant de l'envoyer au navigateur, qui « hydrate » ensuite cet HTML pour devenir une application interactive. Depuis Angular 16, l'hydratation est non-destructive : le DOM serveur est réutilisé. Depuis v17, le package
@angular/ssrremplace@nguniversal/express-engine(Angular Universal a été renommé et refactoré). Depuis v18, le replay d'événements capture les clics avant la fin de l'hydratation. Depuis v19, l'incremental hydration déclenche l'hydratation à la demande via@defer (hydrate on …). SSR améliore le SEO, le LCP et le partage social, mais introduit des pièges (window,document, mémoire serveur, fetch dupliqué). UtilisezTransferStatepour éviter le double fetch etisPlatformBrowser()pour garder du code spécifique au navigateur.
🧠 Mental model — ASCII + analogie
L'analogie classique : SPA = restaurant qui prépare votre plat à votre arrivée. SSR = livraison à domicile où le plat arrive déjà cuit, vous n'avez qu'à le réchauffer (hydrater) pour le manger.
┌─────────────────────────────────────────────────────────────┐
│ 1. REQUEST │
│ Browser ──── GET /products/42 ────► Node SSR │
│ │
│ 2. SERVER RENDER │
│ ┌─────────────────────────────────────────┐ │
│ │ Angular bootstrapApplication (Node) │ │
│ │ ├─ Router résout /products/42 │ │
│ │ ├─ Composants exécutent ngOnInit │ │
│ │ ├─ HttpClient → fetch via fetch API │ │
│ │ ├─ TransferState capture les données │ │
│ │ └─ renderApplication() → HTML string │ │
│ └─────────────────────────────────────────┘ │
│ │
│ 3. RESPONSE │
│ Server ──── HTML + <script>TransferState</script> ──► │
│ │
│ 4. BROWSER PAINT (LCP rapide) │
│ Le navigateur peint immédiatement l'HTML (pas de blank). │
│ │
│ 5. JS BUNDLE LOAD │
│ <script type="module" src="main.js"> télécharge. │
│ │
│ 6. HYDRATION (non-destructive) │
│ Angular monte sur le DOM existant SANS le recréer. │
│ - Lit le TransferState pour skip les HTTP │
│ - Attache les event listeners │
│ - Reprend là où le serveur s'est arrêté │
│ │
│ 7. INTERACTIVE (TTI) │
│ L'application répond aux clics / inputs. │
└─────────────────────────────────────────────────────────────┘Avant Angular 16, l'hydratation était destructive : Angular détruisait l'HTML serveur puis re-rendait tout. Résultat : un flash visuel et une perte de performance. Depuis v16, le DOM est conservé. Depuis v18, les clics survenant entre l'affichage HTML et la fin de l'hydratation sont rejoués (event replay). Depuis v19, l'hydratation peut être incrémentale : seuls les blocs @defer (hydrate on viewport) s'hydratent quand l'utilisateur les voit.
🛠️ Code minimal (ts + html)
Configuration SSR (v17+)
# Création d'un projet avec SSR dès le départ
ng new mon-app --ssr
# Ou ajout de SSR à un projet existant
ng add @angular/ssrLa commande ng add @angular/ssr génère :
src/
main.ts # Bootstrap client
main.server.ts # Bootstrap serveur (renderApplication)
app/
app.config.ts # Config commune (providers)
app.config.server.ts # Config serveur uniquement
server.ts # Express handler (entry point Node)Activer l'hydratation client
// src/app/app.config.ts
import { ApplicationConfig } from '@angular/core';
import {
provideClientHydration,
withEventReplay, // v18+
withIncrementalHydration, // v19+
withHttpTransferCacheOptions,
} from '@angular/platform-browser';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withFetch } from '@angular/common/http';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
// withFetch() est requis pour le SSR : utilise l'API fetch standard
// au lieu de XHR (qui n'existe pas dans Node).
provideHttpClient(withFetch()),
provideClientHydration(
withEventReplay(),
withIncrementalHydration(),
withHttpTransferCacheOptions({
includePostRequests: false,
includeHeaders: ['x-tenant-id'],
filter: (req) => !req.url.includes('/admin'),
}),
),
],
};Garde-fous navigateur
import { Component, PLATFORM_ID, inject, afterNextRender } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
@Component({
selector: 'app-chart',
template: `<canvas #canvas></canvas>`,
})
export class ChartComponent {
private platformId = inject(PLATFORM_ID);
constructor() {
// afterNextRender ne s'exécute QUE côté navigateur, jamais sur le serveur.
// C'est la façon idiomatique d'utiliser une lib qui touche le DOM.
afterNextRender(() => {
import('chart.js').then(({ Chart }) => {
// Initialisation du canvas, accès à document, window, etc.
});
});
}
doSomething() {
if (isPlatformBrowser(this.platformId)) {
localStorage.setItem('foo', 'bar');
}
}
}Éviter le double fetch avec TransferState
Sans précaution, une requête HTTP exécutée sur le serveur est rejouée côté client après hydratation, ce qui annule le bénéfice du SSR. Depuis v16, withHttpTransferCacheOptions automatise ce cache pour les requêtes GET/HEAD. Pour des cas custom :
import { Injectable, TransferState, makeStateKey, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of, tap } from 'rxjs';
const PRODUCTS_KEY = makeStateKey<Product[]>('products');
@Injectable({ providedIn: 'root' })
export class ProductService {
private http = inject(HttpClient);
private state = inject(TransferState);
list(): Observable<Product[]> {
// 1. Côté client après hydratation : la clé est présente, on lit le cache.
const cached = this.state.get(PRODUCTS_KEY, null);
if (cached) {
this.state.remove(PRODUCTS_KEY); // one-shot
return of(cached);
}
// 2. Côté serveur (ou client cold), on fait l'appel réel.
return this.http
.get<Product[]>('/api/products')
.pipe(tap((data) => this.state.set(PRODUCTS_KEY, data)));
}
}Server entry (server.ts)
import { AngularNodeAppEngine, createNodeRequestHandler, isMainModule, writeResponseToNodeResponse } from '@angular/ssr/node';
import express from 'express';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const browserDistFolder = resolve(dirname(fileURLToPath(import.meta.url)), '../browser');
const app = express();
const angularApp = new AngularNodeAppEngine();
app.use(express.static(browserDistFolder, { maxAge: '1y', index: false }));
app.use((req, res, next) => {
angularApp
.handle(req)
.then((response) => (response ? writeResponseToNodeResponse(response, res) : next()))
.catch(next);
});
if (isMainModule(import.meta.url)) {
const port = process.env['PORT'] || 4000;
app.listen(port, () => console.log(`SSR listening on ${port}`));
}
export const reqHandler = createNodeRequestHandler(app);Template avec incremental hydration (v19+)
<!-- Le bloc n'est PAS hydraté tant qu'il n'est pas visible. -->
<!-- L'HTML est servi par le serveur, mais le JS associé reste lazy. -->
@defer (hydrate on viewport) {
<app-product-recommendations />
} @placeholder {
<div class="skeleton-list"></div>
}
<!-- Plusieurs déclencheurs possibles : on interaction, on hover, on idle, on timer(2s), on immediate -->
@defer (hydrate on interaction) {
<app-comments />
}
<!-- never : le bloc ne s'hydrate jamais (purement statique, gain max) -->
@defer (hydrate never) {
<app-footer-static />
}🎯 Patterns courants
1. Prerendering (SSG) au moment du build
Pour les routes qui ne changent pas par utilisateur (landing, blog, docs), on peut pré-rendre au build : on génère un fichier HTML par route, déployable sur n'importe quel CDN statique sans Node en production.
// angular.json — section "outputMode": "server" pour SSR à la requête
// ou "outputMode": "static" pour prerendering uniquement.
{
"architect": {
"build": {
"options": {
"outputMode": "server",
"prerender": {
"routesFile": "routes.txt",
"discoverRoutes": true
}
}
}
}
}# routes.txt — une route par ligne
/
/about
/pricing
/blog/article-1
/blog/article-2Le mode hybride permet de mélanger : /blog/* prérendu, /dashboard rendu à la demande, /api/* côté serveur uniquement. Les route-level render modes (v19+) déclarent la stratégie par route :
// app.routes.server.ts
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{ path: '', renderMode: RenderMode.Prerender },
{ path: 'pricing', renderMode: RenderMode.Prerender },
{ path: 'dashboard/**', renderMode: RenderMode.Server }, // SSR à la requête
{ path: 'api/**', renderMode: RenderMode.AppShell },
];2. SEO dynamique
Le SSR n'a d'intérêt SEO que si les balises <title> et <meta> reflètent la route. Le service Title et Meta d'Angular fonctionne côté serveur :
import { Component, inject, OnInit } from '@angular/core';
import { Title, Meta } from '@angular/platform-browser';
@Component({ /* ... */ })
export class ArticleComponent implements OnInit {
private title = inject(Title);
private meta = inject(Meta);
ngOnInit() {
this.title.setTitle('Mon article — MonSite');
this.meta.updateTag({ name: 'description', content: 'Résumé de l\'article' });
this.meta.updateTag({ property: 'og:image', content: 'https://cdn/og.png' });
this.meta.updateTag({ property: 'og:type', content: 'article' });
}
}3. Critical CSS inlining
Angular inline automatiquement le critical CSS dans le <head> du HTML serveur depuis v15 (inlineCritical: true dans optimization.styles). Cela évite le FOUC (Flash of Unstyled Content) avant le chargement du bundle CSS principal.
4. Cookies et auth en SSR
Une requête SSR n'a pas accès au localStorage, mais elle reçoit les cookies du navigateur via l'en-tête Cookie. Pour faire un appel API authentifié côté serveur, il faut propager ces cookies :
// http-context.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { inject, PLATFORM_ID, REQUEST } from '@angular/core';
import { isPlatformServer } from '@angular/common';
export const ssrCookieInterceptor: HttpInterceptorFn = (req, next) => {
const platformId = inject(PLATFORM_ID);
if (isPlatformServer(platformId)) {
const request = inject(REQUEST, { optional: true });
const cookie = request?.headers.get('cookie');
if (cookie) {
req = req.clone({ setHeaders: { Cookie: cookie } });
}
}
return next(req);
};Depuis v19, les jetons REQUEST, REQUEST_CONTEXT et RESPONSE_INIT permettent d'accéder à la requête/réponse Node depuis l'injecteur, ce qui simplifie ce pattern.
5. Déploiement — Node host vs Edge
Node host (Render, Railway, Fly.io, AWS Lambda Node18+, GCP Cloud Run) : on déploie le serveur Express généré, qui charge l'app Angular au démarrage. Cold start ~300-800 ms, mémoire ~80-150 Mo par instance. Adapté aux applications avec sessions, accès BDD direct, beaucoup de routes dynamiques.
Edge (Cloudflare Workers, Vercel Edge, Netlify Edge) : exécution dans un sandbox V8 isolate, démarrage quasi-instantané (~5 ms), mais API restreinte (pas de
fs, pas de Node natif). Le builder Angular peut produire un bundle compatible Workers (outputMode: server+ adapter). Idéal pour des sites à audience mondiale et latence ultra-faible.
# Dockerfile multi-stage pour Node host
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine
WORKDIR /app
COPY --from=build /app/dist/mon-app ./dist/mon-app
COPY --from=build /app/node_modules ./node_modules
ENV NODE_ENV=production
EXPOSE 4000
CMD ["node", "dist/mon-app/server/server.mjs"]🔄 Versions — Angular 16 → 20
- v16 —
provideClientHydration()introduit, hydratation non-destructive stable. Auparavant le DOM serveur était détruit puis recréé côté client, provoquant un flash visuel. Le package@nguniversal/express-engineest toujours là. - v17 — Package officiel renommé en
@angular/ssr. Schematicng add @angular/ssrremplace l'ancien. Application builder (esbuild) devient la valeur par défaut, divisant les temps de build par 2-4. Vite pour le dev server. - v18 — Event replay (
withEventReplay()) en preview : les clics avant la fin de l'hydratation sont capturés via JSAction (la lib Google) et rejoués. Server-side route resolution : le serveur résout la route avant le render. Lesi18nblocks sont enfin pleinement hydratés. - v19 — Incremental hydration stable via
@defer (hydrate on …). Route-level render modes (RenderMode.Prerender | Server | AppShell | Client). Le modeoutputModeremplace les anciens flags.withI18nSupport()ajusté. Event replay passe en stable. - v20 — Stabilisation et optimisations. Le rendering de zone est facultatif (
provideZonelessChangeDetection()), et le SSR sait gérer le mode zoneless. La gestion mémoire serveur s'améliore (résolution de plusieurs fuites liées aux observables non terminés côté serveur).
⚠️ Pitfalls — 6-10
- Référencer
windowoudocumentau constructor oungOnInit— crash immédiat côté serveur. Solution :afterNextRender()ouisPlatformBrowser(). - Utiliser une lib npm qui touche le DOM au top-level import — l'import lui-même casse le build serveur. Solution :
await import('lib')dans unafterNextRender. - Oublier
withFetch()—HttpClientutilise XHR par défaut, qui n'existe pas dans Node. SanswithFetch(), vous obtenezXMLHttpRequest is not defined. - Double fetch — sans
TransferState, chaque HTTP server-side est rejoué client-side, doublant la latence perçue. Le HTTP transfer cache (activé par défaut depuis v17) couvre la plupart des cas, mais pas POST ni les requêtes avec en-têtes filtrés. - Hydration mismatch — l'HTML serveur doit être strictement identique à celui que rendrait le client. Différer un rendu via
setTimeoutou utiliserMath.random()/Date.now()provoque des warnings et des écarts visuels. - Memory leaks côté serveur — un observable non terminé (
interval(1000)) reste actif et empêche la requête de se terminer. Toujours utilisertakeUntilDestroyed()outake(1). - Third-party JS non-SSR-friendly — beaucoup de libs (analytics, chat widgets) attendent
window. Charger ces scripts uniquement côté client viaafterNextRender, ou via un<script>avecdeferinjecté dansindex.html. localStoragelu au démarrage — uninject(...)qui litlocalStorageau constructor crash. Encapsuler dans un service avec gardeisPlatformBrowser.- Routes paramétrées non listées — le prerendering ne devine pas
/products/:id. Lister explicitement viagetPrerenderParams()ou activerdiscoverRoutesavec un crawler. - Cache CDN trop agressif sur HTML SSR — le HTML est dynamique mais peut être mis en cache par erreur. Toujours vérifier les en-têtes
Cache-Control(HTML :no-storeous-maxagecourt, assets versionnés :immutable1 an).
🧪 Testing
Tester du SSR demande de simuler l'environnement Node + l'absence de window. Approches :
// Test unitaire : utiliser le TestBed avec ServerTestingModule
import { TestBed } from '@angular/core/testing';
import { ServerTestingModule } from '@angular/platform-server/testing';
it('rend en SSR sans crasher', () => {
TestBed.configureTestingModule({
imports: [ServerTestingModule, MyComponent],
});
const fixture = TestBed.createComponent(MyComponent);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Hello');
});// Test E2E (Playwright) — vérifier que le HTML initial contient le texte
test('SSR renders product list before JS hydrates', async ({ page }) => {
await page.route('**/main-*.js', (route) => route.abort());
const response = await page.goto('/products');
const html = await response!.text();
expect(html).toContain('Produit 1');
expect(html).toContain('<script type="application/json" id="ng-state">');
});Pour valider la non-destructive hydration, ouvrir DevTools > Performance et confirmer l'absence de re-render au load. Le warning NG0500: There was an error during hydration apparaît en console en cas de mismatch.
🎬 Cas d'usage concrets
Scénario 1 — E-commerce mode, SEO via SSR
Contexte : le retailer mode tire 40% de son trafic du SEO organique (Google Shopping, Lighthouse SEO score crucial pour le ranking). Une SPA classique ne fonctionne pas car Googlebot indexe mal le contenu JS-rendered, et les bots Pinterest/Facebook ne savent pas attendre le JS pour générer un preview. Approche : SSR @angular/ssr sur toute la zone publique (catalogue, fiche produit, blog), avec incremental hydration v19+ sur les blocs interactifs (sélecteur taille, ajout panier, avis client). Le HTML serveur contient le structured data JSON-LD (Schema.org Product, BreadcrumbList) injecté via Meta service. Les images sont lazy au-dessous du fold mais le LCP image (photo produit hero) a fetchpriority="high". Le cache CDN (Cloudflare) garde les pages produit 10 minutes avec stale-while-revalidate, ce qui rend la latence TTFB sous 100ms en moyenne. Résultat : Lighthouse SEO à 100, LCP médian à 1.2s sur 4G, et un gain de 28% de trafic organique en 6 mois.
Scénario 2 — SaaS RH, portail public offres d'emploi
Contexte : le SaaS RH expose pour chaque client un portail public d'offres d'emploi (careers.client.com) indexable par Indeed, Google for Jobs, LinkedIn. L'app interne (recrutement, paramétrage) reste en SPA classique. Approche : architecture en deux sous-applications Angular dans un monorepo Nx — apps/portail-public en SSR avec routes /offres, /offres/:slug, /postuler/:slug, et apps/back-office en SPA classique. Le portail SSR utilise TransferState pour éviter le double fetch (l'API liste des offres est appelée côté serveur, le state est sérialisé dans le HTML, le client le récupère sans nouveau HTTP). Les meta tags Open Graph et JobPosting JSON-LD sont injectés depuis les données de l'offre. Chaque offre a une URL canonique /offres/:slug avec slug stable. Le formulaire de candidature côté public est hydraté à la première interaction (@defer hydrate on interaction) pour ne pas charger ReactiveForms en eager. Bénéfice métier : référencement Google for Jobs activé pour tous les clients, génération de leads candidats × 3.
Scénario 3 — Cabinet juridique, site public + zone client
Contexte : le cabinet juridique veut une vitrine publique (présentation, expertises, équipe, blog d'analyses juridiques) référencée Google, doublée d'une zone client privée (suivi de dossiers). Approche : SSR Angular sur les routes publiques /, /expertises, /equipe, /blog/:slug, /contact avec prerender complet au build (outputMode: 'static' pour ces routes) car le contenu change rarement (mise à jour CI quand le CMS publie). Les articles de blog ont sitemap.xml généré automatiquement, balises canonical, et structured data Article. La route /client est server-rendered à la demande (outputMode: 'server') car elle redirige selon l'authentification. La zone authentifiée bascule en CSR classique après login. Le site public passe Lighthouse Performance 98, Accessibility 100, SEO 100, et le cabinet apparait en première page Google sur ses expertises clés.
🛠️ Exemple end-to-end
Use case : page produit e-commerce mode en SSR avec TransferState, meta tags SEO, structured data, et hydration incrementale.
// app.config.server.ts — API Angular 20 (courante)
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
// ⚠️ v20 : provideServerRendering vient de '@angular/ssr' (PLUS de
// '@angular/platform-server') et absorbe le routing serveur via withRoutes().
// provideServerRouting() est SUPPRIMÉ en v20 (la migration ng update le réécrit).
import { provideServerRendering, withRoutes } from '@angular/ssr';
import { appConfig } from './app.config';
import { serverRoutes } from './app.routes.server';
const serverConfig: ApplicationConfig = {
providers: [provideServerRendering(withRoutes(serverRoutes))],
};
export const config = mergeApplicationConfig(appConfig, serverConfig);Note de version (piège upgrade). En v17/18 :
provideServerRendering()(sans args) depuis@angular/platform-server+ l'ancien routing implicite. En v19 :provideServerRendering()depuis@angular/platform-server+provideServerRouting(serverRoutes)depuis@angular/ssr. En v20 : tout est unifié enprovideServerRendering(withRoutes(serverRoutes))depuis@angular/ssr, etprovideServerRoutingdisparaît. Si vous voyezprovideServerRoutingdans un tuto, c'est du v19 — lancezng update @angular/core @angular/cliqui applique la migration automatique.
// app.routes.server.ts
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{ path: '', renderMode: RenderMode.Prerender },
{ path: 'categories/:slug', renderMode: RenderMode.Server },
{ path: 'produits/:slug', renderMode: RenderMode.Server },
{ path: 'panier', renderMode: RenderMode.Client },
{ path: 'compte/**', renderMode: RenderMode.Client },
];⚠️ Piège SSR fréquent dans cet exemple — meta tags & timing. Un
rxResource/resourceest asynchrone : son.value()estundefinedau premier tick. Si vous lisezproduitRes.value()dansngOnInitpour poser le<title>, il seraundefinedsur le serveur et les meta SEO ne partiront jamais dans le HTML — le bénéfice SSR est annulé. Deux solutions correctes : (a) un resolver de route qui résout les données AVANT le render (le SSR attend les resolvers), ou (b) uneffect()qui réagit àvalue()ET qui sait écrire les meta côté serveur. L'exemple ci-dessous utilise uneffect(); en production sur une page SEO-critique, préférez le resolver car il garantit que le render serveur ne se déclenche qu'une fois les données prêtes.
// produit-detail.component.ts
import { ChangeDetectionStrategy, Component, effect, inject, input } from '@angular/core';
import { Meta, Title } from '@angular/platform-browser';
import { rxResource } from '@angular/core/rxjs-interop';
import { CurrencyPipe } from '@angular/common';
import { ProduitsApi } from './produits.api';
@Component({
selector: 'app-produit-detail',
imports: [CurrencyPipe],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (produitRes.value(); as p) {
<article itemscope itemtype="https://schema.org/Product">
<h1 itemprop="name">{{ p.nom }}</h1>
<img
itemprop="image"
[src]="p.imageUrl"
[alt]="p.nom"
fetchpriority="high"
width="800" height="800"
/>
<p itemprop="description">{{ p.description }}</p>
<p itemprop="offers" itemscope itemtype="https://schema.org/Offer">
<span itemprop="price">{{ p.prix | currency:'EUR' }}</span>
<link itemprop="availability" [href]="p.enStock ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock'" />
</p>
@defer (hydrate on interaction; hydrate on viewport) {
<app-product-actions [produit]="p" />
} @placeholder {
<button class="placeholder-btn">Ajouter au panier</button>
}
@defer (hydrate on viewport) {
<app-product-reviews [produitId]="p.id" />
}
</article>
}
`,
})
export class ProduitDetailComponent {
readonly slug = input.required<string>();
private api = inject(ProduitsApi);
private meta = inject(Meta);
private title = inject(Title);
protected produitRes = rxResource({
// `params` remplace `request` (renommé en Angular 19/20).
params: () => ({ slug: this.slug() }),
stream: ({ params }) => this.api.getBySlug(params.slug),
});
constructor() {
// effect() réagit dès que la valeur arrive — y compris pendant le render
// serveur, car Angular attend la stabilité avant `renderApplication()`.
effect(() => {
const p = this.produitRes.value();
if (!p) return;
this.title.setTitle(`${p.nom} — Shop Mode`);
this.meta.updateTag({ name: 'description', content: p.description.slice(0, 160) });
this.meta.updateTag({ property: 'og:title', content: p.nom });
this.meta.updateTag({ property: 'og:image', content: p.imageUrl });
this.meta.updateTag({ property: 'og:type', content: 'product' });
});
}
}// produits.api.ts (TransferState pour éviter double fetch)
import { Injectable, TransferState, inject, makeStateKey } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of, tap } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class ProduitsApi {
private http = inject(HttpClient);
private state = inject(TransferState);
getBySlug(slug: string): Observable<Produit> {
const key = makeStateKey<Produit>(`produit-${slug}`);
const cached = this.state.get(key, null);
if (cached) return of(cached);
return this.http.get<Produit>(`/api/produits/${slug}`).pipe(
tap((p) => this.state.set(key, p)),
);
}
}
interface Produit { id: string; slug: string; nom: string; description: string; prix: number; imageUrl: string; enStock: boolean; }// server.ts (Express minimal, API v17+ — PAS le vieux CommonEngine)
// Note : ne plus utiliser `CommonEngine` ni `import 'zone.js/node'` ni
// `app.engine('html', …)` : c'est l'ancienne API @nguniversal, dépréciée.
import { AngularNodeAppEngine, createNodeRequestHandler, isMainModule, writeResponseToNodeResponse } from '@angular/ssr/node';
import express from 'express';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const browserDistFolder = resolve(dirname(fileURLToPath(import.meta.url)), '../browser');
const app = express();
const angularApp = new AngularNodeAppEngine();
app.use(express.static(browserDistFolder, { maxAge: '1y', index: false }));
app.use((req, res, next) => {
angularApp
.handle(req)
.then((r) => (r ? writeResponseToNodeResponse(r, res) : next()))
.catch(next);
});
if (isMainModule(import.meta.url)) {
app.listen(process.env['PORT'] || 4000);
}
export const reqHandler = createNodeRequestHandler(app);SSR avec prerender du home, server-rendering des fiches produit, TransferState pour éviter le double fetch, hydration incrémentale sur boutons et reviews — LCP < 1.5s, SEO optimal, expérience interactive rapide.
🔁 Quand utiliser / éviter
Utiliser SSR quand :
- SEO critique (e-commerce, blog, contenu indexable) ;
- partage social (Open Graph image, prévisualisations LinkedIn) ;
- LCP visé < 1,5 s sur 3G ;
- audience à connexion lente où le bundle JS retarde l'affichage.
Éviter SSR quand :
- application interne authentifiée (dashboard admin, intranet) — SEO inutile, complexité ajoutée ;
- contenu 100 % personnalisé non cacheable et coût Node infra trop élevé ;
- équipe peu familière avec les pièges (
window, fuites) — démarrer en CSR pur, ajouter SSR plus tard ; - tooling externe non compatible (vieilles libs DOM-only sans alternative).
Préférer le prerendering (SSG) quand le contenu est connu au build : marketing, docs, blog statique. C'est le meilleur compromis SEO/perf/coût.
Tableau de décision — quel RenderMode par route ?
C'est la décision d'architecture centrale d'une app @angular/ssr. Elle se prend par route, pas globalement.
| RenderMode | Quand le HTML est produit | TTFB | Coût infra | Fraîcheur | Cas typique |
|---|---|---|---|---|---|
Prerender (SSG) | au build | ~CDN (10-50 ms) | quasi nul (statique) | figée au build (+ ISR si supporté) | landing, docs, blog, fiches catalogue stables |
Server (SSR) | à chaque requête | 50-300 ms (+ render Node) | Node always-on / fonction | temps réel | fiche produit avec stock/prix live, pages personnalisées cacheables |
Client (CSR) | jamais côté serveur (shell vide hydraté) | très bas (shell statique) | nul | client only | dashboard authentifié, panier, zone privée non-SEO |
AppShell | shell au build, contenu au client | ~CDN | quasi nul | client only | PWA shell, zones offline-first |
Comment un staff engineer tranche : (1) la route est-elle indexable / partagée socialement ? Non → Client. (2) Son contenu est-il identique pour tous et stable entre deux déploiements ? Oui → Prerender (le moins cher, le plus rapide). (3) Le contenu varie par requête (stock, A/B, géo) mais reste cacheable ? → Server + s-maxage court via RESPONSE_INIT. (4) Hautement personnalisé et non-cacheable ? → soit Server derrière auth (assumer le coût Node), soit Client si le SEO n'importe pas. La granularité est l'arme : on prerend /, on SSR /produits/:slug, on CSR /compte/** dans la même application.
Prerender+ données dynamiques = ISR maison. Angular n'a pas (encore) d'ISR (Incremental Static Regeneration) natif comme Next.js. Le pattern de prod : prerender au build + invalidation CDN ciblée via webhook CMS (purge la page quand le contenu change), ouServeravecCache-Control: s-maxage=600, stale-while-revalidate=86400au edge — ce qui donne du « SSR servi froid » avec fraîcheur bornée à 10 min et zéro pénalité de latence pour 99 % du trafic.
🧰 Anatomie du processus de rendu serveur
Pour bien diagnostiquer les bugs SSR, il faut visualiser ce que fait Angular en interne entre la requête HTTP et le HTML retourné.
HTTP REQUEST NODE PROCESS HTTP RESPONSE
───────────── ──────────── ─────────────
GET /products/42 ─► 1. Express handler ◄─── 200 OK
Cookie: sid=abc 2. AngularNodeAppEngine Content-Type: text/html
.handle(req) Cache-Control: private
3. Bootstrap Angular sur Node <html>
├─ Création d'un Injector <head>
├─ Application des providers <title>…</title>
│ spécifiques serveur <style>critical</style>
├─ Router resolves /products/42 </head>
└─ Rendering tree <body>
4. ngOnInit / resolvers <app-root>…</app-root>
├─ HTTP via fetch <script id="ng-state"
│ (TransferState capture) type="application/json">
└─ Signals évaluation {...}
5. renderApplication() </script>
→ string HTML <script src="main.js" defer></script>
6. Inline du TransferState </body>
dans <script> </html>
7. Injection du <script src=main.js>
8. Réponse ExpressÀ chaque étape, des choses peuvent mal tourner : un inject(WINDOW) introuvable, une promesse non résolue qui timeout la requête, un setTimeout dans ngOnInit qui ne s'exécute jamais avant le renderApplication. Le debug consiste presque toujours à logger dans le service côté serveur (if (isPlatformServer(...)) console.log(...)) et à inspecter le HTML brut retourné par curl -s sans JavaScript.
🎛️ Configuration avancée — flags utiles
// app.config.ts
import { provideClientHydration, withEventReplay, withIncrementalHydration, withHttpTransferCacheOptions } from '@angular/platform-browser';
provideClientHydration(
// Active le rejeu d'événements (v18+).
withEventReplay(),
// Active l'hydratation incrémentale par @defer (hydrate on …) (v19+).
withIncrementalHydration(),
// Personnalise le cache HTTP transféré du serveur au client.
withHttpTransferCacheOptions({
// Cacher aussi les POST (par défaut false, attention : POST = mutation).
includePostRequests: false,
// Conserver certains headers de la requête (utile pour multi-tenant).
includeHeaders: ['x-tenant-id', 'x-locale'],
// Filtrer les requêtes à NE PAS cacher (ex. data sensible utilisateur).
filter: (req) => !req.url.startsWith('/api/me'),
// Inclure les requêtes avec auth header (par défaut exclus).
includeRequestsWithAuthHeaders: false,
}),
)Pour aller plus loin, RESPONSE_INIT permet d'écrire les en-têtes de la réponse depuis un composant ou un guard, par exemple pour mettre un Cache-Control: s-maxage=300 quand la route est cacheable :
import { inject, RESPONSE_INIT, PLATFORM_ID } from '@angular/core';
import { isPlatformServer } from '@angular/common';
const responseInit = inject(RESPONSE_INIT, { optional: true });
const platformId = inject(PLATFORM_ID);
if (isPlatformServer(platformId) && responseInit) {
responseInit.headers ??= new Headers();
(responseInit.headers as Headers).set('Cache-Control', 'public, s-maxage=300');
}📊 Mesurer l'impact SSR
Trois métriques clés à surveiller :
- TTFB (Time To First Byte) — temps avant que l'octet de HTML arrive. En SSR pur, cela inclut le rendering serveur (50-300 ms typiquement). En SSG, c'est juste la latence CDN (10-50 ms).
- LCP (Largest Contentful Paint) — temps avant qu'un élément significatif s'affiche. Le SSR le réduit drastiquement (souvent 50-70 %).
- TTI (Time To Interactive) — quand l'app répond aux clics. Le SSR le retarde légèrement (le JS doit télécharger et hydrater) ; l'event replay et l'hydratation incrémentale compensent.
Outils :
- WebPageTest (
webpagetest.org) — vue détaillée par phase, profil réseau « 3G Slow » pour stresser. - Chrome DevTools > Lighthouse — score global, suggestions.
- Chrome DevTools > Performance > Web Vitals — métriques temps réel.
- Sentry Performance / Datadog RUM — métriques en production sur vrais utilisateurs.
🤖 SSR + UI d'agent IA — streaming, hydration et flush serveur
C'est ici que le SSR rencontre votre stack IA (Python/NestJS qui sert les tokens, Angular qui les rend). Le piège mental : SSR et streaming LLM sont deux flux temporels distincts qu'il ne faut pas confondre.
Mental model — ne JAMAIS streamer un LLM pendant le render serveur
MAUVAIS (anti-pattern) BON
────────────────────── ───
GET /chat/42 GET /chat/42
└─ SSR attend la fin du └─ SSR rend le SHELL + l'historique
stream LLM (10s) avant figés (messages déjà terminés),
de flush le HTML ──► TTFB 10s flush immédiat ──► TTFB 80ms
Hydration côté client
└─ EventSource/fetch ouvre le
stream LLM, tokens rendus liveLe render serveur doit se stabiliser vite : Angular attend que la zone (ou, en zoneless, les tâches en cours) soit stable avant renderApplication(). Un stream LLM ouvert garde la requête active → la requête SSR timeout ou flush avec 10s de retard. Règle : sur le serveur, on rend l'historique de conversation déjà persisté (messages done) ; le token-streaming live est une affaire 100 % client, déclenchée après hydration.
Garde-fou : ne pas ouvrir le stream sur le serveur
// chat.component.ts
import { ChangeDetectionStrategy, Component, inject, signal, afterNextRender } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { TransferState, makeStateKey } from '@angular/core';
interface ChatMessage {
id: string;
role: 'user' | 'assistant';
// discriminated state pour la timeline de l'agent
status: 'done' | 'streaming' | 'pending' | 'error';
text: string;
}
const HISTORY_KEY = makeStateKey<ChatMessage[]>('chat-history');
@Component({
selector: 'app-chat',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@for (m of messages(); track m.id) {
<article [class]="m.role" [attr.data-status]="m.status">{{ m.text }}</article>
}
`,
})
export class ChatComponent {
private http = inject(HttpClient);
private state = inject(TransferState);
// L'historique figé est rendu côté serveur (SEO + TTFB) ET sérialisé via TransferState.
readonly messages = signal<ChatMessage[]>(this.state.get(HISTORY_KEY, []));
constructor() {
// afterNextRender ⇒ ce code ne tourne JAMAIS sur le serveur.
// On n'ouvre le stream qu'une fois le DOM hydraté côté navigateur.
afterNextRender(() => this.resumeOrStartStream());
}
private resumeOrStartStream() {
/* ouvre EventSource / fetch ReadableStream — voir bloc suivant */
}
}Streaming de tokens sous zoneless — append-only + rAF coalescing
Un stream LLM émet des dizaines de tokens/seconde. Sous zoneless (provideZonelessChangeDetection(), recommandé v18+), chaque signal.set() planifie une détection. Mettre à jour à chaque token = des dizaines de CD/s = jank. La parade : buffer append-only + flush coalescé en requestAnimationFrame.
import { signal, NgZone, inject, DestroyRef } from '@angular/core';
private appendController() {
let pending = '';
let scheduled = false;
const destroyRef = inject(DestroyRef);
const flush = () => {
scheduled = false;
if (!pending) return;
const chunk = pending;
pending = '';
// une seule mutation de signal par frame ⇒ une seule CD/frame
this.messages.update((list) => {
const last = list[list.length - 1];
const updated = { ...last, text: last.text + chunk, status: 'streaming' as const };
return [...list.slice(0, -1), updated];
});
};
return (token: string) => {
pending += token;
if (!scheduled) {
scheduled = true;
requestAnimationFrame(flush); // coalesce N tokens en 1 update/frame
}
};
}Stop = annuler client ET serveur (AbortController)
Un bouton « Stop » qui ferme juste l'EventSource côté client laisse le LLM continuer à brûler des tokens côté serveur (et à coûter de l'argent). Le contrat correct : AbortController côté client + propagation au backend qui abort() le stream Anthropic.
import { signal } from '@angular/core';
private ctrl: AbortController | null = null;
async startStream(prompt: string) {
this.ctrl = new AbortController();
const res = await fetch('/api/chat/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, generationId: crypto.randomUUID() }),
signal: this.ctrl.signal, // ⇐ abort réseau ET, si le backend l'écoute, abort LLM
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
const append = this.appendController();
for (;;) {
const { value, done } = await reader.read();
if (done) break;
// parse SSE "data: {token}\n\n" — simplifié ici
append(decoder.decode(value, { stream: true }));
}
}
stop() {
this.ctrl?.abort(); // ferme le fetch ; le backend NestJS reçoit `req.on('close')`
this.ctrl = null;
}Contrat NestJS côté serveur (rappel stack). Le handler SSE doit écouter
request.on('close')(ou l'AbortSignalNest) et passer ce signal au SDK :client.messages.stream({ model: 'claude-sonnet-4-6', … }, { signal }). Sans ça, lestop()Angular est cosmétique. LegenerationIdenvoyé par le client sert de clé d'idempotence : si le réseau coupe et que le client renvoie la requête, BullMQ/Redis déduplique au lieu de relancer une génération payante. Modèles phares actuels :claude-opus-4-8(raisonnement),claude-sonnet-4-6(équilibré, défaut chat),claude-haiku-4-5(latence/coût).
Timeline d'outils (tool-use) — discriminated union + markdown sûr
Pour afficher la trace agentique (« recherche web → lecture → réponse »), modélisez chaque étape en union discriminée et rendez le markdown via DomSanitizer (jamais d'innerHTML brut sur du contenu LLM).
type ToolStep =
| { kind: 'pending'; tool: string }
| { kind: 'running'; tool: string; input: unknown }
| { kind: 'streaming'; tool: string; partial: string }
| { kind: 'done'; tool: string; output: unknown }
| { kind: 'error'; tool: string; message: string };// rendu markdown sécurisé
import { inject } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
import { marked } from 'marked';
const sanitizer = inject(DomSanitizer);
const safeHtml = sanitizer.sanitize(1 /* SecurityContext.HTML */, marked.parse(text) as string);Note SSR sur ce bloc :
marked/DomSanitizertournent côté serveur sans souci (pas dewindow), donc l'historique markdown peut être pré-rendu. Mais l'EventSource/fetchstreaming reste client-only viaafterNextRender.
🏋️ Exercices
Stack : Angular 18-20, standalone, zoneless,
@angular/ssr. Chaque exercice se vérifie aucurl -s(HTML brut, JS désactivé) puis en navigateur.
Exercice 1 — Prouver que le SSR rend vraiment (implement)
Objectif : servir une liste de produits visible dans le HTML AVANT tout JS, sans double fetch.
Implémentez ProduitsApi avec TransferState, branchez provideClientHydration() + provideHttpClient(withFetch()), et écrivez un test Playwright qui route.abort() sur main-*.js puis vérifie que le HTML contient les noms de produits ET le <script id="ng-state">.
Indice/Solution
Réutilisez le pattern getBySlug du fichier. Le test clé : sans JS, response.text() doit contenir les données ; avec JS, ouvrez l'onglet Network et vérifiez 0 appel à /api/produits après hydration (preuve que le TransferState a servi). Si l'appel se rejoue, c'est que withHttpTransferCacheOptions exclut votre requête (auth header, POST, ou filtre).
Exercice 2 — Render modes hybrides par route (production-grade)
Objectif : une même app avec / prerendu au build, /produits/:slug SSR à la demande, /compte/** en CSR pur, et /produits/:slug qui pose des meta OG correctes.
Écrivez app.routes.server.ts avec les RenderMode adéquats, ajoutez getPrerenderParams() pour pré-rendre les 100 produits les plus vus, et posez Cache-Control: s-maxage=300 via RESPONSE_INIT sur les pages produit.
Indice/Solution
RenderMode.Prerender exige getPrerenderParams pour les routes paramétrées (sinon build échoue : « cannot prerender a parameterized route without params »). Pour les meta côté serveur, utilisez un resolver (pas ngOnInit lisant un resource async — il serait undefined au render, cf. piège plus haut). Vérifiez l'en-tête avec curl -sI /produits/x | grep -i cache-control.
Exercice 3 — Chasser un hydration mismatch (break it, then fix it)
Objectif : introduire puis diagnostiquer un NG0500.
Interpolez un appel à Date.now() dans un template (voir le bloc ci-dessous), lancez l'app, observez le warning NG0500 en console et le flicker. Puis corrigez en figeant la valeur côté serveur via un signal résolu une fois, ou en isolant la partie non-déterministe derrière un @if (isBrowser) { … }.
<!-- Reproduction du mismatch : timestamp serveur ≠ timestamp client -->
<span>{{ now }}</span>Indice/Solution
Le mismatch vient du fait que le serveur calcule un timestamp T1 et le client un T2 ≠ T1 → DOM divergent. Sources classiques : Date.now(), Math.random(), window.innerWidth, ordre de Object.keys sur une Map non triée, locale différente. Fix propre : capturer la valeur serveur dans TransferState et la relire au client, OU rendre la partie volatile uniquement après afterNextRender(). Activez withNoHttpTransferCache() temporairement pour isoler si le mismatch vient des données.
Exercice 4 — Fuite mémoire serveur (break it, then fix it)
Objectif : faire grimper la RSS du process Node sous charge, puis la stabiliser.
Dans un composant, souscrivez à interval(1000) sans takeUntilDestroyed(). Lancez 200 requêtes SSR concurrentes (autocannon ou ab), observez la mémoire grimper et les TTFB exploser. Corrigez, re-mesurez.
Indice/Solution
interval() ne complète jamais → la requête SSR ne se stabilise pas → soit elle timeout, soit l'observable reste retenu après render et le GC ne libère pas. Sur le serveur, tout flux infini doit être borné : take(1), takeUntilDestroyed(), ou isPlatformBrowser pour ne démarrer le timer qu'au client. Mesurez avec --inspect + heap snapshots avant/après, ou process.memoryUsage().rss loggé par requête.
Exercice 5 — Chat IA SSR-safe avec Stop bout-en-bout (architect)
Objectif : rendre l'historique de conversation en SSR, puis streamer la réponse live côté client avec un Stop qui annule AUSSI le LLM serveur.
Rendez les messages done via TransferState (TTFB rapide, indexable). Au afterNextRender, ouvrez le stream fetch avec getReader(), bufferisez les tokens en requestAnimationFrame (zoneless-safe), et câblez un AbortController dont le signal est propagé jusqu'à client.messages.stream(…, { signal }) côté NestJS. Prouvez via logs serveur que stop() interrompt réellement la génération.
Indice/Solution
Le test décisif : appuyez sur Stop à mi-génération et vérifiez dans les logs NestJS que l'event message_stop n'arrive pas / que le for await du stream lève AbortError. Côté NestJS, écoutez req.on('close') → controller.abort(). Idempotence : un generationId (UUID client) keyé en Redis évite de relancer une génération si le client retente. Ne JAMAIS ouvrir l'EventSource dans ngOnInit — il s'exécuterait au render serveur et garderait la requête SSR ouverte (TTFB catastrophique).
Exercice 6 — Casser puis durcir le SSR sous charge réelle (break it, then fix it)
Objectif : reproduire les trois pannes prod les plus coûteuses d'un serveur SSR — saturation CPU, cache HTML empoisonné, fuite de PII inter-requêtes — puis les corriger.
- CPU bound. Mettez
RenderMode.Serversur une route lourde, lancezautocannon -c 100 -d 20 http://localhost:4000/produits/x. Observez la latence p99 exploser (le render Angular est synchrone et CPU-bound : un seul event-loop bloque tout). Corrigez : passez la route enPrerenderou ajoutez un cache HTML au edge (s-maxage) + scaling horizontal (le render ne parallélise pas dans un process). - Cache empoisonné. Mettez un
Cache-Control: public, s-maxage=300sur une page qui dépend du cookie de session (prix négocié par client). Constatez qu'un CDN sert à l'utilisateur B la page rendue pour A. Corrigez :Vary: Cookie, ouprivate/no-storedès qu'un appel authentifié a eu lieu, ou découplez la partie personnalisée en@defer (hydrate on …)client-only. - Fuite d'état inter-requêtes. Déclarez un service
@Injectable({ providedIn: 'root' })qui stockecurrentUserdans un champ d'instance mutable, et lisez-le pendant le render. Sous concurrence, l'utilisateur A voit les données de B. Corrigez : sur le serveur, un injecteur est créé par requête — ne jamais hisser d'état requête-spécifique dans un singleton partagé ; passez parREQUEST/REQUEST_CONTEXTou un provider scoped à la requête.
Indice/Solution
(1) Le render SSR Angular est synchrone : il n'existe pas de « streaming render » par chunks comme React renderToPipeableStream — donc une route SSR lente sature un cœur. La parade prod n'est pas « optimiser le render » mais éviter de rendre à chaque requête (cache edge + Prerender) et scaler le nombre de process Node (un par cœur, cluster/PM2/replicas k8s). (2) Règle d'or : toute réponse touchée par un cookie/auth ne doit jamais être public-cachée sans Vary ; en cas de doute, private. (3) Le bug d'état partagé est le plus vicieux car invisible en dev mono-utilisateur — il n'apparaît que sous concurrence. Test : 50 requêtes concurrentes avec des cookies différents, assert que chaque réponse contient le bon identifiant. C'est la raison pour laquelle on lit la requête via les jetons DI (REQUEST) et jamais via une variable module-level.
🎤 En entretien
Q : Pourquoi l'hydratation Angular ≥16 est-elle « non-destructive », et qu'est-ce que ça change concrètement ? R : Angular réutilise le DOM rendu par le serveur au lieu de le détruire et re-rendre — il fait correspondre l'arbre de composants au DOM existant et n'attache que les listeners. Concrètement : plus de flash visuel, meilleur LCP/CLS, et le replay d'événements (v18+) capture les clics survenus avant la fin de l'hydratation.
Q : Comment éviter le double fetch en SSR, et quelles requêtes le transfer cache par défaut NE couvre PAS ? R : TransferState sérialise les données du serveur dans un <script id="ng-state"> que le client relit au lieu de refaire l'appel. Le cache HTTP automatique (withHttpTransferCacheOptions, défaut depuis v17) couvre les GET/HEAD ; il exclut par défaut les POST, les requêtes avec Authorization, et tout ce que votre filter retire — pour ces cas, gérez TransferState à la main.
Q : Un rxResource/resource async qui pose les meta SEO dans ngOnInit — quel est le bug, et comment le corriger pour le SSR ? R : value() est undefined au premier tick, donc en SSR les meta partent vides dans le HTML — Google et les bots OG ne voient rien. Correctif : un resolver de route (le SSR attend les resolvers avant de rendre) ou un effect() réactif ; sur une page SEO-critique, on préfère le resolver pour garantir que le render serveur n'a lieu qu'avec les données prêtes.
Q : Streaming d'un LLM dans une page SSR — où le brancher et pourquoi pas sur le serveur ? R : On rend l'historique figé en SSR (TTFB rapide, indexable) mais on ouvre le stream token UNIQUEMENT après hydration via afterNextRender. Ouvrir le stream pendant le render garderait la requête SSR active jusqu'à la fin du LLM (plusieurs secondes) → TTFB catastrophique ou timeout. Le Stop doit propager un AbortController jusqu'au SDK serveur, sinon il est cosmétique et continue de coûter des tokens.
Q : Un service providedIn: 'root' peut-il fuiter l'état d'un utilisateur vers un autre en SSR ? Pourquoi, et comment l'éviter ? R : Oui — c'est le bug le plus dangereux du SSR car invisible en dev mono-utilisateur. Sur le serveur, un injecteur est créé par requête, donc un singleton root est en théorie isolé par requête… SAUF si vous stockez de l'état dans une variable module-level (en dehors du DI), un cache statique ou un BehaviorSubject partagé hissé hors de l'injecteur — là, deux requêtes concurrentes partagent la même mémoire et l'utilisateur B voit les données de A. Règle : tout état requête-spécifique doit venir de l'injecteur (jetons REQUEST/REQUEST_CONTEXT), jamais d'une variable globale. Se teste sous concurrence (50 requêtes, cookies distincts), jamais en un seul onglet.
Q : SSR « à la requête » vs prerender (SSG) vs edge — comment arbitres-tu, et que perd-on à tout mettre en SSR ? R : Décision par route. SSG (prerender) = HTML au build, servi par CDN : TTFB minimal, coût quasi nul, mais fraîcheur figée → marketing/docs/catalogue stable. SSR Node = HTML par requête : fraîcheur temps réel, mais render synchrone CPU-bound (un cœur bloqué par render, pas de streaming render comme React) → réservé aux pages dynamiques cacheables avec s-maxage court. Edge = démarrage ~5 ms et latence mondiale, mais runtime restreint (pas de fs/Node natif). Tout mettre en SSR = facture Node qui explose sous trafic, p99 sensible au render, et un cache mal réglé qui empoisonne ou tue le bénéfice. Le bon design : prerender ce qui est stable, SSR+cache edge ce qui est dynamique-mais-cacheable, CSR ce qui est privé/non-SEO.
🔗 Liens
- Documentation officielle Angular SSR —
angular.dev/guide/ssr - Guide d'hydratation —
angular.dev/guide/hydration - Incremental hydration —
angular.dev/guide/incremental-hydration - Angular DevTools Profiler — extension Chrome pour mesurer l'hydratation
- Article « Hydration in Angular » par Matthieu Riegler (équipe Angular)
- Repo de référence :
github.com/angular/angular/tree/main/aio/content/examples/ssr web.dev/articles/vitals— Core Web Vitals- WebPageTest —
webpagetest.org