Front-end angularforall.com

- Angular Hybrid Rendering : SSR SSG et CSR

Angular Hybrid-Rendering Ssr Ssg Csr Render-Mode Prerender Hydration-Incrementale Angular-19 Angular-20 Transfer-State Edge-Computing Core-Web-Vitals Seo
Angular Hybrid Rendering : SSR SSG et CSR

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.

L'idée centrale : chaque route de votre app peut choisir indépendamment sa stratégie de rendu. Une page marketing en SSG (instantanée), une fiche produit en SSR (SEO + stock à jour), un dashboard en CSR (interactif). Tout cela dans la même application Angular.

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)
Économie réelle : sur un site combinant ces 3 zones, passer de tout-SSR à Hybrid Rendering réduit typiquement la facture serveur de 60 à 80%. Les pages marketing (souvent 70% du trafic) deviennent gratuites à servir depuis un CDN.

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.

Cas mixte recommandé : pour une SaaS B2B, mettez la home et les pages produit en 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
    ],
};
Sans TransferState : votre page produit fait 2 appels API — un côté serveur, un côté client après hydration. Avec TransferState, 1 seul appel côté serveur, la réponse est sérialisée dans le HTML et lue par le client.

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);
Checklist déploiement production :
  • Builder Angular 19+ avec outputMode: 'server'
  • app.routes.server.ts revue : pas de route oubliée
  • provideClientHydration(withIncrementalHydration()) activé
  • withHttpTransferCache() activé pour éviter double-fetch
  • CDN configuré pour servir dist/<app>/browser/ statique
  • Headers Cache-Control distincts 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 :

  1. Mettre à jour Angular 19+ et installer @angular/ssr
  2. Mode safe : tout en Server pour commencer, puis migrer route par route
  3. Identifier les pages marketing et les passer en Prerender
  4. Mettre dashboard et admin en Client (zéro charge serveur)
  5. Activer withIncrementalHydration() dans app.config.ts
  6. Ajouter @defer (hydrate on viewport) sur les zones lourdes
  7. Mesurer Lighthouse avant/après pour valider les gains
Prochaine évolution : Angular 22+ devrait introduire des modes intermédiaires comme l'ISR (Incremental Static Regeneration) — Prerender avec revalidation périodique configurable. Le rendu hybride continue d'évoluer vers plus de granularité et moins de configuration.

Partager