Angular 17 SSR : hydration mismatch invisible, −34 % trafic

Migration Angular 17 vers SSR : quand provideServerRendering livre une page vide à Googlebot

Mercredi 14h20. L'équipe front d'un comparateur d'assurances français — 8 200 pages indexées, 410K clics organiques mensuels — déploie en production la migration Angular 17 avec SSR activé. Le build passe. Le smoke test Cypress passe. Le rendu navigateur est impeccable. Trois semaines plus tard, le trafic organique a chuté de 34 %. Search Console affiche 3 100 pages "Découverte – actuellement non indexée". Personne dans l'équipe n'a reçu la moindre alerte.

Jeudi matin, T+22 jours : l'alerte vient du business

L'équipe SEO ouvre son rapport GA4 hebdomadaire. Le canal organique affiche 271K sessions au lieu des 410K habituelles. Premier réflexe : vérifier si le May 2026 Core Update a frappé. Le déploiement du core update est en cours, mais les dates ne collent pas. La baisse a commencé le 15 mai — soit le lendemain du déploiement SSR.

Le lead SEO ouvre Search Console. Onglet "Pages". Filtre "Non indexée". 3 147 pages classées "Découverte – actuellement non indexée". Avant la migration : 89 pages dans ce statut. Multiplication par 35.

Il lance un crawl Screaming Frog en mode "JavaScript rendering" avec un Chromium headless intégré. Les 8 200 URLs remontent avec un <title>, un <meta description>, du contenu dans le <body>. Tout semble normal.

Puis il relance le crawl en mode "texte brut" — sans exécuter le JavaScript. Le résultat est un massacre silencieux.

Sur 6 840 pages de fiches produit, le HTML brut retourné par le serveur contient :

<!DOCTYPE html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <title>Comparateur Assurances</title>
  <base href="/">
  <link rel="stylesheet" href="styles.abc123.css">
</head>
<body>
  <app-root></app-root>
  <script src="main.def456.js" type="module"></script>
</body>
</html>

Un squelette vide. Le <app-root> ne contient rien. Le SSR ne s'exécute pas. Le serveur envoie la SPA telle quelle — exactement comme avant la migration. Le navigateur exécute le JavaScript, hydrate l'app, et tout semble fonctionner. Mais Googlebot, qui privilégie de plus en plus le HTML initial pour l'indexation rapide, voit une page sans titre dynamique, sans contenu, sans balise meta spécifique.

Le lead SEO envoie un message dans le channel Slack #incident-seo à 10h12 : "Le SSR ne tourne pas en prod. On sert du CSR pur à Google depuis 22 jours."

Le CTO répond : "Impossible. Le build Angular SSR est dans la pipeline. On a testé en staging."

La staging. Bien sûr. Le problème, c'est qu'elle a été testée uniquement dans le navigateur.

Le bug : provideServerRendering absent du bootstrap serveur

L'équipe utilise Angular 17.1 avec le nouveau système d'application standalone (bootstrapApplication). La migration vers SSR suit la documentation officielle Angular : ajout de @angular/ssr, configuration de server.ts, mise à jour de angular.json.

Le fichier app.config.ts — la configuration client — est correct :

// 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(),
  ],
};

provideClientHydration() est bien présent. C'est la moitié du contrat d'hydration Angular 17. Côté client, l'app sait qu'elle doit récupérer un DOM pré-rendu par le serveur et l'hydrater au lieu de le reconstruire.

Mais l'autre moitié du contrat vit dans app.config.server.ts. Et c'est là que tout s'effondre.

Le fichier tel que déployé en production :

// src/app/app.config.server.ts
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering } from '@angular/platform-server';
import { appConfig } from './app.config';

const serverConfig: ApplicationConfig = {
  providers: [],
};

export const config = mergeApplicationConfig(appConfig, serverConfig);

Le provideServerRendering() est importé. Mais il n'est pas appelé dans le tableau providers. L'import est là — le provider est mort dans le code. Le tree-shaking de esbuild l'élimine sans avertissement.

