Développement Web

Angular SSR avec hydration incrémentale en production

13 دقائق للقراءة

Servir du HTML statique depuis le serveur puis réveiller les composants une fois côté client : c’est tout l’enjeu du rendu serveur Angular. La promesse séduit, mais la mise en œuvre déçoit souvent — les premiers paragraphes apparaissent vite, puis le navigateur peine à devenir interactif. Depuis Angular 18, le replay d’événements et l’hydratation incrémentale corrigent ce trou d’expérience. Ce tutoriel installe SSR sur un projet existant, configure l’hydratation complète, ajoute le replay, puis bascule en hydratation incrémentale pilotée par des déclencheurs hydrate on.

Prérequis

  • Node.js 20 LTS minimum (l’@angular/ssr récent en a besoin pour les API Express modernes).
  • Angular CLI 18 ou plus récent (Angular 20+ recommandé pour l’hydratation incrémentale stable).
  • Un projet Angular existant ou créé via ng new mon-app --ssr.
  • Une compréhension de base des composants standalone et des signals.
  • Environ 50 minutes pour tout suivre, hébergement de test inclus.

Étape 1 — Ajouter SSR à un projet existant

Si votre projet a été créé sans SSR, la commande dédiée vous évite toute manipulation manuelle. Le schematic @angular/ssr ajoute les dépendances Express nécessaires, génère le point d’entrée serveur, ajuste angular.json avec une cible server et inscrit le bootstrap serveur dans main.server.ts. C’est l’unique commande à connaître pour démarrer.

ng add @angular/ssr

À l’issue de la commande, ouvrez le projet et observez les nouveaux fichiers : src/main.server.ts (bootstrap côté Node), src/server.ts (serveur Express qui sert l’application), et src/app/app.config.server.ts (providers spécifiques au serveur). Pour tester en mode SSR réel, lancez ng build puis npm run serve:ssr:<mon-projet> — le script est généré automatiquement par le schematic et démarre le serveur Express compilé. Ouvrez http://localhost:4000 et regardez le code source : vous voyez désormais du HTML complet, pas un squelette vide. Notez que ng serve seul reste un dev server client-only — il accélère l’itération mais ne rejoue pas le SSR à chaque rechargement. C’est la première brique : sans hydratation, l’application est cependant statique.

Étape 2 — Activer l’hydratation complète

L’hydratation est le mécanisme qui reconnecte le DOM rendu côté serveur aux composants Angular côté client, sans rebâtir l’arbre. Sans elle, le navigateur jette le HTML serveur et reconstruit la page, créant le fameux flash blanc et faisant perdre tout l’intérêt du SSR. L’activation passe par un unique provider à ajouter à votre appConfig.

// src/app/app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideClientHydration } from '@angular/platform-browser';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideClientHydration(),
  ],
};

Une fois ce provider en place, rechargez la page et inspectez le DOM dans DevTools. Vous remarquez de nouveaux attributs ngh="0" sur les nœuds — Angular les utilise comme empreinte pour retrouver chaque composant lors de l’hydratation. Le HTML reste celui du serveur, mais les boutons réagissent désormais. Pour confirmer, cliquez sur n’importe quel élément interactif aussi vite que possible après l’apparition du markup : il doit répondre.

Étape 3 — Activer le replay d’événements

Même avec l’hydratation complète, il existe un intervalle entre le moment où le HTML s’affiche et celui où les composants deviennent interactifs. Un utilisateur impatient peut cliquer pendant cette fenêtre, et l’événement est perdu. Le replay d’événements, disponible depuis Angular 18 et stable depuis Angular 19, résout ce problème en mémorisant les interactions et en les rejouant une fois l’hydratation terminée.

import { provideClientHydration, withEventReplay } from '@angular/platform-browser';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideClientHydration(withEventReplay()),
  ],
};

Le mécanisme est subtil mais transparent. Angular inscrit des listeners au niveau du document dès le HTML servi (via un petit script inline), capture chaque clic, soumission et événement clavier, puis rejoue ces événements sur les composants une fois hydratés. Le test pratique : ralentissez l’exécution JavaScript dans DevTools (Performance → CPU 4x slowdown), rechargez, et tentez de cliquer immédiatement sur un bouton. Sans withEventReplay, le clic est perdu. Avec, il est honoré dès que possible.

