Combinez SSR, SSG et CSR dans Angular 19+ avec RenderMode par route, hydration incrementale et deploiement edge : strategie complete pour SEO et perf.
SSR, SSG, CSR : décoder les acronymes
Avant de parler de rendu hybride, alignons le vocabulaire. Ces trois stratégies de rendu coexistent et chacune a son terrain de jeu idéal.
| Stratégie | Moment du rendu | Avantage clé | Idéal pour |
|---|---|---|---|
| CSR (Client-Side Rendering) | Dans le navigateur | Interactivité totale | Dashboards, apps authentifiées |
| SSR (Server-Side Rendering) | À chaque requête HTTP | SEO + contenu dynamique | E-commerce, news, blogs récents |
| SSG (Static Site Generation / Prerender) | Au moment du build | Performance maximale (CDN) | Pages marketing, docs, blog |
| ISR (Incremental Static Regeneration) | Build + revalidation périodique | Statique + données fraîches | Catalogues, pages produits |
Le compromis classique des frameworks SPA
Pendant des années, Angular a forcé le choix : tout-CSR (universel mais lent au premier rendu et mauvais pour le SEO) ou tout-SSR (rapide et SEO-friendly mais coûteux en serveur). Cette dichotomie est révolue depuis Angular 17+ et particulièrement depuis Angular 19 avec l'arrivée d'un véritable Hybrid Rendering par route.
Pourquoi le rendu hybride ?
Au sein d'une application réelle, toutes les pages n'ont pas les mêmes contraintes. Forcer une stratégie unique sur toute l'app conduit toujours à des compromis. Voici les besoins typiques et les choix optimaux.
Pages marketing / landing
- Objectif : Lighthouse 100, SEO maximal, conversion
- Contenu : statique, change rarement
- Trafic : élevé, anonyme
- Solution :
RenderMode.Prerender+ cache CDN long
Pages produits e-commerce
- Objectif : SEO + stock temps réel + recommandations personnalisées
- Contenu : dynamique (prix, stock), partiellement personnalisé
- Solution :
RenderMode.Server(SSR) + hydration incrémentale
Tableau de bord après login
- Objectif : interactivité maximale, pas de SEO
- Contenu : ultra-dynamique, authentifié
- Solution :
RenderMode.Client(CSR pur)
Mettre en place Hybrid Rendering
L'installation du rendu hybride dans Angular 19+ se fait via le schematic @angular/ssr officiel. Aucune configuration Webpack ou Express à écrire manuellement.
Activer SSR sur un projet existant
// Sur une app Angular 19+ existante (en CSR uniquement)
ng add @angular/ssr
// La commande modifie automatiquement :
// - angular.json (builder application + outputMode 'server')
// - app.config.ts → app.config.server.ts (config Node-side)
// - app.routes.server.ts CRÉÉ (déclaration des RenderMode)
// - server.ts CRÉÉ (entry-point Node.js)
// - package.json (scripts serve:ssr et prerender)
Structure de projet résultante
// Arborescence typique après ng add @angular/ssr
my-app/
├── src/
│ ├── app/
│ │ ├── app.config.ts // config commune
│ │ ├── app.config.server.ts // config serveur (ajoute provideServerRendering)
│ │ ├── app.routes.ts // routes Angular (lazy-loading, guards)
│ │ ├── app.routes.server.ts // ★ NOUVEAU : RenderMode par route
│ │ └── ...
│ └── main.ts
├── server.ts // entry-point Express/Node.js
├── angular.json // builder mis à jour
└── package.json
Vérifier l'installation
// Lancer le build hybride
ng build
// Le dossier dist/ contient désormais :
// - dist/my-app/browser/ → assets servis aux clients (CSR + hydration)
// - dist/my-app/server/ → bundle Node.js (SSR runtime)
// - dist/my-app/browser/*.html → pages prérendues (Prerender)
// Lancer en local
npm run serve:ssr:my-app
RenderMode et configuration par route
Le fichier app.routes.server.ts est le cœur du rendu hybride. C'est ici que vous décidez, route par route, comment chaque page sera rendue.
// src/app/app.routes.server.ts — déclaration RenderMode par route
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
// PRERENDER : généré au build, mis sur CDN, instantané
{ path: '', renderMode: RenderMode.Prerender },
{ path: 'about', renderMode: RenderMode.Prerender },
{ path: 'pricing', renderMode: RenderMode.Prerender },
// SERVER : SSR à chaque requête (SEO + données fraîches)
{ path: 'products', renderMode: RenderMode.Server },
{ path: 'product/:id', renderMode: RenderMode.Server },
{ path: 'blog/:slug', renderMode: RenderMode.Server },
// CLIENT : CSR pur, pas de rendu serveur (apps authentifiées)
{ path: 'dashboard', renderMode: RenderMode.Client },
{ path: 'settings', renderMode: RenderMode.Client },
{ path: 'admin/**', renderMode: RenderMode.Client },
// PRERENDER avec routes dynamiques générées au build
{
path: 'docs/:topic',
renderMode: RenderMode.Prerender,
// getPrerenderParams récupère la liste des paramètres à pré-rendre
async getPrerenderParams() {
// Exemple : 50 topics chargés depuis un JSON local
const topics = await import('./docs-list.json');
return topics.default.map(t => ({ topic: t.slug }));
},
},
// Catch-all : SSR par défaut pour les routes non listées
{ path: '**', renderMode: RenderMode.Server },
];
RenderMode.Prerender
Les routes en Prerender sont générées au moment du build. Angular CLI les exécute dans un environnement headless, sérialise le HTML résultant, et l'écrit dans dist/<app>/browser/. Servies depuis un CDN, ces pages sont quasi-instantanées (TTFB < 50ms).
RenderMode.Server
Les routes en Server sont rendues à chaque requête par le serveur Node.js. Vous accédez aux cookies, headers, et données fraîches. Idéal pour le SEO sur du contenu qui change (prix, stock, news).
RenderMode.Client
Le serveur sert uniquement un shell HTML vide ; le contenu est rendu côté client après chargement du bundle JS. Aucun gain SEO mais aucune charge serveur — parfait pour les zones authentifiées où le SEO n'a pas d'intérêt.
Prerender (SEO + Lighthouse 100), les pages blog en Server (contenu frais), et tout l'espace authentifié en Client (zéro charge serveur après login).
Hydration incrémentale avec @defer
L'hydration classique télécharge l'intégralité du bundle JS dès que la page apparaît, puis « réveille » tous les composants. L'hydration incrémentale (Angular 19+) diffère ce réveil zone par zone, déclenché par le viewport, l'interaction ou l'idle browser.
Activer l'hydration incrémentale
// app.config.ts — activer hydration + features avancées
import { ApplicationConfig } from '@angular/core';
import { provideClientHydration, withIncrementalHydration } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
provideClientHydration(
// withIncrementalHydration() active la nouvelle stratégie
withIncrementalHydration(),
),
// ... autres providers
],
};
Marquer une zone "hydratable plus tard"
<!-- product-page.component.html — bloc commentaires hydraté au scroll -->
<h1>{{ product().name }}</h1>
<p class="price">{{ product().price | currency }}</p>
<button (click)="addToCart()">Ajouter au panier</button>
<!-- Cette zone est SSR-rendue dans le HTML initial,
mais son JS ne se charge QUE quand l'utilisateur scrolle dessus -->
@defer (hydrate on viewport) {
<app-reviews [productId]="product().id"></app-reviews>
} @placeholder {
<div class="reviews-skeleton">Chargement des avis...</div>
}
<!-- Hydration au clic sur le bouton (interaction trigger) -->
@defer (hydrate on interaction) {
<app-related-products></app-related-products>
}
<!-- Hydration quand le browser est idle -->
@defer (hydrate on idle) {
<app-newsletter-signup></app-newsletter-signup>
}
Gains mesurés
| Métrique Lighthouse | SSR classique | SSR + Hydration incrémentale |
|---|---|---|
| FCP (First Contentful Paint) | 0,9 s | 0,8 s |
| LCP (Largest Contentful Paint) | 1,8 s | 1,6 s |
| TBT (Total Blocking Time) | 340 ms | 80 ms (4× moins) |
| TTI (Time to Interactive) | 2,4 s | 1,1 s (2× plus rapide) |
| JS téléchargé initialement | 340 KB gzippé | 120 KB gzippé |
Data fetching avec resource() et SSR
Angular 19+ introduit l'API resource() qui s'intègre parfaitement avec le SSR : les requêtes initiées côté serveur sont sérialisées et réutilisées côté client sans refaire le fetch. Plus de double-fetch entre SSR et hydration.
// product.component.ts — data fetching SSR-aware avec resource()
import { Component, computed, inject, input } from '@angular/core';
import { resource } from '@angular/core';
import { ProductApi } from './product.api';
@Component({
selector: 'app-product',
standalone: true,
template: `
@if (product.isLoading()) {
<p>Chargement...</p>
} @else if (product.error()) {
<p class="error">Erreur : {{ product.error()?.message }}</p>
} @else if (product.value(); as p) {
<h1>{{ p.name }}</h1>
<p class="price">{{ p.price | currency }}</p>
}
`,
})
export class ProductComponent {
id = input.required<string>();
private api = inject(ProductApi);
// resource() est conscient du SSR : exécuté côté serveur,
// sérialisé dans le HTML, et hydraté côté client sans nouveau fetch
product = resource({
request: () => ({ id: this.id() }),
loader: async ({ request, abortSignal }) => {
return this.api.findById(request.id, abortSignal);
},
});
}
HTTP TransferState : éviter le double-fetch
// app.config.ts — TransferState activé par défaut avec withHttpTransferCache()
import { provideHttpClient, withFetch, withHttpTransferCache } from '@angular/common/http';
export const appConfig = {
providers: [
provideHttpClient(
withFetch(), // utilise fetch() (compatible edge)
withHttpTransferCache({
// Met en cache automatiquement les réponses GET pendant l'hydration
includePostRequests: false,
includeRequestsWithAuthHeaders: false,
}),
),
// ... autres providers
],
};
Déploiement Node, edge et CDN
L'architecture du déploiement dépend de quelle proportion de routes utilise chaque mode. Voici les configurations recommandées par scénario.
Scénario 1 : majorité Prerender + un peu de SSR
// Architecture recommandée
// 1. Build :
// - ng build → génère dist/<app>/browser/*.html (Prerender)
// - ng build → génère dist/<app>/server/ (bundle Node pour les routes SSR)
//
// 2. Déploiement :
// - CDN (Cloudflare, CloudFront) : sert dist/browser/ — pages statiques
// - Node.js server (Render, Railway, Fly.io) : sert les routes SSR uniquement
//
// 3. Routing CDN :
// - /, /about, /pricing → CDN (Prerender)
// - /products/*, /blog/* → Node SSR
// - /dashboard, /admin → CDN sert le shell + CSR
// vercel.json — exemple de routing
{
"rewrites": [
{ "source": "/products/(.*)", "destination": "/api/ssr" },
{ "source": "/blog/(.*)", "destination": "/api/ssr" },
{ "source": "/dashboard(.*)", "destination": "/index.html" }
]
}
Scénario 2 : déploiement edge (Cloudflare Workers, Vercel Edge)
// Angular 20+ supporte l'exécution edge native — runtime ultra-léger
// server.ts adapté pour Cloudflare Workers
import { createRequestHandler } from '@angular/ssr';
const handler = createRequestHandler({
// bundleManifest généré automatiquement par ng build
});
export default {
async fetch(request: Request, env: any, ctx: ExecutionContext) {
return handler(request, { env, ctx });
},
};
Cache et invalidation
// server.ts — exemple Express avec cache HTTP par route
import express from 'express';
import { createNodeRequestHandler } from '@angular/ssr/node';
const app = express();
const angularHandler = createNodeRequestHandler();
// Cache des prerenders : 1h CDN + 1 jour browser
app.use((req, res, next) => {
if (req.path === '/' || req.path === '/about') {
res.set('Cache-Control', 'public, max-age=86400, s-maxage=3600');
}
// SSR routes : pas de cache navigateur, cache CDN court (1 min)
if (req.path.startsWith('/products')) {
res.set('Cache-Control', 'no-store, s-maxage=60, stale-while-revalidate=300');
}
next();
});
app.use(angularHandler);
app.listen(4000);
- Builder Angular 19+ avec
outputMode: 'server' app.routes.server.tsrevue : pas de route oubliéeprovideClientHydration(withIncrementalHydration())activéwithHttpTransferCache()activé pour éviter double-fetch- CDN configuré pour servir
dist/<app>/browser/statique - Headers
Cache-Controldistincts Prerender / SSR / Client - Healthcheck endpoint sur le serveur SSR
- Monitoring TTFB par route via votre APM
Pièges et performance
Piège 1 : utiliser window côté serveur
// ❌ Crash côté serveur : "window is not defined"
@Component({ /* ... */ })
export class HeroComponent implements OnInit {
ngOnInit() {
// window n'existe pas en Node.js → crash SSR
const width = window.innerWidth;
this.isMobile = width < 768;
}
}
// ✅ Utiliser isPlatformBrowser pour gating
import { Component, inject, PLATFORM_ID, OnInit } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
@Component({ /* ... */ })
export class HeroComponent implements OnInit {
private platformId = inject(PLATFORM_ID);
isMobile = false;
ngOnInit() {
if (isPlatformBrowser(this.platformId)) {
// Code spécifique navigateur uniquement
this.isMobile = window.innerWidth < 768;
}
}
}
Piège 2 : appeler localStorage sans gating
// ❌ Crash en SSR : "localStorage is not defined"
const theme = localStorage.getItem('theme');
// ✅ Service abstrait avec fallback in-memory côté serveur
@Injectable({ providedIn: 'root' })
export class StorageService {
private memoryStore = new Map<string, string>();
private platformId = inject(PLATFORM_ID);
get(key: string): string | null {
if (isPlatformBrowser(this.platformId)) {
return localStorage.getItem(key);
}
return this.memoryStore.get(key) ?? null;
}
}
Piège 3 : Prerender lent au build
// Pour 1000+ routes prerendre, optimiser le build
// angular.json — augmenter la parallélisation
{
"prerender": {
"options": {
"concurrency": 8, // tâches parallèles (CPUs disponibles)
"discoverRoutes": false // désactiver crawl si routes statiques
}
}
}
Piège 4 : SSR qui timeout sur une API externe
// Toujours imposer un timeout aux requêtes côté serveur
import { HttpClient, HttpContext, HttpContextToken } from '@angular/common/http';
import { timeout, catchError } from 'rxjs/operators';
const data = this.http
.get<Product>('/api/product/' + id)
.pipe(
timeout(3000), // 3 secondes max
catchError(() => of({ name: '', price: 0, error: true })),
);
Conclusion et stratégie de migration
L'Hybrid Rendering n'est plus un futur lointain : c'est le standard 2026 pour toute application Angular sérieuse. La combinaison Prerender + SSR + CSR + hydration incrémentale offre les meilleurs Core Web Vitals du marché, des coûts d'hébergement minimaux, et une expérience utilisateur exemplaire.
Stratégie de migration pragmatique :
- Mettre à jour Angular 19+ et installer
@angular/ssr - Mode safe : tout en Server pour commencer, puis migrer route par route
- Identifier les pages marketing et les passer en
Prerender - Mettre dashboard et admin en
Client(zéro charge serveur) - Activer
withIncrementalHydration()dansapp.config.ts - Ajouter
@defer (hydrate on viewport)sur les zones lourdes - Mesurer Lighthouse avant/après pour valider les gains