[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"$ffW3k3RSka95w6sKTyPJR53q3pHQqSC7vi8uUJbdkdNc":3,"$fqjyuGME9S1beXYtUksY61CC_uXwkVR2d1bcDbd01iSo":24},{"_id":4,"slug":5,"__v":6,"author":7,"body":8,"canonical":9,"category":10,"createdAt":11,"date":12,"description":13,"htmlContent":14,"image":15,"imageAlt":15,"readingTime":16,"tags":17,"title":22,"updatedAt":23},"6a148e95aa6b273b0ce72f4a","migration-angular-17-vers-ssr-provideserverrendering-mal-configure-et-hydration-mismatch-invisible",0,"Equipe Seogard","# Migration Angular 17 vers SSR : quand provideServerRendering livre une page vide à Googlebot\n\nMercredi 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.\n\n## Jeudi matin, T+22 jours : l'alerte vient du business\n\nL'é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](/blog/google-may-2026-core-update-rolling-out-now) est en cours, mais les dates ne collent pas. La baisse a commencé le 15 mai — soit le lendemain du déploiement SSR.\n\nLe 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.\n\nIl lance un crawl Screaming Frog en mode \"JavaScript rendering\" avec un Chromium headless intégré. Les 8 200 URLs remontent avec un `\u003Ctitle>`, un `\u003Cmeta description>`, du contenu dans le `\u003Cbody>`. Tout semble normal.\n\nPuis il relance le crawl en mode \"texte brut\" — sans exécuter le JavaScript. Le résultat est un massacre silencieux.\n\nSur 6 840 pages de fiches produit, le HTML brut retourné par le serveur contient :\n\n```html\n\u003C!DOCTYPE html>\n\u003Chtml lang=\"fr\">\n\u003Chead>\n  \u003Cmeta charset=\"utf-8\">\n  \u003Ctitle>Comparateur Assurances\u003C/title>\n  \u003Cbase href=\"/\">\n  \u003Clink rel=\"stylesheet\" href=\"styles.abc123.css\">\n\u003C/head>\n\u003Cbody>\n  \u003Capp-root>\u003C/app-root>\n  \u003Cscript src=\"main.def456.js\" type=\"module\">\u003C/script>\n\u003C/body>\n\u003C/html>\n```\n\nUn squelette vide. Le `\u003Capp-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.\n\nLe 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.\"\n\nLe CTO répond : \"Impossible. Le build Angular SSR est dans la pipeline. On a testé en staging.\"\n\nLa staging. Bien sûr. Le problème, c'est qu'elle a été testée uniquement dans le navigateur.\n\n## Le bug : provideServerRendering absent du bootstrap serveur\n\nL'é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`.\n\nLe fichier `app.config.ts` — la configuration client — est correct :\n\n```typescript\n// src/app/app.config.ts\nimport { ApplicationConfig } from '@angular/core';\nimport { provideRouter } from '@angular/router';\nimport { provideClientHydration } from '@angular/platform-browser';\nimport { routes } from './app.routes';\n\nexport const appConfig: ApplicationConfig = {\n  providers: [\n    provideRouter(routes),\n    provideClientHydration(),\n  ],\n};\n```\n\n`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.\n\nMais l'autre moitié du contrat vit dans `app.config.server.ts`. Et c'est là que tout s'effondre.\n\nLe fichier tel que déployé en production :\n\n```typescript\n// src/app/app.config.server.ts\nimport { mergeApplicationConfig, ApplicationConfig } from '@angular/core';\nimport { provideServerRendering } from '@angular/platform-server';\nimport { appConfig } from './app.config';\n\nconst serverConfig: ApplicationConfig = {\n  providers: [],\n};\n\nexport const config = mergeApplicationConfig(appConfig, serverConfig);\n```\n\nLe `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.\n\nSans `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 `\u003Capp-root>\u003C/app-root>` vide. Aucune erreur. Aucun warning en console. Le code HTTP reste 200. Le Content-Type reste `text/html`.\n\n### Pourquoi personne n'a rien vu\n\nTrois raisons.\n\n**1. Le navigateur masque tout.** En chargeant la page, le navigateur exécute `main.js`, Angular bootstrape côté client, détecte un `\u003Capp-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.\n\n**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.\n\n**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.\n\n### La preuve via curl et Lighthouse CI\n\nLe diagnostic final vient d'une commande simple :\n\n```bash\ncurl -s -A \"Googlebot\" https://comparateur.example.fr/assurance-auto/paris \\\n  | grep -c '\u003Capp-root>'\n```\n\nRésultat : 1 occurrence. Et un `grep` sur le contenu attendu — le titre H1 \"Assurance auto à Paris\" — retourne 0.\n\nPour confirmer l'ampleur, l'équipe lance Lighthouse CI en mode `--chrome-flags=\"--disable-javascript\"` sur un échantillon de 50 URLs :\n\n```bash\nlhci autorun --collect.url=https://comparateur.example.fr/assurance-auto/paris \\\n  --collect.settings.chromeFlags=\"--disable-javascript\" \\\n  --assert.assertions.categories:seo=error\n```\n\nScore SEO moyen sans JS : 31/100. Avec JS : 97/100. L'écart est la preuve.\n\nLe 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.\n\nLe comportement est cohérent avec la [documentation officielle Angular sur l'hydration](https://angular.dev/guide/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.\n\n### Le piège de l'import sans appel\n\nCe 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.\n\n## Le fix : 4 lignes et 11 jours de récupération\n\nLe correctif est chirurgical. Dans `app.config.server.ts` :\n\n```typescript\n// src/app/app.config.server.ts — CORRIGÉ\nimport { mergeApplicationConfig, ApplicationConfig } from '@angular/core';\nimport { provideServerRendering } from '@angular/platform-server';\nimport { appConfig } from './app.config';\n\nconst serverConfig: ApplicationConfig = {\n  providers: [\n    provideServerRendering(),\n  ],\n};\n\nexport const config = mergeApplicationConfig(appConfig, serverConfig);\n```\n\nUne ligne déplacée. `provideServerRendering()` passe de l'import mort au tableau `providers`.\n\nAvant de redéployer, l'équipe ajoute un test de non-régression dans la CI :\n\n```typescript\n// e2e/ssr-smoke.spec.ts\nimport { test, expect } from '@playwright/test';\n\ntest('SSR returns rendered HTML without JS', async ({ request }) => {\n  const response = await request.get('/assurance-auto/paris', {\n    headers: { 'User-Agent': 'Googlebot' },\n  });\n  const html = await response.text();\n  \n  expect(html).toContain('\u003Ch1');\n  expect(html).toContain('Assurance auto');\n  expect(html).not.toMatch(/\u003Capp-root>\u003C\\/app-root>/);\n  expect(html).toContain('ngh=');\n});\n```\n\nCe test fait deux choses : il vérifie que le HTML brut contient du contenu réel (pas un `\u003Capp-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.\n\n### Le déploiement\n\nLe 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.\n\nLe CDN (Cloudflare) cache les réponses HTML. L'équipe purge le cache global :\n\n```bash\ncurl -X POST \"https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache\" \\\n  -H \"Authorization: Bearer {token}\" \\\n  -H \"Content-Type: application/json\" \\\n  --data '{\"purge_everything\":true}'\n```\n\n### La récupération\n\nLes 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.\n\nAu 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.\n\nLe 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.\n\n### Les mesures post-incident\n\nL'équipe met en place trois garde-fous :\n\n1. **Test SSR dans la CI** — le test Playwright ci-dessus bloque le déploiement si le HTML brut est vide.\n2. **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.\n3. **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/`.\n\nL'é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](/blog/migration-next-js-pages-router-vers-app-router-les-metadata-ignorees-sur-les-pages-client), [Nuxt 3](/blog/migration-nuxt-2-vers-nuxt-3-200-pages-en-fallback-layout-pendant-6-semaines), et [Vue 3](/blog/migration-vue-2-vers-vue-3-47-pages-produit-en-panne-seo-en-silence). 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.\n\n## Ce qu'on en retient\n\nLe 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.\n\nLa 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.\n\nUn 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.\n```","https://seogard.io/blog/migration-angular-17-vers-ssr-provideserverrendering-mal-configure-et-hydration-mismatch-invisible","Migration","2026-05-25T18:01:57.093Z","2026-05-25","Migration Angular 17 vers SSR : provideServerRendering mal configuré cause un hydration mismatch invisible. Récit, diagnostic Lighthouse, fix précis.","\u003Ch1>Migration Angular 17 vers SSR : quand provideServerRendering livre une page vide à Googlebot\u003C/h1>\n\u003Cp>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.\u003C/p>\n\u003Ch2>Jeudi matin, T+22 jours : l'alerte vient du business\u003C/h2>\n\u003Cp>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 \u003Ca href=\"/blog/google-may-2026-core-update-rolling-out-now\">déploiement du core update\u003C/a> est en cours, mais les dates ne collent pas. La baisse a commencé le 15 mai — soit le lendemain du déploiement SSR.\u003C/p>\n\u003Cp>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.\u003C/p>\n\u003Cp>Il lance un crawl Screaming Frog en mode \"JavaScript rendering\" avec un Chromium headless intégré. Les 8 200 URLs remontent avec un \u003Ccode>&#x3C;title>\u003C/code>, un \u003Ccode>&#x3C;meta description>\u003C/code>, du contenu dans le \u003Ccode>&#x3C;body>\u003C/code>. Tout semble normal.\u003C/p>\n\u003Cp>Puis il relance le crawl en mode \"texte brut\" — sans exécuter le JavaScript. Le résultat est un massacre silencieux.\u003C/p>\n\u003Cp>Sur 6 840 pages de fiches produit, le HTML brut retourné par le serveur contient :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;!\u003C/span>\u003Cspan style=\"color:#85E89D\">DOCTYPE\u003C/span>\u003Cspan style=\"color:#B392F0\"> html\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">html\u003C/span>\u003Cspan style=\"color:#B392F0\"> lang\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"fr\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">head\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  &#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">meta\u003C/span>\u003Cspan style=\"color:#B392F0\"> charset\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"utf-8\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  &#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">title\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>Comparateur Assurances&#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">title\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  &#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">base\u003C/span>\u003Cspan style=\"color:#B392F0\"> href\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"/\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  &#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">link\u003C/span>\u003Cspan style=\"color:#B392F0\"> rel\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"stylesheet\"\u003C/span>\u003Cspan style=\"color:#B392F0\"> href\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"styles.abc123.css\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">head\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">body\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  &#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">app-root\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>&#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">app-root\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  &#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">script\u003C/span>\u003Cspan style=\"color:#B392F0\"> src\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"main.def456.js\"\u003C/span>\u003Cspan style=\"color:#B392F0\"> type\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"module\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>&#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">script\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">body\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">html\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Un squelette vide. Le \u003Ccode>&#x3C;app-root>\u003C/code> 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.\u003C/p>\n\u003Cp>Le lead SEO envoie un message dans le channel Slack \u003Ccode>#incident-seo\u003C/code> à 10h12 : \"Le SSR ne tourne pas en prod. On sert du CSR pur à Google depuis 22 jours.\"\u003C/p>\n\u003Cp>Le CTO répond : \"Impossible. Le build Angular SSR est dans la pipeline. On a testé en staging.\"\u003C/p>\n\u003Cp>La staging. Bien sûr. Le problème, c'est qu'elle a été testée uniquement dans le navigateur.\u003C/p>\n\u003Ch2>Le bug : provideServerRendering absent du bootstrap serveur\u003C/h2>\n\u003Cp>L'équipe utilise Angular 17.1 avec le nouveau système d'application standalone (\u003Ccode>bootstrapApplication\u003C/code>). La migration vers SSR suit la documentation officielle Angular : ajout de \u003Ccode>@angular/ssr\u003C/code>, configuration de \u003Ccode>server.ts\u003C/code>, mise à jour de \u003Ccode>angular.json\u003C/code>.\u003C/p>\n\u003Cp>Le fichier \u003Ccode>app.config.ts\u003C/code> — la configuration client — est correct :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">// src/app/app.config.ts\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { ApplicationConfig } \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> '@angular/core'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { provideRouter } \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> '@angular/router'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { provideClientHydration } \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> '@angular/platform-browser'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { routes } \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> './app.routes'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">export\u003C/span>\u003Cspan style=\"color:#F97583\"> const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> appConfig\u003C/span>\u003Cspan style=\"color:#F97583\">:\u003C/span>\u003Cspan style=\"color:#B392F0\"> ApplicationConfig\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  providers: [\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">    provideRouter\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(routes),\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">    provideClientHydration\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(),\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  ],\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">};\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>\u003Ccode>provideClientHydration()\u003C/code> 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.\u003C/p>\n\u003Cp>Mais l'autre moitié du contrat vit dans \u003Ccode>app.config.server.ts\u003C/code>. Et c'est là que tout s'effondre.\u003C/p>\n\u003Cp>Le fichier tel que déployé en production :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">// src/app/app.config.server.ts\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { mergeApplicationConfig, ApplicationConfig } \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> '@angular/core'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { provideServerRendering } \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> '@angular/platform-server'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { appConfig } \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> './app.config'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> serverConfig\u003C/span>\u003Cspan style=\"color:#F97583\">:\u003C/span>\u003Cspan style=\"color:#B392F0\"> ApplicationConfig\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  providers: [],\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">};\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">export\u003C/span>\u003Cspan style=\"color:#F97583\"> const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> config\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#B392F0\"> mergeApplicationConfig\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(appConfig, serverConfig);\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Le \u003Ccode>provideServerRendering()\u003C/code> est importé. Mais il n'est pas appelé dans le tableau \u003Ccode>providers\u003C/code>. L'import est là — le provider est mort dans le code. Le tree-shaking de esbuild l'élimine sans avertissement.\u003C/p>\n\u003Cp>Sans \u003Ccode>provideServerRendering()\u003C/code>, Angular ne sait pas qu'il tourne en contexte serveur. Le \u003Ccode>bootstrapApplication\u003C/code> côté serveur initialise l'app comme si elle était dans un navigateur — mais sans DOM. Le résultat : \u003Ccode>renderApplication()\u003C/code> dans \u003Ccode>server.ts\u003C/code> retourne le template HTML initial avec \u003Ccode>&#x3C;app-root>&#x3C;/app-root>\u003C/code> vide. Aucune erreur. Aucun warning en console. Le code HTTP reste 200. Le Content-Type reste \u003Ccode>text/html\u003C/code>.\u003C/p>\n\u003Ch3>Pourquoi personne n'a rien vu\u003C/h3>\n\u003Cp>Trois raisons.\u003C/p>\n\u003Cp>\u003Cstrong>1. Le navigateur masque tout.\u003C/strong> En chargeant la page, le navigateur exécute \u003Ccode>main.js\u003C/code>, Angular bootstrape côté client, détecte un \u003Ccode>&#x3C;app-root>\u003C/code> vide, et reconstruit le DOM complet. Visuellement, aucune différence. Le \u003Ccode>provideClientHydration()\u003C/code> 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.\u003C/p>\n\u003Cp>\u003Cstrong>2. Les tests E2E ne testent pas le HTML brut.\u003C/strong> 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.\u003C/p>\n\u003Cp>\u003Cstrong>3. Lighthouse en mode navigation locale utilise Chromium.\u003C/strong> 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.\u003C/p>\n\u003Ch3>La preuve via curl et Lighthouse CI\u003C/h3>\n\u003Cp>Le diagnostic final vient d'une commande simple :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">curl\u003C/span>\u003Cspan style=\"color:#79B8FF\"> -s\u003C/span>\u003Cspan style=\"color:#79B8FF\"> -A\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> \"Googlebot\"\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> https://comparateur.example.fr/assurance-auto/paris\u003C/span>\u003Cspan style=\"color:#79B8FF\"> \\\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  |\u003C/span>\u003Cspan style=\"color:#B392F0\"> grep\u003C/span>\u003Cspan style=\"color:#79B8FF\"> -c\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> '&#x3C;app-root>'\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Résultat : 1 occurrence. Et un \u003Ccode>grep\u003C/code> sur le contenu attendu — le titre H1 \"Assurance auto à Paris\" — retourne 0.\u003C/p>\n\u003Cp>Pour confirmer l'ampleur, l'équipe lance Lighthouse CI en mode \u003Ccode>--chrome-flags=\"--disable-javascript\"\u003C/code> sur un échantillon de 50 URLs :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">lhci\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> autorun\u003C/span>\u003Cspan style=\"color:#79B8FF\"> --collect.url=https://comparateur.example.fr/assurance-auto/paris\u003C/span>\u003Cspan style=\"color:#79B8FF\"> \\\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">  --collect.settings.chromeFlags=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"--disable-javascript\"\u003C/span>\u003Cspan style=\"color:#79B8FF\"> \\\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">  --assert.assertions.categories:seo=error\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Score SEO moyen sans JS : 31/100. Avec JS : 97/100. L'écart est la preuve.\u003C/p>\n\u003Cp>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 \u003Ccode>curl\u003C/code>. 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.\u003C/p>\n\u003Cp>Le comportement est cohérent avec la \u003Ca href=\"https://angular.dev/guide/hydration\">documentation officielle Angular sur l'hydration\u003C/a> : sans \u003Ccode>provideServerRendering()\u003C/code>, le serveur ne produit pas de DOM sérialisé. Le client ne trouve pas de \u003Ccode>ngh\u003C/code> attributes dans le HTML, et bascule en rendu destructif complet.\u003C/p>\n\u003Ch3>Le piège de l'import sans appel\u003C/h3>\n\u003Cp>Ce pattern est un classique des migrations Angular standalone. Dans l'ancien système NgModule, \u003Ccode>ServerModule\u003C/code> était déclaré dans les \u003Ccode>imports\u003C/code> — 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 \u003Ccode>no-unused-vars\u003C/code> aurait pu alerter — mais le fichier \u003Ccode>app.config.server.ts\u003C/code> n'était pas couvert par le lint CI de l'équipe.\u003C/p>\n\u003Ch2>Le fix : 4 lignes et 11 jours de récupération\u003C/h2>\n\u003Cp>Le correctif est chirurgical. Dans \u003Ccode>app.config.server.ts\u003C/code> :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">// src/app/app.config.server.ts — CORRIGÉ\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { mergeApplicationConfig, ApplicationConfig } \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> '@angular/core'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { provideServerRendering } \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> '@angular/platform-server'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { appConfig } \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> './app.config'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> serverConfig\u003C/span>\u003Cspan style=\"color:#F97583\">:\u003C/span>\u003Cspan style=\"color:#B392F0\"> ApplicationConfig\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  providers: [\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">    provideServerRendering\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(),\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  ],\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">};\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">export\u003C/span>\u003Cspan style=\"color:#F97583\"> const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> config\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#B392F0\"> mergeApplicationConfig\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(appConfig, serverConfig);\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Une ligne déplacée. \u003Ccode>provideServerRendering()\u003C/code> passe de l'import mort au tableau \u003Ccode>providers\u003C/code>.\u003C/p>\n\u003Cp>Avant de redéployer, l'équipe ajoute un test de non-régression dans la CI :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">// e2e/ssr-smoke.spec.ts\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { test, expect } \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> '@playwright/test'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">test\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">'SSR returns rendered HTML without JS'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#F97583\">async\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> ({ \u003C/span>\u003Cspan style=\"color:#FFAB70\">request\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> }) \u003C/span>\u003Cspan style=\"color:#F97583\">=>\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> response\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#F97583\"> await\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> request.\u003C/span>\u003Cspan style=\"color:#B392F0\">get\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">'/assurance-auto/paris'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    headers: { \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'User-Agent'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'Googlebot'\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> },\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  });\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> html\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#F97583\"> await\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> response.\u003C/span>\u003Cspan style=\"color:#B392F0\">text\u003C/span>\u003Cspan style=\"color:#E1E4E8\">();\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  \u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">  expect\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(html).\u003C/span>\u003Cspan style=\"color:#B392F0\">toContain\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">'&#x3C;h1'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">  expect\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(html).\u003C/span>\u003Cspan style=\"color:#B392F0\">toContain\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">'Assurance auto'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">  expect\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(html).not.\u003C/span>\u003Cspan style=\"color:#B392F0\">toMatch\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">/\u003C/span>\u003Cspan style=\"color:#DBEDFF\">&#x3C;app-root>&#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D;font-weight:bold\">\\/\u003C/span>\u003Cspan style=\"color:#DBEDFF\">app-root>\u003C/span>\u003Cspan style=\"color:#9ECBFF\">/\u003C/span>\u003Cspan style=\"color:#E1E4E8\">);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">  expect\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(html).\u003C/span>\u003Cspan style=\"color:#B392F0\">toContain\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">'ngh='\u003C/span>\u003Cspan style=\"color:#E1E4E8\">);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">});\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Ce test fait deux choses : il vérifie que le HTML brut contient du contenu réel (pas un \u003Ccode>&#x3C;app-root>\u003C/code> vide), et il vérifie la présence de l'attribut \u003Ccode>ngh\u003C/code> — le marqueur d'hydration Angular qui prouve que le serveur a bien sérialisé le DOM.\u003C/p>\n\u003Ch3>Le déploiement\u003C/h3>\n\u003Cp>Le patch est mergé et déployé le jeudi à 11h40. Un \u003Ccode>curl\u003C/code> 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.\u003C/p>\n\u003Cp>Le CDN (Cloudflare) cache les réponses HTML. L'équipe purge le cache global :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">curl\u003C/span>\u003Cspan style=\"color:#79B8FF\"> -X\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> POST\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> \"https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache\"\u003C/span>\u003Cspan style=\"color:#79B8FF\"> \\\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">  -H\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> \"Authorization: Bearer {token}\"\u003C/span>\u003Cspan style=\"color:#79B8FF\"> \\\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">  -H\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> \"Content-Type: application/json\"\u003C/span>\u003Cspan style=\"color:#79B8FF\"> \\\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">  --data\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> '{\"purge_everything\":true}'\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Ch3>La récupération\u003C/h3>\n\u003Cp>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.\u003C/p>\n\u003Cp>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.\u003C/p>\n\u003Cp>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.\u003C/p>\n\u003Ch3>Les mesures post-incident\u003C/h3>\n\u003Cp>L'équipe met en place trois garde-fous :\u003C/p>\n\u003Col>\n\u003Cli>\u003Cstrong>Test SSR dans la CI\u003C/strong> — le test Playwright ci-dessus bloque le déploiement si le HTML brut est vide.\u003C/li>\n\u003Cli>\u003Cstrong>Alerte Search Console\u003C/strong> — 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.\u003C/li>\n\u003Cli>\u003Cstrong>Lint strict sur les fichiers server config\u003C/strong> — la règle ESLint \u003Ccode>no-unused-imports\u003C/code> (via \u003Ccode>eslint-plugin-unused-imports\u003C/code>) est activée sur tout le répertoire \u003Ccode>src/\u003C/code>.\u003C/li>\n\u003C/ol>\n\u003Cp>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 \u003Ca href=\"/blog/migration-next-js-pages-router-vers-app-router-les-metadata-ignorees-sur-les-pages-client\">Next.js App Router\u003C/a>, \u003Ca href=\"/blog/migration-nuxt-2-vers-nuxt-3-200-pages-en-fallback-layout-pendant-6-semaines\">Nuxt 3\u003C/a>, et \u003Ca href=\"/blog/migration-vue-2-vers-vue-3-47-pages-produit-en-panne-seo-en-silence\">Vue 3\u003C/a>. 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.\u003C/p>\n\u003Ch2>Ce qu'on en retient\u003C/h2>\n\u003Cp>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.\u003C/p>\n\u003Cp>La seule défense fiable : tester le HTML brut, sans JavaScript, sur chaque déploiement. Un \u003Ccode>curl\u003C/code> dans la CI. Un test Playwright sans navigation. Un diff automatisé entre le rendu serveur et le rendu client.\u003C/p>\n\u003Cp>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.\u003C/p>\n\u003Cpre>\u003Ccode>\u003C/code>\u003C/pre>",null,12,[18,19,20,21],"angular 17","ssr","hydration","provideServerRendering","Angular 17 SSR : hydration mismatch invisible, −34 % trafic","Mon May 25 2026 18:01:57 GMT+0000 (Coordinated Universal Time)",[25,38,53],{"_id":26,"slug":27,"__v":6,"author":7,"canonical":28,"category":10,"createdAt":29,"date":12,"description":30,"image":15,"imageAlt":15,"readingTime":16,"tags":31,"title":36,"updatedAt":37},"6a141e10aa6b273b0c8a4eb7","react-17-vers-react-18-suspense-ssr-fait-crasher-next-head-en-streaming","https://seogard.io/blog/react-17-vers-react-18-suspense-ssr-fait-crasher-next-head-en-streaming","2026-05-25T10:01:52.337Z","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.",[32,33,19,34,35],"react 18","suspense","next/head","streaming","React 18 Suspense SSR : next/head cassé par le streaming","Mon May 25 2026 10:01:52 GMT+0000 (Coordinated Universal Time)",{"_id":39,"slug":40,"__v":6,"author":7,"canonical":41,"category":10,"createdAt":42,"date":12,"description":43,"image":15,"imageAlt":15,"readingTime":44,"tags":45,"title":51,"updatedAt":52},"6a14645baa6b273b0cc45458","astro-v5-vers-v6-content-collections-cassent-le-mapping-frontmatter-title","https://seogard.io/blog/astro-v5-vers-v6-content-collections-cassent-le-mapping-frontmatter-title","2026-05-25T15:01:47.013Z","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.",11,[46,47,48,49,50],"astro","content collections","frontmatter","title","migration","Astro v6 : Content Collections cassent les title en silence","Mon May 25 2026 15:01:47 GMT+0000 (Coordinated Universal Time)",{"_id":54,"slug":55,"__v":6,"author":7,"canonical":56,"category":10,"createdAt":57,"date":58,"description":59,"image":15,"imageAlt":15,"readingTime":44,"tags":60,"title":65,"updatedAt":66},"6a129444aa6b273b0c453fac","migration-vue-2-vers-vue-3-47-pages-produit-en-panne-seo-en-silence","https://seogard.io/blog/migration-vue-2-vers-vue-3-47-pages-produit-en-panne-seo-en-silence","2026-05-24T06:01:40.987Z","2026-05-24","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.",[61,50,62,63,64],"vue 3","usehead","composition api","seo","Migration Vue 3 : 47 pages produit sans meta titles pendant 21 jours","Sun May 24 2026 06:01:40 GMT+0000 (Coordinated Universal Time)"]