Sans provideServerRendering(), Angular ne sait pas qu'il tourne en contexte serveur. Le bootstrapApplication côté serveur initialise l'app comme si elle était dans un navigateur — mais sans DOM. Le résultat : renderApplication() dans server.ts retourne le template HTML initial avec <app-root></app-root> vide. Aucune erreur. Aucun warning en console. Le code HTTP reste 200. Le Content-Type reste text/html.

Pourquoi personne n'a rien vu

Trois raisons.

1. Le navigateur masque tout. En chargeant la page, le navigateur exécute main.js, Angular bootstrape côté client, détecte un <app-root> vide, et reconstruit le DOM complet. Visuellement, aucune différence. Le provideClientHydration() est conçu pour détecter un mismatch entre le HTML serveur et le rendu client — mais quand le HTML serveur est vide, Angular ne crashe pas. Il passe en mode "full client render" silencieusement.

2. Les tests E2E ne testent pas le HTML brut. Cypress et Playwright exécutent JavaScript par défaut. Le smoke test post-deploy vérifie que les éléments sont visibles dans le DOM final. Ils ne vérifient jamais le HTML retourné avant exécution JS.

3. Lighthouse en mode navigation locale utilise Chromium. L'audit Lighthouse en local (ou via Chrome DevTools) exécute le JS. Le score Performance et SEO reste excellent. Le problème n'apparaît que dans un audit du HTML brut.

La preuve via curl et Lighthouse CI

Le diagnostic final vient d'une commande simple :

curl -s -A "Googlebot" https://comparateur.example.fr/assurance-auto/paris \
  | grep -c '<app-root>'

Résultat : 1 occurrence. Et un grep sur le contenu attendu — le titre H1 "Assurance auto à Paris" — retourne 0.

Pour confirmer l'ampleur, l'équipe lance Lighthouse CI en mode --chrome-flags="--disable-javascript" sur un échantillon de 50 URLs :

lhci autorun --collect.url=https://comparateur.example.fr/assurance-auto/paris \
  --collect.settings.chromeFlags="--disable-javascript" \
  --assert.assertions.categories:seo=error

Score SEO moyen sans JS : 31/100. Avec JS : 97/100. L'écart est la preuve.

Le lead SEO vérifie dans Search Console l'outil "Inspection d'URL" sur trois fiches produit. Le HTML rendu affiché par Google est quasi vide — conforme au curl. Le rendering JavaScript de Google a partiellement fonctionné sur certaines pages, mais avec des délais de crawl étendus. Le budget de crawl a été gaspillé : le rapport de statistiques de crawl montre un temps moyen par page passé de 280ms à 4.2s — le temps que WRS (Web Rendering Service) prenne le relais.

Le comportement est cohérent avec la documentation officielle Angular sur l'hydration : sans provideServerRendering(), le serveur ne produit pas de DOM sérialisé. Le client ne trouve pas de ngh attributes dans le HTML, et bascule en rendu destructif complet.

Le piège de l'import sans appel

Ce pattern est un classique des migrations Angular standalone. Dans l'ancien système NgModule, ServerModule était déclaré dans les imports — impossible de l'oublier sans casser la compilation. Avec le système standalone d'Angular 17, chaque provider est un appel de fonction explicite. Un import JavaScript inutilisé ne génère aucune erreur TypeScript. ESLint avec la règle no-unused-vars aurait pu alerter — mais le fichier app.config.server.ts n'était pas couvert par le lint CI de l'équipe.

Le fix : 4 lignes et 11 jours de récupération

Le correctif est chirurgical. Dans app.config.server.ts :

// src/app/app.config.server.ts — CORRIGÉ
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering } from '@angular/platform-server';
import { appConfig } from './app.config';

const serverConfig: ApplicationConfig = {
  providers: [
    provideServerRendering(),
  ],
};

export const config = mergeApplicationConfig(appConfig, serverConfig);

Une ligne déplacée. provideServerRendering() passe de l'import mort au tableau providers.

Avant de redéployer, l'équipe ajoute un test de non-régression dans la CI :

// e2e/ssr-smoke.spec.ts
import { test, expect } from '@playwright/test';

test('SSR returns rendered HTML without JS', async ({ request }) => {
  const response = await request.get('/assurance-auto/paris', {
    headers: { 'User-Agent': 'Googlebot' },
  });
  const html = await response.text();
  
  expect(html).toContain('<h1');
  expect(html).toContain('Assurance auto');
  expect(html).not.toMatch(/<app-root><\/app-root>/);
  expect(html).toContain('ngh=');
});