Étape 4 — Comprendre les modes de rendu

Angular 19 a introduit la notion de render mode par route : chaque URL peut être servie en pré-rendu statique, en SSR pur, ou en mode client uniquement. Cette finesse évite de payer le coût du SSR sur des routes qui n’en ont pas besoin (un panneau d’administration interne par exemple) ou de pré-générer du HTML pour des routes éditoriales.

// src/app/app.routes.server.ts
import { RenderMode, ServerRoute } from '@angular/ssr';

export const serverRoutes: ServerRoute[] = [
  { path: '',         renderMode: RenderMode.Prerender },
  { path: 'blog/:id', renderMode: RenderMode.Server },
  { path: 'compte',   renderMode: RenderMode.Client },
];

Trois modes, trois cas d’usage. Prerender génère un HTML statique au moment du build : c’est idéal pour les pages publiques avec un contenu stable (accueil, articles). Server rend à chaque requête : utile quand le contenu dépend du contexte utilisateur (session, cookies, géolocalisation). Client désactive le SSR pour cette route : pertinent pour les zones authentifiées où le SEO n’a aucun intérêt. Cette config se branche dans app.config.server.ts via provideServerRendering(withRoutes(serverRoutes)).

Étape 5 — Activer l’hydratation incrémentale

L’hydratation complète présente une limite : tout l’arbre de composants est hydraté d’un bloc dès le chargement. Pour une page riche avec dix composants interactifs, cela représente potentiellement plusieurs centaines de kilo-octets de JavaScript à parser et exécuter avant la première interaction. L’hydratation incrémentale, stabilisée en Angular 21, change la donne : chaque section reste « dormante » jusqu’à ce qu’un déclencheur précis la réveille.

import {
  provideClientHydration,
  withEventReplay,
  withIncrementalHydration,
} from '@angular/platform-browser';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideClientHydration(
      withEventReplay(),
      withIncrementalHydration(),
    ),
  ],
};

Activer le provider seul ne suffit pas : il faut aussi marquer dans le template les zones à hydrater de manière différée. Cela se fait via la nouvelle syntaxe @defer (hydrate on …), qui combine déferrement et hydratation en un seul bloc. Lancez le build, ouvrez DevTools et observez le réseau : seules les zones immédiatement utiles déclenchent un téléchargement de chunk JavaScript, les autres restent passives.

Étape 6 — Marquer les zones avec hydrate on

Six déclencheurs sont disponibles pour hydrate on : idle, viewport, interaction, hover, immediate et timer(duration). Le choix dépend de la criticité du composant et de l’usage attendu. Un widget de chat support qui dort en bas de page se prête parfaitement à hydrate on viewport ; un carrousel d’images en haut de page mérite hydrate on idle pour ne pas bloquer le thread principal au démarrage.

<article>
  <h1>{{ post().title }}</h1>
  <p>{{ post().intro }}</p>

  @defer (hydrate on viewport) {
    <app-commentaires [postId]="post().id" />
  } @placeholder {
    <div class="skeleton-comments"></div>
  }

  @defer (hydrate on idle) {
    <app-recommandations />
  }

  @defer (hydrate on interaction) {
    <app-partage />
  } @placeholder {
    <button>Partager</button>
  }
</article>

Le serveur produit du HTML complet : commentaires, recommandations et bouton de partage sont tous rendus statiquement. Côté client, rien n’est hydraté tant que le déclencheur ne s’active pas. Le composant de partage attend un clic pour devenir réactif, ce qui économise des dizaines de kilo-octets de JS si l’utilisateur ne touche jamais le bouton. Mesurez l’impact avec Lighthouse : sur un article avec dix composants, le Total Blocking Time peut chuter de 400 ms à moins de 100 ms.

Étape 7 — Gérer les états non hydratables (hydrate never)

