Front-end angularforall.com

- Angular SSR : rendu côté serveur et hydration

Angular Ssr Hydration Performance Angular 17
Angular SSR : rendu côté serveur et hydration

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 17+ : Le package @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 />
        }
    `
})
Impact performance : L'hydration incrémentale réduit le TTI (Time to Interactive) de 30-60% sur les pages longues. Seul le JavaScript nécessaire aux interactions visibles est exécuté immédiatement.

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, navigator directement
  • Toujours vérifier avec isPlatformBrowser() ou utiliser afterNextRender()
  • Charger les librairies DOM (Chart.js, Leaflet) via import dynamique dans afterNextRender
  • Toujours clearInterval/clearTimeout côté serveur ou utiliser takeUntilDestroyed()
  • Activer withFetch() pour que HttpClient utilise 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 viewportQuand visible dans le viewportSections sous la ligne de flottaison
on idleAprès inactivité navigateurWidgets non critiques
on interactionAu premier clic/keydown sur l'élémentComposants lourds activés par l'utilisateur
on hoverAu survol de l'élémentTooltips, previews
on timer(3000ms)Après N millisecondesBandeaux, notifications différées
when conditionQuand une condition est vraieChargement 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 commande
  • provideClientHydration(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érifications isPlatformBrowser pour le code DOM
  • Hydration incrémentale (withIncrementalHydration()) pour réduire le TTI sur pages longues
  • Import dynamique des librairies tierces DOM dans afterNextRender
Résultat mesurable : Une application Angular e-commerce typique passe de LCP 4.5s (CSR) à LCP 1.2s (SSR) — différence directement mesurable dans Google Search Console et Core Web Vitals.

Partager