Ce test fait deux choses : il vérifie que le HTML brut contient du contenu réel (pas un <app-root> vide), et il vérifie la présence de l'attribut ngh — le marqueur d'hydration Angular qui prouve que le serveur a bien sérialisé le DOM.

Le déploiement

Le patch est mergé et déployé le jeudi à 11h40. Un curl post-deploy confirme que le HTML brut contient désormais le contenu rendu. L'équipe force un recrawl via Search Console sur les 50 URLs les plus stratégiques.

Le CDN (Cloudflare) cache les réponses HTML. L'équipe purge le cache global :

curl -X POST "https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache" \
  -H "Authorization: Bearer {token}" \
  -H "Content-Type: application/json" \
  --data '{"purge_everything":true}'

La récupération

Les premiers signes apparaissent au bout de 72 heures. Le nombre de pages "Découverte – actuellement non indexée" passe de 3 147 à 2 340. Le temps moyen de crawl redescend de 4.2s à 310ms — le serveur renvoyant du HTML complet, WRS n'a plus besoin d'exécuter le JS.

Au jour 7, 1 200 pages ont retrouvé leur indexation. Au jour 11, le trafic organique remonte à 389K sessions hebdomadaires — soit 95 % du niveau pré-migration. Les 5 % restants mettront encore deux semaines à se stabiliser, le temps que Google réévalue les signaux de qualité sur les pages réindexées.

Le bilan : 22 jours de régression silencieuse. 139K clics organiques perdus (estimation basée sur le delta quotidien moyen). Aucune alerte automatisée déclenchée.

Les mesures post-incident

L'équipe met en place trois garde-fous :

  1. Test SSR dans la CI — le test Playwright ci-dessus bloque le déploiement si le HTML brut est vide.
  2. Alerte Search Console — un script Apps Script interroge l'API Search Console quotidiennement et alerte Slack si le nombre de pages "non indexées" augmente de plus de 10 % en 48 heures.
  3. Lint strict sur les fichiers server config — la règle ESLint no-unused-imports (via eslint-plugin-unused-imports) est activée sur tout le répertoire src/.

L'équipe documente aussi un pattern récurrent observé dans d'autres migrations de frameworks : le même type de divergence SSR/CSR invisible avait frappé des équipes sur Next.js App Router, Nuxt 3, et Vue 3. Le symptôme diffère, la cause racine est identique : ce que le développeur voit dans son navigateur n'est pas ce que le crawler reçoit.

Ce qu'on en retient

Le SSR Angular 17 ne crashe pas quand il est mal configuré. Il se dégrade en silence. Le navigateur compense. Les tests E2E compensent. Lighthouse en mode standard compense. Tout conspire à masquer le problème jusqu'à ce que le trafic organique s'effondre.

La seule défense fiable : tester le HTML brut, sans JavaScript, sur chaque déploiement. Un curl dans la CI. Un test Playwright sans navigation. Un diff automatisé entre le rendu serveur et le rendu client.

Un monitoring continu type Seogard détecte ce type de divergence SSR/CSR en quelques minutes — pas en 22 jours. Mais même sans outil externe, le test de quatre lignes ajouté dans la CI aurait suffi. La vraie erreur n'était pas le provider manquant. C'était l'absence de vérification de ce que le serveur envoie réellement au monde extérieur.

Articles connexes

Migration25 mai 2026

React 18 Suspense SSR : next/head cassé par le streaming

Migration React 17→18 : le streaming SSR réordonne les chunks et supprime les meta tags. Récit d'incident, diagnostic complet et patch Next.js.

Migration25 mai 2026

Astro v6 : Content Collections cassent les title en silence

Après upgrade Astro v5→v6, 312 articles perdent leur balise title. Récit du bug, diagnostic frontmatter, fix et récupération SEO en 19 jours.

Migration24 mai 2026

Migration Vue 3 : 47 pages produit sans meta titles pendant 21 jours

Récit d'une migration Vue 2 vers Vue 3 où useHead mal porté a supprimé les meta titles de 47 pages produit. Diagnostic, code du bug, et fix complet.