Certaines sections n’ont pas vocation à être interactives : un encart promotionnel statique, une signature en fin d’article, un avis de copyright. Les marquer hydrate never indique à Angular de ne jamais télécharger leur JavaScript côté client. Le HTML reste, le SEO en profite, et le bundle se réduit d’autant.

@defer (hydrate never) {
  <app-footer-legal />
}

Attention au piège : un composant marqué hydrate never ne sera jamais rendu réactif, même si son template contient des bindings. Vérifiez avant le déploiement que le composant n’attend pas d’entrée dynamique du parent. C’est typiquement le cas pour les composants entièrement statiques : un menu de navigation pré-rendu avec liens en dur, un panneau d’aide affichant un texte fixe.

Étape 8 — Déployer derrière Node.js

Le SSR Angular produit un serveur Express prêt à l’emploi. Pour passer en production, plusieurs options sont valides : un VPS classique avec systemd, un conteneur Docker derrière un reverse proxy Nginx, ou un service serverless type Cloud Run ou Vercel. Voici la configuration minimale d’un déploiement avec Docker, qui marche aussi bien sur VPS qu’en orchestration.

# Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist /app/dist
COPY --from=builder /app/node_modules /app/node_modules
COPY --from=builder /app/package.json /app/package.json
EXPOSE 4000
CMD ["node", "dist/mon-app/server/server.mjs"]

L’image résultante pèse environ 180 Mo sur Alpine, ce qui reste acceptable pour un VPS d’entrée de gamme. Le port d’écoute par défaut est 4000 ; placez Nginx devant pour gérer SSL et compression Brotli. Pour tester localement, lancez docker build -t mon-app . && docker run -p 4000:4000 mon-app, puis curl -I http://localhost:4000 doit renvoyer une réponse 200 avec le HTML hydraté.

Étape 9 — Mesurer l’impact sur les Web Vitals

Une fois SSR et hydratation incrémentale en place, mesurez. Sans chiffres avant/après, vous ne saurez pas si le travail a porté ses fruits. Le triplet à surveiller : Largest Contentful Paint (LCP), Interaction to Next Paint (INP) et Cumulative Layout Shift (CLS). Lighthouse les calcule, mais l’outil le plus représentatif reste le rapport CrUX (Chrome User Experience Report) qui agrège les données utilisateurs réels.

# Audit Lighthouse mobile avec throttling
npx lighthouse https://votre-site.example \
  --preset=mobile \
  --throttling-method=devtools \
  --only-categories=performance \
  --output=json --output-path=./perf.json

# Extraire les chiffres clés
cat perf.json | jq '.audits["largest-contentful-paint"].displayValue, .audits.interactive.displayValue'

Un site éditorial bien configuré atteint typiquement un LCP de 1,8 s sur 4G et un INP inférieur à 200 ms. Si vos chiffres dépassent largement ces valeurs, examinez deux pistes : le serveur met-il trop de temps à répondre (Time To First Byte) ou y a-t-il trop de JavaScript critique non déféré. La commande ng build --stats-json couplée à source-map-explorer révèle ce qui survit dans le bundle initial.

Étape 10 — Vérifier le SEO et les méta-données

Le bénéfice principal du SSR est SEO. Encore faut-il que les moteurs de recherche reçoivent un HTML correct, avec titre, méta-description, balises Open Graph et données structurées. Le service Meta d’Angular permet d’injecter ces informations dynamiquement, et le rendu serveur les inclut dans le HTML initial.

import { Component, inject } from '@angular/core';
import { Meta, Title } from '@angular/platform-browser';

@Component({
  selector: 'app-article',
  template: `<h1>{{ article().title }}</h1>`,
})
export class ArticleComponent {
  private title = inject(Title);
  private meta = inject(Meta);

  ngOnInit() {
    const a = this.article();
    this.title.setTitle(a.title);
    this.meta.updateTag({ name: 'description', content: a.excerpt });
    this.meta.updateTag({ property: 'og:title', content: a.title });
  }
}

