Implémentez le Server-Side Rendering Angular avec Angular Universal et maîtrisez l'hydration pour améliorer significativement les performances et le SEO.
SSR vs CSR vs SSG — quand choisir ?
Angular supporte trois stratégies de rendu. Choisir la bonne est crucial pour les performances et le SEO.
| Stratégie | Rendu | FCP | SEO | Cas d'usage |
|---|---|---|---|---|
| CSR (Client-Side) | Dans le navigateur | Lent (JS d'abord) | Faible | Apps internes, dashboards privés |
| SSR (Server-Side) | Serveur Node.js | Rapide (HTML immédiat) | Excellent | E-commerce, blogs, landing pages |
| SSG (Static) | Build time | Très rapide (CDN) | Excellent | Docs, marketing, contenu statique |
| Hybrid | SSR + SSG par route | Optimal | Excellent | Apps complexes Angular 17+ |
@angular/ssr remplace Angular Universal. Il introduit le rendu hybride — certaines routes en SSR, d'autres en SSG dans le même projet.
Installation et structure générée
# Ajouter SSR à un projet existant
ng add @angular/ssr
# Ou à la création du projet
ng new my-app --ssr
# Build SSR
ng build # production build
ng serve # dev server avec SSR
Angular CLI génère ces fichiers supplémentaires :
// server.ts — Serveur Express généré par Angular CLI
import { APP_BASE_HREF } from '@angular/common';
import { CommonEngine } from '@angular/ssr';
import express from 'express';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
import AppServerModule from './src/main.server';
export function app(): express.Express {
const server = express();
const serverDistFolder = dirname(fileURLToPath(import.meta.url));
const browserDistFolder = resolve(serverDistFolder, '../browser');
const indexHtml = join(serverDistFolder, 'index.server.html');
const commonEngine = new CommonEngine();
server.set('view engine', 'html');
server.set('views', browserDistFolder);
// Servir les assets statiques
server.get('*.*', express.static(browserDistFolder, { maxAge: '1y' }));
// SSR pour toutes les routes Angular
server.get('*', (req, res, next) => {
const { protocol, originalUrl, baseUrl, headers } = req;
commonEngine
.render({
bootstrap: AppServerModule,
documentFilePath: indexHtml,
url: `${protocol}://${headers.host}${originalUrl}`,
publicPath: browserDistFolder,
providers: [{ provide: APP_BASE_HREF, useValue: baseUrl }],
})
.then(html => res.send(html))
.catch(err => next(err));
});
return server;
}
function run(): void {
const port = process.env['PORT'] || 4000;
const server = app();
server.listen(port, () => {
console.log(`Server listening on http://localhost:${port}`);
});
}
run();
Configuration complète app.config
// app.config.ts — Configuration client avec hydration
import { ApplicationConfig } from '@angular/core';
import { provideRouter, withComponentInputBinding, withViewTransitions } from '@angular/router';
import { provideHttpClient, withFetch } from '@angular/common/http';
import { provideClientHydration, withHttpTransferCache } from '@angular/platform-browser';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes,
withComponentInputBinding(), // Inputs depuis les params de route
withViewTransitions() // Transitions animées entre routes
),
provideHttpClient(
withFetch() // Utiliser fetch() au lieu de XMLHttpRequest (meilleur SSR)
),
provideClientHydration(
withHttpTransferCache() // Éviter les doubles requêtes HTTP
),
]
};
// app.config.server.ts — Configuration serveur uniquement
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering } from '@angular/platform-server';
import { appConfig } from './app.config';
const serverConfig: ApplicationConfig = {
providers: [
provideServerRendering()
]
};
// Fusion des configs client + serveur
export const config = mergeApplicationConfig(appConfig, serverConfig);
Rendu hybride SSR + SSG par route (Angular 17+)
// app.routes.ts — Définir le mode de rendu par route
import { Routes, RenderMode, ServerRoute } from '@angular/router';
export const routes: Routes = [
{ path: '', loadComponent: () => import('./home/home.component') },
{ path: 'blog/:slug', loadComponent: () => import('./blog/blog.component') },
{ path: 'dashboard', loadComponent: () => import('./dashboard/dashboard.component') },
];
// server.routes.ts — Mode de rendu par route
export const serverRoutes: ServerRoute[] = [
{ path: '', renderMode: RenderMode.Prerender }, // SSG
{ path: 'blog/:slug', renderMode: RenderMode.Prerender, // SSG avec params
getPrerenderParams: async () => [{ slug: 'intro' }, { slug: 'guide' }] },
{ path: 'dashboard', renderMode: RenderMode.Client }, // CSR (données privées)
{ path: '**', renderMode: RenderMode.Server }, // SSR par défaut
];
Hydration et hydration incrémentale
Sans hydration, Angular détruisait et recréait entièrement le DOM côté client, causant un flash de contenu (FCP haut, CLS mauvais). Depuis Angular 16, provideClientHydration() réutilise le DOM rendu par le serveur.
// Vérifier l'hydration dans Angular DevTools
// Chrome → F12 → onglet "Angular" → chaque composant montre son statut d'hydration
// Skipping hydration pour un composant avec DOM complexe
import { Component } from '@angular/core';
import { NgSkipHydration } from '@angular/platform-browser';
@Component({
selector: 'app-canvas-chart',
hostDirectives: [NgSkipHydration], // ← Angular ne tente pas d'hydrater
template: `<canvas #chartCanvas></canvas>`
})
export class CanvasChartComponent {
// Canvas sera recréé entièrement côté client — OK pour les composants canvas/WebGL
}
Hydration incrémentale (Angular 19+)
// L'hydration incrémentale diffère l'hydration des sections non visibles
// Seules les parties visibles sont hydratées immédiatement
// app.config.ts
import { provideClientHydration, withIncrementalHydration } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
provideClientHydration(
withIncrementalHydration() // Active l'hydration incrémentale
)
]
};
// Dans le template : contrôler quand hydrater chaque section
@Component({
template: `
<!-- Hydraté immédiatement (au-dessus de la ligne de flottaison) -->
<app-hero />
<!-- Hydraté seulement quand visible dans le viewport -->
@defer (hydrate on viewport) {
<app-comments-section />
}
<!-- Hydraté seulement au clic -->
@defer (hydrate on interaction) {
<app-chat-widget />
}
`
})
TransferState et HTTP cache
Sans TransferState, chaque requête HTTP faite côté serveur est répétée côté client — double requête, double latence. withHttpTransferCache() sérialise les réponses dans le HTML et les rejoue côté client sans nouveau round-trip.
// app.config.ts — Activer le cache de transfert HTTP
provideClientHydration(withHttpTransferCache())
// Comportement automatique :
// 1. Serveur fait GET /api/articles → réponse sérialisée dans le HTML
// 2. Client reçoit le HTML avec les données déjà incluses
// 3. Premier appel Angular HttpClient → retourne depuis le cache instantanément
// 4. Aucune requête réseau répétée
// Utilisation avec HttpClient — aucun changement requis
@Injectable({ providedIn: 'root' })
export class ArticlesService {
private http = inject(HttpClient);
getArticles() {
// Cette requête est automatiquement cachée par withHttpTransferCache()
return this.http.get<Article[]>('/api/articles');
}
}
TransferState manuel pour données complexes
// Pour transférer des données non-HTTP (ex: config serveur)
import { TransferState, makeStateKey } from '@angular/core';
const CONFIG_KEY = makeStateKey<AppConfig>('app-config');
// Côté serveur : stocker les données
@Injectable({ providedIn: 'root' })
export class ConfigService {
private transferState = inject(TransferState);
loadConfig(): AppConfig {
const config = fetchConfigFromServer(); // appel synchrone côté serveur
// Sérialiser pour le client
this.transferState.set(CONFIG_KEY, config);
return config;
}
}
// Côté client : récupérer les données transférées
@Injectable({ providedIn: 'root' })
export class ConfigService {
private transferState = inject(TransferState);
loadConfig(): AppConfig {
if (this.transferState.hasKey(CONFIG_KEY)) {
// Lire depuis le transfert — aucun appel réseau
const config = this.transferState.get(CONFIG_KEY, null)!;
this.transferState.remove(CONFIG_KEY); // Nettoyer après usage
return config;
}
return this.fetchFromApi(); // Fallback si pas de transfert
}
}
Pièges courants et solutions
Le serveur Node.js n'a pas accès aux APIs du navigateur. Voici les 5 erreurs les plus fréquentes :
// === PIÈGE 1 : window/document/localStorage ===
// ERREUR
ngOnInit() {
const width = window.innerWidth; // ReferenceError côté serveur!
localStorage.setItem('key', 'val'); // ReferenceError!
}
// SOLUTION 1 : isPlatformBrowser
import { isPlatformBrowser } from '@angular/common';
import { PLATFORM_ID, inject } from '@angular/core';
readonly #platformId = inject(PLATFORM_ID);
ngOnInit() {
if (isPlatformBrowser(this.#platformId)) {
const width = window.innerWidth; // OK, seulement côté client
}
}
// SOLUTION 2 : afterNextRender (Angular 16+) — toujours côté client
import { afterNextRender, ElementRef } from '@angular/core';
export class ChartComponent {
private el = inject(ElementRef);
constructor() {
afterNextRender(() => {
// Garanti côté client après le premier rendu
this.initChart(this.el.nativeElement);
});
}
}
// === PIÈGE 2 : Timers et setInterval ===
// ERREUR : setInterval n'est jamais clearé côté serveur → memory leak
ngOnInit() {
setInterval(() => this.refreshData(), 5000); // Leak serveur!
}
// SOLUTION : Utiliser takeUntilDestroyed + isPlatformBrowser
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
constructor() {
if (isPlatformBrowser(inject(PLATFORM_ID))) {
interval(5000).pipe(
takeUntilDestroyed() // Nettoyage automatique à la destruction
).subscribe(() => this.refreshData());
}
}
// === PIÈGE 3 : Librairies tierces qui accèdent au DOM ===
// PROBLÈME : Chart.js, Leaflet, etc. accèdent à window immédiatement à l'import
// SOLUTION : Chargement dynamique côté client uniquement
import { afterNextRender } from '@angular/core';
export class MapComponent {
constructor() {
afterNextRender(async () => {
const L = await import('leaflet'); // Import dynamique côté client
this.initMap(L);
});
}
}
- Ne jamais accéder à
window,document,localStorage,navigatordirectement - Toujours vérifier avec
isPlatformBrowser()ou utiliserafterNextRender() - Charger les librairies DOM (Chart.js, Leaflet) via import dynamique dans
afterNextRender - Toujours
clearInterval/clearTimeoutcôté serveur ou utilisertakeUntilDestroyed() - Activer
withFetch()pour queHttpClientutilise l'API fetch native (meilleure perf SSR)
Deferred views avec @defer
La directive @defer (Angular 17+) charge et rend les sections de template de façon paresseuse, améliorant le LCP et réduisant le bundle initial. En SSR, Angular contrôle ce qui est pré-rendu et ce qui est différé.
@Component({
template: `
<!-- Section au-dessus du fold : rendue immédiatement -->
<app-hero-banner />
<app-featured-articles />
<!-- Commentaires : chargés quand visibles -->
@defer (on viewport; prefetch on idle) {
<app-comments [articleId]="articleId" />
} @placeholder {
<div class="comments-skeleton">Chargement des commentaires...</div>
} @loading (minimum 300ms) {
<app-spinner />
} @error {
<p>Impossible de charger les commentaires.</p>
}
<!-- Widget chat : chargé après 5 secondes d'inactivité -->
@defer (on idle; after 5000ms) {
<app-chat-widget />
}
`
})
| Trigger | Quand charger | Cas d'usage |
|---|---|---|
on viewport | Quand visible dans le viewport | Sections sous la ligne de flottaison |
on idle | Après inactivité navigateur | Widgets non critiques |
on interaction | Au premier clic/keydown sur l'élément | Composants lourds activés par l'utilisateur |
on hover | Au survol de l'élément | Tooltips, previews |
on timer(3000ms) | Après N millisecondes | Bandeaux, notifications différées |
when condition | Quand une condition est vraie | Chargement conditionnel |
Build et déploiement
# Build production SSR
ng build
# Structure du build
dist/
├── browser/ # Assets statiques (JS, CSS, images)
│ └── index.html # Shell HTML pour hydration
└── server/
└── server.mjs # Serveur Express compilé
# Lancer le serveur SSR en production
node dist/my-app/server/server.mjs
# Variables d'environnement
PORT=4000 node dist/my-app/server/server.mjs
// Dockerfile pour déploiement containerisé
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS production
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/package*.json ./
RUN npm ci --omit=dev
EXPOSE 4000
CMD ["node", "dist/my-app/server/server.mjs"]
Conclusion
Le SSR Angular avec @angular/ssr apporte un gain SEO et performance immédiat. Les points clés à retenir :
ng add @angular/ssr— intégration en une commandeprovideClientHydration(withHttpTransferCache())— zéro double requête HTTP- Rendu hybride : SSG pour les pages statiques, SSR pour le dynamique, CSR pour les dashboards privés
afterNextRender()remplace les vérificationsisPlatformBrowserpour le code DOM- Hydration incrémentale (
withIncrementalHydration()) pour réduire le TTI sur pages longues - Import dynamique des librairies tierces DOM dans
afterNextRender