Pour confirmer que ces balises arrivent bien dans le HTML servi, utilisez curl avec le user-agent d’un bot : curl -A "Googlebot" https://votre-site.example/article/42 | grep -E "og:|description". Vous devez voir vos balises <meta> dans le markup brut. Si elles n’apparaissent que dans le DOM rendu (visible via DevTools mais absent du source view), c’est que le rendu serveur ne déclenche pas vos hooks — vérifiez que les méta sont définies avant la fin de la phase serveur, idéalement dans un resolver ou un effect synchrone.

Erreurs fréquentes

Erreur Cause Solution
Flash blanc au chargement Hydratation absente, DOM serveur jeté Ajouter provideClientHydration()
NG0500 hydration mismatch HTML serveur différent du HTML client (date, random, fuseau) Utiliser afterNextRender() pour les valeurs non déterministes
Cookies non lus côté serveur API navigateur invoquée pendant le rendu serveur Vérifier isPlatformBrowser(platformId) avant accès
Bundle initial trop gros Composants importés statiquement Convertir en @defer (hydrate on …)
Méta absentes du source view Setter exécuté trop tard côté serveur Déplacer dans un resolver de route ou un effect synchrone
Erreur ERR_HTTP_HEADERS_SENT Code Express qui répond plusieurs fois Vérifier server.ts pour les middlewares dupliqués

Adaptation aux connexions limitées

Le SSR avec hydratation incrémentale prend tout son sens sur des réseaux mobiles peu performants. Le HTML s’affiche en quelques centaines de millisecondes même sur 3G, là où une SPA classique imposerait deux à trois secondes de bundle JavaScript à parser. Pour un blog accédé majoritairement depuis mobile, le SSR n’est pas un confort, c’est un facteur déterminant de conversion. Combinez compression Brotli côté serveur (gain de 20 à 30 % sur le HTML), images WebP ou AVIF avec loading="lazy", et hydratation incrémentale sur les zones non visibles : un article éditorial de 2000 mots peut atteindre un LCP sous les 2 secondes même sur connexion 3G simulée.

FAQ

SSR ou pré-rendu statique : que choisir ?
Si votre contenu change rarement (blog, documentation), préférez le pré-rendu avec RenderMode.Prerender : le HTML est généré au build, hébergé sur un CDN, et coûte une fraction d’un VPS Node. Si le contenu dépend de l’utilisateur ou change à chaque requête, gardez RenderMode.Server.

Faut-il toujours activer withEventReplay() ?
Oui, dans 99 % des cas. Le coût en bundle est minime (quelques kilo-octets) et le gain UX est substantiel sur des connexions lentes. La seule raison de s’en passer serait un bug d’incompatibilité avec une bibliothèque tierce, auquel cas vous l’apprendrez vite via vos tests E2E.

Quel hébergeur pour un SSR Angular en production ?
Tout hébergeur qui supporte Node.js 20+ convient : VPS Linux (Hetzner, OVH, Hostinger), conteneurs (Fly.io, Railway, Render), serverless (Cloud Run, Vercel). Pour un trafic modéré, un VPS à 4-6 €/mois suffit largement et garde un contrôle complet sur le serveur Express.

L’hydratation incrémentale fonctionne-t-elle sans @defer ?
Non. Le mécanisme repose entièrement sur les blocs @defer avec déclencheurs hydrate on …. Sans ces marqueurs dans le template, l’hydratation reste complète (tout en un bloc), même avec withIncrementalHydration() activé.

Comment déboguer une erreur NG0500 d’hydration mismatch ?
Cette erreur indique que le HTML rendu côté serveur diffère du HTML attendu côté client. Les causes courantes : un Date.now() dans un template, une valeur aléatoire, une lecture de window non protégée. Le message console pointe le nœud DOM concerné — examinez le composant correspondant et utilisez afterNextRender() pour les valeurs non déterministes. En dernier recours, ajoutez l’attribut ngSkipHydration sur le composant pour désactiver l’hydratation de cet arbre, le temps de corriger le code.

Pour aller plus loin

Références

Service ITSkillsCenter

Site ou application web sur mesure

Conception Pro + Nom de domaine 1 an + Hébergement 1 an + Formation + Support 6 mois. Accès et code livrés. À partir de 350 000 FCFA.

Demander un devis
Publicité