[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"$f1eK8EgVNQyN5xjVtjXuaraYurGPaTPNiV_ZMkolOvxU":3,"$f0di90KNM1AOiNDxQ_9bQIeoS8nuXPJDclt1sLi1-PyI":25},{"_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":23,"updatedAt":24},"6a141e10aa6b273b0c8a4eb7","react-17-vers-react-18-suspense-ssr-fait-crasher-next-head-en-streaming",0,"Equipe Seogard","# React 17 → React 18 : quand Suspense SSR fait disparaître les meta tags de next/head\n\nJeudi 14h30. L'équipe frontend d'une marketplace française de mobilier — 1 200 fiches produit, 380K visites organiques par mois — merge la PR « Upgrade React 18 + Streaming SSR ». Les Lighthouse passent au vert. Le QA valide le parcours d'achat. Vendredi soir, le déploiement part en production. Lundi matin, Search Console affiche 847 pages avec l'alerte « Title tag absent ». Le trafic organique a déjà commencé à glisser.\n\n## Lundi 9h12 — Le tableau de bord vire au rouge\n\nLe Lead SEO ouvre Search Console comme chaque lundi. L'onglet « Pages » affiche un nouveau cluster d'erreurs : 847 URL remontées avec le statut « Explorée, actuellement non indexée ». Le rapport « Améliorations » signale des balises `\u003Ctitle>` manquantes sur l'ensemble des fiches produit. Première réaction : un problème de déploiement partiel. Le DevOps confirme que le rollout est complet depuis vendredi 21h.\n\nL'équipe ouvre une fiche produit dans Chrome. Le `\u003Ctitle>` est bien visible dans l'onglet du navigateur. Les meta description, canonical, og:title — tout est là dans l'inspecteur. Le Lead SEO lance un `curl -A Googlebot` sur la même URL. Le HTML retourné contient un `\u003Chead>` quasi vide : pas de `\u003Ctitle>`, pas de `\u003Cmeta name=\"description\">`, pas de `\u003Clink rel=\"canonical\">`. Juste le charset et le viewport.\n\nHypothèse #1 : un problème de cloaking accidentel lié au user-agent. L'équipe vérifie le middleware Next.js — aucune condition sur le user-agent. Hypothèse écartée en 15 minutes.\n\nHypothèse #2 : un cache CDN qui servirait une version stale. L'équipe purge Cloudflare, reteste. Même résultat. Le `curl` sans user-agent Googlebot renvoie aussi un `\u003Chead>` vide. Le problème ne vient pas du CDN.\n\nHypothèse #3 : un bug de `next/head` après la mise à jour de dépendances. L'équipe regarde le `package.json` — Next.js 13.5.6 avec React 18.2.0, monté depuis React 17.0.2. Le diff du lock file montre que `react-dom` a changé de mode de rendu. C'est là que le soupçon se forme.\n\nLe Lead SEO lance un crawl Screaming Frog en mode « JavaScript rendering » sur 200 URL. Résultat : les meta tags apparaissent. Puis un crawl en mode « HTML brut » : les meta tags sont absents sur 194 des 200 pages. L'écart est net. Le problème est dans le HTML initial envoyé par le serveur, pas dans le rendu client.\n\nÀ ce stade, les chiffres Google Analytics 4 ne montrent pas encore de chute — le week-end masque le signal. Mais le Lead SEO sait que Googlebot a crawlé 310 pages depuis vendredi soir. 310 pages crawlées avec un `\u003Chead>` vide. L'horloge tourne.\n\n## Le bug : le streaming SSR de React 18 réordonne les chunks et abandonne next/head\n\nPour comprendre ce qui s'est passé, il faut remonter au changement fondamental entre React 17 et React 18 côté serveur.\n\n### React 17 : `renderToString`, synchrone et prévisible\n\nAvec React 17, `react-dom/server` expose `renderToString`. La méthode est synchrone. Elle produit une chaîne HTML complète en une seule passe. `next/head` collecte tous les appels à `\u003CHead>` dans l'arbre de composants, puis injecte les balises dans le `\u003Chead>` du document avant d'envoyer la réponse. L'ordre est garanti : le `\u003Chead>` est complet au moment où le premier octet part vers le client.\n\nLe HTML renvoyé ressemblait à ceci :\n\n```html\n\u003C!DOCTYPE html>\n\u003Chtml>\n\u003Chead>\n  \u003Cmeta charset=\"utf-8\" />\n  \u003Cmeta name=\"viewport\" content=\"width=device-width\" />\n  \u003Ctitle>Canapé 3 places velours bleu — MaisonDeco\u003C/title>\n  \u003Cmeta name=\"description\" content=\"Canapé 3 places en velours bleu, livraison gratuite. Dimensions 220x95x85cm.\" />\n  \u003Clink rel=\"canonical\" href=\"https://www.maisondeco.fr/canape-3-places-velours-bleu\" />\n  \u003Cmeta property=\"og:title\" content=\"Canapé 3 places velours bleu\" />\n\u003C/head>\n\u003Cbody>\n  \u003Cdiv id=\"__next\">\u003C!-- contenu complet -->\u003C/div>\n\u003C/body>\n\u003C/html>\n```\n\nGooglebot reçoit ce HTML, parse le `\u003Chead>`, trouve tout ce qu'il faut. Indexation propre.\n\n### React 18 : `renderToPipeableStream`, asynchrone et fragmenté\n\nReact 18 introduit `renderToPipeableStream`. Le rendu est streamé : le serveur envoie le shell HTML immédiatement, puis injecte les contenus des `\u003CSuspense>` boundaries au fur et à mesure qu'ils se résolvent. C'est un gain de performance réel — le TTFB chute, le LCP s'améliore.\n\nMais `next/head` n'a pas été conçu pour ce modèle. Le composant `\u003CHead>` de `next/head` fonctionne par side-effect : pendant le rendu, chaque instance de `\u003CHead>` pousse ses balises dans un contexte global. Avec `renderToString`, ce contexte est lu une fois le rendu terminé, et les balises sont injectées dans le `\u003Chead>` du document.\n\nAvec `renderToPipeableStream`, le shell HTML — y compris le `\u003Chead>` — est envoyé **avant** que les composants wrappés dans `\u003CSuspense>` aient fini leur rendu. Si un composant produit appelle `\u003CHead>` et que ce composant est enfant d'un `\u003CSuspense>`, ses meta tags ne sont pas encore collectés quand le `\u003Chead>` est flushed.\n\nLe HTML streamé ressemblait à ceci côté serveur :\n\n```html\n\u003C!DOCTYPE html>\n\u003Chtml>\n\u003Chead>\n  \u003Cmeta charset=\"utf-8\" />\n  \u003Cmeta name=\"viewport\" content=\"width=device-width\" />\n  \u003C!-- next/head : rien ici, les composants produit n'ont pas encore rendu -->\n\u003C/head>\n\u003Cbody>\n  \u003Cdiv id=\"__next\">\n    \u003Cheader>\u003C!-- shell statique -->\u003C/header>\n    \u003C!--$?-->\u003Ctemplate id=\"B:0\">\u003C/template>\u003C!--/$-->\n  \u003C/div>\n  \u003C!-- plus tard, injecté par streaming : -->\n  \u003Cscript>\n    // React injecte le contenu résolu du Suspense boundary\n    // mais les balises \u003CHead> ne remontent PAS dans le \u003Chead> du document\n  \u003C/script>\n\u003C/body>\n\u003C/html>\n```\n\nLes meta tags finissent par arriver côté client via JavaScript. Le navigateur les insère dans le DOM. L'utilisateur ne voit rien d'anormal. Mais Googlebot, même s'il exécute JavaScript, reçoit d'abord le HTML initial. Et dans de nombreux cas, l'indexation se base sur ce HTML initial, surtout pour les balises du `\u003Chead>`.\n\n### Reproduction step-by-step\n\nL'équipe a isolé le bug avec cette commande :\n\n```bash\ncurl -s https://www.maisondeco.fr/canape-3-places-velours-bleu \\\n  | head -50 \\\n  | grep -E '\u003Ctitle>|\u003Cmeta name=\"description\"|\u003Clink rel=\"canonical\"'\n```\n\nRésultat : aucune ligne. La même commande sur une page statique (CGU, mentions légales) qui n'utilise pas `\u003CSuspense>` retourne les trois balises.\n\nPour confirmer, l'équipe a utilisé l'outil « Inspection d'URL » de Search Console, puis cliqué sur « Tester l'URL en direct ». Le HTML rendu par Google montrait un `\u003Chead>` sans meta tags sur les pages produit.\n\nLa comparaison dans Chrome DevTools était trompeuse. Le panneau « Elements » affiche le DOM live — après hydratation client, après que React a patché le `\u003Chead>`. Le panneau « Sources » → « View Page Source » montrait le HTML brut streamé. C'est là que le `\u003Chead>` était vide.\n\n### Pourquoi les tests n'ont rien vu\n\nLa CI de l'équipe utilisait `@testing-library/react` avec `render()` — du rendu client. Les tests e2e Playwright vérifiaient `page.title()` — qui lit le DOM après hydratation. Aucun test ne vérifiait le HTML brut de la réponse HTTP.\n\nLe pipeline Lighthouse tournait en mode « navigation » avec un Chrome headless qui exécute le JS. Les scores étaient au vert. Les meta tags étaient présents — dans le DOM post-hydratation.\n\nPersonne ne testait ce que Googlebot voit en premier : le HTML brut, avant JavaScript.\n\nC'est un angle mort classique des migrations de framework. La même régression silencieuse [a frappé des équipes sur la migration Next.js Pages Router vers App Router](/blog/migration-next-js-pages-router-vers-app-router-les-metadata-ignorees-sur-les-pages-client), où les `metadata` exports étaient ignorés sur les composants marqués `\"use client\"`.\n\n### La structure du composant fautif\n\nVoici le pattern qui causait le problème. Le composant `ProductPage` était wrappé dans un `\u003CSuspense>` dans `_app.tsx` :\n\n```tsx\n// pages/product/[slug].tsx\nimport Head from 'next/head';\nimport { Suspense } from 'react';\nimport ProductDetails from '@/components/ProductDetails';\nimport ProductReviews from '@/components/ProductReviews';\n\nexport default function ProductPage({ product }) {\n  return (\n    \u003C>\n      \u003CHead>\n        \u003Ctitle>{product.name} — MaisonDeco\u003C/title>\n        \u003Cmeta name=\"description\" content={product.shortDescription} />\n        \u003Clink rel=\"canonical\" href={`https://www.maisondeco.fr/${product.slug}`} />\n        \u003Cmeta property=\"og:title\" content={product.name} />\n        \u003Cmeta property=\"og:image\" content={product.image} />\n      \u003C/Head>\n      \u003CProductDetails product={product} />\n      \u003CSuspense fallback={\u003Cdiv>Chargement avis...\u003C/div>}>\n        \u003CProductReviews productId={product.id} />\n      \u003C/Suspense>\n    \u003C/>\n  );\n}\n```\n\nLe composant `ProductPage` lui-même n'était pas dans un Suspense. Mais la configuration de `_app.tsx` avait été modifiée pendant la migration pour wrapper le contenu principal dans un Suspense boundary global :\n\n```tsx\n// pages/_app.tsx\nimport { Suspense } from 'react';\nimport Layout from '@/components/Layout';\n\nexport default function App({ Component, pageProps }) {\n  return (\n    \u003CLayout>\n      \u003CSuspense fallback={\u003Cdiv className=\"loading-shell\" />}>\n        \u003CComponent {...pageProps} />\n      \u003C/Suspense>\n    \u003C/Layout>\n  );\n}\n```\n\nCe `\u003CSuspense>` dans `_app.tsx` est le coupable. Il dit à React 18 : « tu peux streamer le shell (`\u003CLayout>`) immédiatement, et résoudre `\u003CComponent>` plus tard ». Le `\u003CHead>` à l'intérieur de `\u003CComponent>` n'est donc pas collecté au moment du flush initial du `\u003Chead>`.\n\n## Le fix — Sortir next/head du Suspense boundary\n\n### Patch immédiat : remonter les meta tags hors du Suspense\n\nLa solution la plus propre : ne plus wrapper le composant page entier dans un `\u003CSuspense>`. Le Suspense doit être granulaire — uniquement autour des composants qui ont réellement besoin de lazy loading.\n\n```tsx\n// pages/_app.tsx — CORRIGÉ\nimport Layout from '@/components/Layout';\n\nexport default function App({ Component, pageProps }) {\n  return (\n    \u003CLayout>\n      \u003CComponent {...pageProps} />\n    \u003C/Layout>\n  );\n}\n```\n\nLe `\u003CSuspense>` est déplacé à l'intérieur des pages, uniquement autour des sections non critiques pour le SEO :\n\n```tsx\n// pages/product/[slug].tsx — CORRIGÉ\nimport Head from 'next/head';\nimport { Suspense } from 'react';\nimport ProductDetails from '@/components/ProductDetails';\nimport ProductReviews from '@/components/ProductReviews';\n\nexport default function ProductPage({ product }) {\n  return (\n    \u003C>\n      \u003CHead>\n        \u003Ctitle>{product.name} — MaisonDeco\u003C/title>\n        \u003Cmeta name=\"description\" content={product.shortDescription} />\n        \u003Clink rel=\"canonical\" href={`https://www.maisondeco.fr/${product.slug}`} />\n        \u003Cmeta property=\"og:title\" content={product.name} />\n        \u003Cmeta property=\"og:image\" content={product.image} />\n      \u003C/Head>\n      \u003CProductDetails product={product} />\n      \u003CSuspense fallback={\u003Cdiv>Chargement avis...\u003C/div>}>\n        \u003CProductReviews productId={product.id} />\n      \u003C/Suspense>\n    \u003C/>\n  );\n}\n```\n\n### Vérification post-déploiement\n\nAprès le merge et le déploiement, l'équipe vérifie avec le même `curl` :\n\n```bash\ncurl -s https://www.maisondeco.fr/canape-3-places-velours-bleu \\\n  | head -50 \\\n  | grep -E '\u003Ctitle>|\u003Cmeta name=\"description\"|\u003Clink rel=\"canonical\"'\n```\n\nRésultat :\n\n```\n\u003Ctitle>Canapé 3 places velours bleu — MaisonDeco\u003C/title>\n\u003Cmeta name=\"description\" content=\"Canapé 3 places en velours bleu, livraison gratuite. Dimensions 220x95x85cm.\" />\n\u003Clink rel=\"canonical\" href=\"https://www.maisondeco.fr/canape-3-places-velours-bleu\" />\n```\n\nLes trois lignes sont de retour dans le HTML initial.\n\n### Test CI ajouté\n\nL'équipe ajoute un test d'intégration qui vérifie le HTML brut retourné par le serveur Next.js :\n\n```ts\n// __tests__/seo-ssr.test.ts\nimport { createServer } from 'http';\nimport next from 'next';\n\ndescribe('SSR meta tags', () => {\n  let server: ReturnType\u003Ctypeof createServer>;\n  let baseUrl: string;\n\n  beforeAll(async () => {\n    const app = next({ dev: false, dir: '.' });\n    await app.prepare();\n    const handle = app.getRequestHandler();\n    server = createServer(handle);\n    await new Promise\u003Cvoid>((resolve) => {\n      server.listen(0, () => {\n        const addr = server.address();\n        baseUrl = `http://localhost:${(addr as any).port}`;\n        resolve();\n      });\n    });\n  });\n\n  afterAll(() => server.close());\n\n  it('should include title and canonical in raw HTML for product pages', async () => {\n    const res = await fetch(`${baseUrl}/product/canape-3-places-velours-bleu`);\n    const html = await res.text();\n    expect(html).toContain('\u003Ctitle>');\n    expect(html).toContain('\u003Clink rel=\"canonical\"');\n    expect(html).toContain('\u003Cmeta name=\"description\"');\n  });\n});\n```\n\nCe test échoue sur le HTML brut, pas sur le DOM hydraté. Il aurait détecté la régression avant le déploiement.\n\n### Invalidation et récupération\n\nL'équipe force un recrawl via Search Console (« Demander l'indexation ») sur les 50 pages à plus fort trafic. Pour le reste, elle soumet un sitemap mis à jour avec des `\u003Clastmod>` au jour du fix pour accélérer le passage de Googlebot.\n\nLe cache Cloudflare est purgé intégralement. L'équipe vérifie que le header `Cache-Control` des pages produit inclut `s-maxage=3600, stale-while-revalidate=86400` — suffisamment court pour que la version corrigée soit servie rapidement.\n\n### Chronologie de récupération\n\n- **J+0** (mardi) : fix déployé, recrawl forcé sur top 50 pages.\n- **J+3** : Search Console montre 340 pages re-crawlées avec `\u003Ctitle>` détecté.\n- **J+7** : les alertes « Title tag absent » passent de 847 à 210.\n- **J+14** : toutes les pages sont re-crawlées. Les alertes tombent à 0.\n- **J+21** : le trafic organique revient au niveau pré-incident. La perte cumulée est estimée à ~45K clics sur la période, soit environ 12% du trafic mensuel.\n\nLes pages qui avaient perdu leur position 1 sur des requêtes longue traîne (« canapé velours bleu 3 places livraison gratuite ») ont mis jusqu'à 18 jours pour retrouver leur rang. Les pages à fort Domain Authority (catégories principales) ont récupéré en 4-5 jours.\n\nL'impact aurait été pire sur un site plus récent ou avec moins d'autorité. Et il aurait été détecté plus tard sans la vérification systématique du lundi matin dans Search Console. D'autres migrations silencieuses — comme celle documentée sur [Nuxt 2 vers Nuxt 3 avec 200 pages en fallback layout](/blog/migration-nuxt-2-vers-nuxt-3-200-pages-en-fallback-layout-pendant-6-semaines) — ont mis six semaines avant d'être repérées.\n\n## Ce qu'on en retient\n\nLa migration React 17 → 18 ne casse rien visuellement. Le navigateur affiche le bon titre, les bonnes metas. C'est ce qui rend le bug invisible aux yeux de toute l'équipe — devs, QA, product.\n\nLe danger est dans l'écart entre le DOM hydraté et le HTML initial. Cet écart n'existe pas en React 17. Il apparaît dès qu'un `\u003CSuspense>` boundary englobe un composant qui appelle `next/head`. Et aucun outil de test front standard ne le détecte par défaut.\n\nTrois règles à appliquer après une migration React 18 avec streaming :\n\n1. **Ne jamais wrapper un composant contenant `\u003CHead>` dans un `\u003CSuspense>` au niveau `_app`.**\n2. **Tester le HTML brut en CI**, pas seulement le DOM post-hydratation.\n3. **Monitorer la divergence SSR/CSR en continu.** Un outil comme Seogard compare automatiquement le HTML initial et le DOM rendu pour chaque page crawlée — ce type de régression est détecté en minutes, pas en semaines.\n\nLe streaming SSR est un vrai gain de performance. Mais les meta tags SEO doivent être dans le shell, jamais dans un chunk différé. C'est la règle non écrite de React 18 que la [documentation officielle](https://react.dev/reference/react-dom/server/renderToPipeableStream) ne mentionne qu'en passant.\n```","https://seogard.io/blog/react-17-vers-react-18-suspense-ssr-fait-crasher-next-head-en-streaming","Migration","2026-05-25T10:01:52.337Z","2026-05-25","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.","\u003Ch1>React 17 → React 18 : quand Suspense SSR fait disparaître les meta tags de next/head\u003C/h1>\n\u003Cp>Jeudi 14h30. L'équipe frontend d'une marketplace française de mobilier — 1 200 fiches produit, 380K visites organiques par mois — merge la PR « Upgrade React 18 + Streaming SSR ». Les Lighthouse passent au vert. Le QA valide le parcours d'achat. Vendredi soir, le déploiement part en production. Lundi matin, Search Console affiche 847 pages avec l'alerte « Title tag absent ». Le trafic organique a déjà commencé à glisser.\u003C/p>\n\u003Ch2>Lundi 9h12 — Le tableau de bord vire au rouge\u003C/h2>\n\u003Cp>Le Lead SEO ouvre Search Console comme chaque lundi. L'onglet « Pages » affiche un nouveau cluster d'erreurs : 847 URL remontées avec le statut « Explorée, actuellement non indexée ». Le rapport « Améliorations » signale des balises \u003Ccode>&#x3C;title>\u003C/code> manquantes sur l'ensemble des fiches produit. Première réaction : un problème de déploiement partiel. Le DevOps confirme que le rollout est complet depuis vendredi 21h.\u003C/p>\n\u003Cp>L'équipe ouvre une fiche produit dans Chrome. Le \u003Ccode>&#x3C;title>\u003C/code> est bien visible dans l'onglet du navigateur. Les meta description, canonical, og:title — tout est là dans l'inspecteur. Le Lead SEO lance un \u003Ccode>curl -A Googlebot\u003C/code> sur la même URL. Le HTML retourné contient un \u003Ccode>&#x3C;head>\u003C/code> quasi vide : pas de \u003Ccode>&#x3C;title>\u003C/code>, pas de \u003Ccode>&#x3C;meta name=\"description\">\u003C/code>, pas de \u003Ccode>&#x3C;link rel=\"canonical\">\u003C/code>. Juste le charset et le viewport.\u003C/p>\n\u003Cp>Hypothèse #1 : un problème de cloaking accidentel lié au user-agent. L'équipe vérifie le middleware Next.js — aucune condition sur le user-agent. Hypothèse écartée en 15 minutes.\u003C/p>\n\u003Cp>Hypothèse #2 : un cache CDN qui servirait une version stale. L'équipe purge Cloudflare, reteste. Même résultat. Le \u003Ccode>curl\u003C/code> sans user-agent Googlebot renvoie aussi un \u003Ccode>&#x3C;head>\u003C/code> vide. Le problème ne vient pas du CDN.\u003C/p>\n\u003Cp>Hypothèse #3 : un bug de \u003Ccode>next/head\u003C/code> après la mise à jour de dépendances. L'équipe regarde le \u003Ccode>package.json\u003C/code> — Next.js 13.5.6 avec React 18.2.0, monté depuis React 17.0.2. Le diff du lock file montre que \u003Ccode>react-dom\u003C/code> a changé de mode de rendu. C'est là que le soupçon se forme.\u003C/p>\n\u003Cp>Le Lead SEO lance un crawl Screaming Frog en mode « JavaScript rendering » sur 200 URL. Résultat : les meta tags apparaissent. Puis un crawl en mode « HTML brut » : les meta tags sont absents sur 194 des 200 pages. L'écart est net. Le problème est dans le HTML initial envoyé par le serveur, pas dans le rendu client.\u003C/p>\n\u003Cp>À ce stade, les chiffres Google Analytics 4 ne montrent pas encore de chute — le week-end masque le signal. Mais le Lead SEO sait que Googlebot a crawlé 310 pages depuis vendredi soir. 310 pages crawlées avec un \u003Ccode>&#x3C;head>\u003C/code> vide. L'horloge tourne.\u003C/p>\n\u003Ch2>Le bug : le streaming SSR de React 18 réordonne les chunks et abandonne next/head\u003C/h2>\n\u003Cp>Pour comprendre ce qui s'est passé, il faut remonter au changement fondamental entre React 17 et React 18 côté serveur.\u003C/p>\n\u003Ch3>React 17 : \u003Ccode>renderToString\u003C/code>, synchrone et prévisible\u003C/h3>\n\u003Cp>Avec React 17, \u003Ccode>react-dom/server\u003C/code> expose \u003Ccode>renderToString\u003C/code>. La méthode est synchrone. Elle produit une chaîne HTML complète en une seule passe. \u003Ccode>next/head\u003C/code> collecte tous les appels à \u003Ccode>&#x3C;Head>\u003C/code> dans l'arbre de composants, puis injecte les balises dans le \u003Ccode>&#x3C;head>\u003C/code> du document avant d'envoyer la réponse. L'ordre est garanti : le \u003Ccode>&#x3C;head>\u003C/code> est complet au moment où le premier octet part vers le client.\u003C/p>\n\u003Cp>Le HTML renvoyé ressemblait à ceci :\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:#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\">meta\u003C/span>\u003Cspan style=\"color:#B392F0\"> name\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"viewport\"\u003C/span>\u003Cspan style=\"color:#B392F0\"> content\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"width=device-width\"\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\">>Canapé 3 places velours bleu — MaisonDeco&#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\">meta\u003C/span>\u003Cspan style=\"color:#B392F0\"> name\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"description\"\u003C/span>\u003Cspan style=\"color:#B392F0\"> content\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"Canapé 3 places en velours bleu, livraison gratuite. Dimensions 220x95x85cm.\"\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\">\"canonical\"\u003C/span>\u003Cspan style=\"color:#B392F0\"> href\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"https://www.maisondeco.fr/canape-3-places-velours-bleu\"\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\"> property\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"og:title\"\u003C/span>\u003Cspan style=\"color:#B392F0\"> content\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"Canapé 3 places velours bleu\"\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\">div\u003C/span>\u003Cspan style=\"color:#B392F0\"> id\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"__next\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003Cspan style=\"color:#6A737D\">&#x3C;!-- contenu complet -->\u003C/span>\u003Cspan style=\"color:#E1E4E8\">&#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">div\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>Googlebot reçoit ce HTML, parse le \u003Ccode>&#x3C;head>\u003C/code>, trouve tout ce qu'il faut. Indexation propre.\u003C/p>\n\u003Ch3>React 18 : \u003Ccode>renderToPipeableStream\u003C/code>, asynchrone et fragmenté\u003C/h3>\n\u003Cp>React 18 introduit \u003Ccode>renderToPipeableStream\u003C/code>. Le rendu est streamé : le serveur envoie le shell HTML immédiatement, puis injecte les contenus des \u003Ccode>&#x3C;Suspense>\u003C/code> boundaries au fur et à mesure qu'ils se résolvent. C'est un gain de performance réel — le TTFB chute, le LCP s'améliore.\u003C/p>\n\u003Cp>Mais \u003Ccode>next/head\u003C/code> n'a pas été conçu pour ce modèle. Le composant \u003Ccode>&#x3C;Head>\u003C/code> de \u003Ccode>next/head\u003C/code> fonctionne par side-effect : pendant le rendu, chaque instance de \u003Ccode>&#x3C;Head>\u003C/code> pousse ses balises dans un contexte global. Avec \u003Ccode>renderToString\u003C/code>, ce contexte est lu une fois le rendu terminé, et les balises sont injectées dans le \u003Ccode>&#x3C;head>\u003C/code> du document.\u003C/p>\n\u003Cp>Avec \u003Ccode>renderToPipeableStream\u003C/code>, le shell HTML — y compris le \u003Ccode>&#x3C;head>\u003C/code> — est envoyé \u003Cstrong>avant\u003C/strong> que les composants wrappés dans \u003Ccode>&#x3C;Suspense>\u003C/code> aient fini leur rendu. Si un composant produit appelle \u003Ccode>&#x3C;Head>\u003C/code> et que ce composant est enfant d'un \u003Ccode>&#x3C;Suspense>\u003C/code>, ses meta tags ne sont pas encore collectés quand le \u003Ccode>&#x3C;head>\u003C/code> est flushed.\u003C/p>\n\u003Cp>Le HTML streamé ressemblait à ceci côté serveur :\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:#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\">meta\u003C/span>\u003Cspan style=\"color:#B392F0\"> name\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"viewport\"\u003C/span>\u003Cspan style=\"color:#B392F0\"> content\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"width=device-width\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> />\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">  &#x3C;!-- next/head : rien ici, les composants produit n'ont pas encore rendu -->\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\">div\u003C/span>\u003Cspan style=\"color:#B392F0\"> id\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"__next\"\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\">header\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003Cspan style=\"color:#6A737D\">&#x3C;!-- shell statique -->\u003C/span>\u003Cspan style=\"color:#E1E4E8\">&#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">header\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">    &#x3C;!--$?-->\u003C/span>\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">template\u003C/span>\u003Cspan style=\"color:#B392F0\"> id\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"B:0\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>&#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">template\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003Cspan style=\"color:#6A737D\">&#x3C;!--/$-->\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  &#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">div\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">  &#x3C;!-- plus tard, injecté par streaming : -->\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:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">    // React injecte le contenu résolu du Suspense boundary\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">    // mais les balises &#x3C;Head> ne remontent PAS dans le &#x3C;head> du document\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:#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>Les meta tags finissent par arriver côté client via JavaScript. Le navigateur les insère dans le DOM. L'utilisateur ne voit rien d'anormal. Mais Googlebot, même s'il exécute JavaScript, reçoit d'abord le HTML initial. Et dans de nombreux cas, l'indexation se base sur ce HTML initial, surtout pour les balises du \u003Ccode>&#x3C;head>\u003C/code>.\u003C/p>\n\u003Ch3>Reproduction step-by-step\u003C/h3>\n\u003Cp>L'équipe a isolé le bug avec cette commande :\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:#9ECBFF\"> https://www.maisondeco.fr/canape-3-places-velours-bleu\u003C/span>\u003Cspan style=\"color:#79B8FF\"> \\\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  |\u003C/span>\u003Cspan style=\"color:#B392F0\"> head\u003C/span>\u003Cspan style=\"color:#79B8FF\"> -50\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\"> -E\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> '&#x3C;title>|&#x3C;meta name=\"description\"|&#x3C;link rel=\"canonical\"'\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Résultat : aucune ligne. La même commande sur une page statique (CGU, mentions légales) qui n'utilise pas \u003Ccode>&#x3C;Suspense>\u003C/code> retourne les trois balises.\u003C/p>\n\u003Cp>Pour confirmer, l'équipe a utilisé l'outil « Inspection d'URL » de Search Console, puis cliqué sur « Tester l'URL en direct ». Le HTML rendu par Google montrait un \u003Ccode>&#x3C;head>\u003C/code> sans meta tags sur les pages produit.\u003C/p>\n\u003Cp>La comparaison dans Chrome DevTools était trompeuse. Le panneau « Elements » affiche le DOM live — après hydratation client, après que React a patché le \u003Ccode>&#x3C;head>\u003C/code>. Le panneau « Sources » → « View Page Source » montrait le HTML brut streamé. C'est là que le \u003Ccode>&#x3C;head>\u003C/code> était vide.\u003C/p>\n\u003Ch3>Pourquoi les tests n'ont rien vu\u003C/h3>\n\u003Cp>La CI de l'équipe utilisait \u003Ccode>@testing-library/react\u003C/code> avec \u003Ccode>render()\u003C/code> — du rendu client. Les tests e2e Playwright vérifiaient \u003Ccode>page.title()\u003C/code> — qui lit le DOM après hydratation. Aucun test ne vérifiait le HTML brut de la réponse HTTP.\u003C/p>\n\u003Cp>Le pipeline Lighthouse tournait en mode « navigation » avec un Chrome headless qui exécute le JS. Les scores étaient au vert. Les meta tags étaient présents — dans le DOM post-hydratation.\u003C/p>\n\u003Cp>Personne ne testait ce que Googlebot voit en premier : le HTML brut, avant JavaScript.\u003C/p>\n\u003Cp>C'est un angle mort classique des migrations de framework. La même régression silencieuse \u003Ca href=\"/blog/migration-next-js-pages-router-vers-app-router-les-metadata-ignorees-sur-les-pages-client\">a frappé des équipes sur la migration Next.js Pages Router vers App Router\u003C/a>, où les \u003Ccode>metadata\u003C/code> exports étaient ignorés sur les composants marqués \u003Ccode>\"use client\"\u003C/code>.\u003C/p>\n\u003Ch3>La structure du composant fautif\u003C/h3>\n\u003Cp>Voici le pattern qui causait le problème. Le composant \u003Ccode>ProductPage\u003C/code> était wrappé dans un \u003Ccode>&#x3C;Suspense>\u003C/code> dans \u003Ccode>_app.tsx\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\">// pages/product/[slug].tsx\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> Head \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'next/head'\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\"> { Suspense } \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'react'\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\"> ProductDetails \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> '@/components/ProductDetails'\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\"> ProductReviews \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> '@/components/ProductReviews'\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\"> default\u003C/span>\u003Cspan style=\"color:#F97583\"> function\u003C/span>\u003Cspan style=\"color:#B392F0\"> ProductPage\u003C/span>\u003Cspan style=\"color:#E1E4E8\">({ \u003C/span>\u003Cspan style=\"color:#FFAB70\">product\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> }) {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  return\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> (\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    &#x3C;>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      &#x3C;\u003C/span>\u003Cspan style=\"color:#79B8FF\">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\">title\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>{product.name} — MaisonDeco&#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\">meta\u003C/span>\u003Cspan style=\"color:#B392F0\"> name\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"description\"\u003C/span>\u003Cspan style=\"color:#B392F0\"> content\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#E1E4E8\">{product.shortDescription} />\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:#F97583\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"canonical\"\u003C/span>\u003Cspan style=\"color:#B392F0\"> href\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#E1E4E8\">{\u003C/span>\u003Cspan style=\"color:#9ECBFF\">`https://www.maisondeco.fr/${\u003C/span>\u003Cspan style=\"color:#E1E4E8\">product\u003C/span>\u003Cspan style=\"color:#9ECBFF\">.\u003C/span>\u003Cspan style=\"color:#E1E4E8\">slug\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\">meta\u003C/span>\u003Cspan style=\"color:#B392F0\"> property\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"og:title\"\u003C/span>\u003Cspan style=\"color:#B392F0\"> content\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#E1E4E8\">{product.name} />\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\"> property\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"og:image\"\u003C/span>\u003Cspan style=\"color:#B392F0\"> content\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#E1E4E8\">{product.image} />\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      &#x3C;/\u003C/span>\u003Cspan style=\"color:#79B8FF\">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:#79B8FF\">ProductDetails\u003C/span>\u003Cspan style=\"color:#B392F0\"> product\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#E1E4E8\">{product} />\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      &#x3C;\u003C/span>\u003Cspan style=\"color:#79B8FF\">Suspense\u003C/span>\u003Cspan style=\"color:#B392F0\"> fallback\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#E1E4E8\">{&#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">div\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>Chargement avis...&#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">div\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>}>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">        &#x3C;\u003C/span>\u003Cspan style=\"color:#79B8FF\">ProductReviews\u003C/span>\u003Cspan style=\"color:#B392F0\"> productId\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#E1E4E8\">{product.id} />\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      &#x3C;/\u003C/span>\u003Cspan style=\"color:#79B8FF\">Suspense\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    &#x3C;/>\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>Le composant \u003Ccode>ProductPage\u003C/code> lui-même n'était pas dans un Suspense. Mais la configuration de \u003Ccode>_app.tsx\u003C/code> avait été modifiée pendant la migration pour wrapper le contenu principal dans un Suspense boundary global :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">// pages/_app.tsx\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { Suspense } \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'react'\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\"> Layout \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> '@/components/Layout'\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\"> default\u003C/span>\u003Cspan style=\"color:#F97583\"> function\u003C/span>\u003Cspan style=\"color:#B392F0\"> App\u003C/span>\u003Cspan style=\"color:#E1E4E8\">({ \u003C/span>\u003Cspan style=\"color:#FFAB70\">Component\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#FFAB70\">pageProps\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> }) {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  return\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> (\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    &#x3C;\u003C/span>\u003Cspan style=\"color:#79B8FF\">Layout\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      &#x3C;\u003C/span>\u003Cspan style=\"color:#79B8FF\">Suspense\u003C/span>\u003Cspan style=\"color:#B392F0\"> fallback\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#E1E4E8\">{&#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">div\u003C/span>\u003Cspan style=\"color:#B392F0\"> className\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"loading-shell\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> />}>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">        &#x3C;\u003C/span>\u003Cspan style=\"color:#79B8FF\">Component\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> {\u003C/span>\u003Cspan style=\"color:#F97583\">...\u003C/span>\u003Cspan style=\"color:#E1E4E8\">pageProps} />\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      &#x3C;/\u003C/span>\u003Cspan style=\"color:#79B8FF\">Suspense\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    &#x3C;/\u003C/span>\u003Cspan style=\"color:#79B8FF\">Layout\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>Ce \u003Ccode>&#x3C;Suspense>\u003C/code> dans \u003Ccode>_app.tsx\u003C/code> est le coupable. Il dit à React 18 : « tu peux streamer le shell (\u003Ccode>&#x3C;Layout>\u003C/code>) immédiatement, et résoudre \u003Ccode>&#x3C;Component>\u003C/code> plus tard ». Le \u003Ccode>&#x3C;Head>\u003C/code> à l'intérieur de \u003Ccode>&#x3C;Component>\u003C/code> n'est donc pas collecté au moment du flush initial du \u003Ccode>&#x3C;head>\u003C/code>.\u003C/p>\n\u003Ch2>Le fix — Sortir next/head du Suspense boundary\u003C/h2>\n\u003Ch3>Patch immédiat : remonter les meta tags hors du Suspense\u003C/h3>\n\u003Cp>La solution la plus propre : ne plus wrapper le composant page entier dans un \u003Ccode>&#x3C;Suspense>\u003C/code>. Le Suspense doit être granulaire — uniquement autour des composants qui ont réellement besoin de lazy loading.\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">// pages/_app.tsx — CORRIGÉ\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> Layout \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> '@/components/Layout'\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\"> default\u003C/span>\u003Cspan style=\"color:#F97583\"> function\u003C/span>\u003Cspan style=\"color:#B392F0\"> App\u003C/span>\u003Cspan style=\"color:#E1E4E8\">({ \u003C/span>\u003Cspan style=\"color:#FFAB70\">Component\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#FFAB70\">pageProps\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> }) {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  return\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> (\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    &#x3C;\u003C/span>\u003Cspan style=\"color:#79B8FF\">Layout\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      &#x3C;\u003C/span>\u003Cspan style=\"color:#79B8FF\">Component\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> {\u003C/span>\u003Cspan style=\"color:#F97583\">...\u003C/span>\u003Cspan style=\"color:#E1E4E8\">pageProps} />\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    &#x3C;/\u003C/span>\u003Cspan style=\"color:#79B8FF\">Layout\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>Le \u003Ccode>&#x3C;Suspense>\u003C/code> est déplacé à l'intérieur des pages, uniquement autour des sections non critiques pour le SEO :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">// pages/product/[slug].tsx — CORRIGÉ\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> Head \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'next/head'\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\"> { Suspense } \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'react'\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\"> ProductDetails \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> '@/components/ProductDetails'\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\"> ProductReviews \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> '@/components/ProductReviews'\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\"> default\u003C/span>\u003Cspan style=\"color:#F97583\"> function\u003C/span>\u003Cspan style=\"color:#B392F0\"> ProductPage\u003C/span>\u003Cspan style=\"color:#E1E4E8\">({ \u003C/span>\u003Cspan style=\"color:#FFAB70\">product\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> }) {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  return\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> (\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    &#x3C;>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      &#x3C;\u003C/span>\u003Cspan style=\"color:#79B8FF\">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\">title\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>{product.name} — MaisonDeco&#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\">meta\u003C/span>\u003Cspan style=\"color:#B392F0\"> name\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"description\"\u003C/span>\u003Cspan style=\"color:#B392F0\"> content\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#E1E4E8\">{product.shortDescription} />\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:#F97583\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"canonical\"\u003C/span>\u003Cspan style=\"color:#B392F0\"> href\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#E1E4E8\">{\u003C/span>\u003Cspan style=\"color:#9ECBFF\">`https://www.maisondeco.fr/${\u003C/span>\u003Cspan style=\"color:#E1E4E8\">product\u003C/span>\u003Cspan style=\"color:#9ECBFF\">.\u003C/span>\u003Cspan style=\"color:#E1E4E8\">slug\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\">meta\u003C/span>\u003Cspan style=\"color:#B392F0\"> property\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"og:title\"\u003C/span>\u003Cspan style=\"color:#B392F0\"> content\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#E1E4E8\">{product.name} />\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\"> property\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"og:image\"\u003C/span>\u003Cspan style=\"color:#B392F0\"> content\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#E1E4E8\">{product.image} />\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      &#x3C;/\u003C/span>\u003Cspan style=\"color:#79B8FF\">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:#79B8FF\">ProductDetails\u003C/span>\u003Cspan style=\"color:#B392F0\"> product\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#E1E4E8\">{product} />\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      &#x3C;\u003C/span>\u003Cspan style=\"color:#79B8FF\">Suspense\u003C/span>\u003Cspan style=\"color:#B392F0\"> fallback\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#E1E4E8\">{&#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">div\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>Chargement avis...&#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">div\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>}>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">        &#x3C;\u003C/span>\u003Cspan style=\"color:#79B8FF\">ProductReviews\u003C/span>\u003Cspan style=\"color:#B392F0\"> productId\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#E1E4E8\">{product.id} />\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      &#x3C;/\u003C/span>\u003Cspan style=\"color:#79B8FF\">Suspense\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    &#x3C;/>\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\u003Ch3>Vérification post-déploiement\u003C/h3>\n\u003Cp>Après le merge et le déploiement, l'équipe vérifie avec le même \u003Ccode>curl\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:#B392F0\">curl\u003C/span>\u003Cspan style=\"color:#79B8FF\"> -s\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> https://www.maisondeco.fr/canape-3-places-velours-bleu\u003C/span>\u003Cspan style=\"color:#79B8FF\"> \\\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  |\u003C/span>\u003Cspan style=\"color:#B392F0\"> head\u003C/span>\u003Cspan style=\"color:#79B8FF\"> -50\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\"> -E\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> '&#x3C;title>|&#x3C;meta name=\"description\"|&#x3C;link rel=\"canonical\"'\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Résultat :\u003C/p>\n\u003Cpre>\u003Ccode>&#x3C;title>Canapé 3 places velours bleu — MaisonDeco&#x3C;/title>\n&#x3C;meta name=\"description\" content=\"Canapé 3 places en velours bleu, livraison gratuite. Dimensions 220x95x85cm.\" />\n&#x3C;link rel=\"canonical\" href=\"https://www.maisondeco.fr/canape-3-places-velours-bleu\" />\n\u003C/code>\u003C/pre>\n\u003Cp>Les trois lignes sont de retour dans le HTML initial.\u003C/p>\n\u003Ch3>Test CI ajouté\u003C/h3>\n\u003Cp>L'équipe ajoute un test d'intégration qui vérifie le HTML brut retourné par le serveur Next.js :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">// __tests__/seo-ssr.test.ts\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { createServer } \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'http'\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\"> next \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'next'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">describe\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">'SSR meta tags'\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\">  let\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> server\u003C/span>\u003Cspan style=\"color:#F97583\">:\u003C/span>\u003Cspan style=\"color:#B392F0\"> ReturnType\u003C/span>\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C/span>\u003Cspan style=\"color:#F97583\">typeof\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> createServer>;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  let\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> baseUrl\u003C/span>\u003Cspan style=\"color:#F97583\">:\u003C/span>\u003Cspan style=\"color:#79B8FF\"> string\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">  beforeAll\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#F97583\">async\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\"> app\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#B392F0\"> next\u003C/span>\u003Cspan style=\"color:#E1E4E8\">({ dev: \u003C/span>\u003Cspan style=\"color:#79B8FF\">false\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, dir: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'.'\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> });\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">    await\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> app.\u003C/span>\u003Cspan style=\"color:#B392F0\">prepare\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\"> handle\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> app.\u003C/span>\u003Cspan style=\"color:#B392F0\">getRequestHandler\u003C/span>\u003Cspan style=\"color:#E1E4E8\">();\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    server \u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#B392F0\"> createServer\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(handle);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">    await\u003C/span>\u003Cspan style=\"color:#F97583\"> new\u003C/span>\u003Cspan style=\"color:#79B8FF\"> Promise\u003C/span>\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C/span>\u003Cspan style=\"color:#79B8FF\">void\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>((\u003C/span>\u003Cspan style=\"color:#FFAB70\">resolve\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:#E1E4E8\">      server.\u003C/span>\u003Cspan style=\"color:#B392F0\">listen\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#79B8FF\">0\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\"> addr\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> server.\u003C/span>\u003Cspan style=\"color:#B392F0\">address\u003C/span>\u003Cspan style=\"color:#E1E4E8\">();\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">        baseUrl \u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> `http://localhost:${\u003C/span>\u003Cspan style=\"color:#9ECBFF\">(\u003C/span>\u003Cspan style=\"color:#E1E4E8\">addr\u003C/span>\u003Cspan style=\"color:#F97583\"> as\u003C/span>\u003Cspan style=\"color:#79B8FF\"> any\u003C/span>\u003Cspan style=\"color:#9ECBFF\">).\u003C/span>\u003Cspan style=\"color:#E1E4E8\">port\u003C/span>\u003Cspan style=\"color:#9ECBFF\">}`\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">        resolve\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\">\u003Cspan style=\"color:#E1E4E8\">  });\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">  afterAll\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(() \u003C/span>\u003Cspan style=\"color:#F97583\">=>\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> server.\u003C/span>\u003Cspan style=\"color:#B392F0\">close\u003C/span>\u003Cspan style=\"color:#E1E4E8\">());\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">  it\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">'should include title and canonical in raw HTML for product pages'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#F97583\">async\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\"> res\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#F97583\"> await\u003C/span>\u003Cspan style=\"color:#B392F0\"> fetch\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">`${\u003C/span>\u003Cspan style=\"color:#E1E4E8\">baseUrl\u003C/span>\u003Cspan style=\"color:#9ECBFF\">}/product/canape-3-places-velours-bleu`\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\"> html\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#F97583\"> await\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> res.\u003C/span>\u003Cspan style=\"color:#B392F0\">text\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\">'&#x3C;title>'\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\">'&#x3C;link rel=\"canonical\"'\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\">'&#x3C;meta name=\"description\"'\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>Ce test échoue sur le HTML brut, pas sur le DOM hydraté. Il aurait détecté la régression avant le déploiement.\u003C/p>\n\u003Ch3>Invalidation et récupération\u003C/h3>\n\u003Cp>L'équipe force un recrawl via Search Console (« Demander l'indexation ») sur les 50 pages à plus fort trafic. Pour le reste, elle soumet un sitemap mis à jour avec des \u003Ccode>&#x3C;lastmod>\u003C/code> au jour du fix pour accélérer le passage de Googlebot.\u003C/p>\n\u003Cp>Le cache Cloudflare est purgé intégralement. L'équipe vérifie que le header \u003Ccode>Cache-Control\u003C/code> des pages produit inclut \u003Ccode>s-maxage=3600, stale-while-revalidate=86400\u003C/code> — suffisamment court pour que la version corrigée soit servie rapidement.\u003C/p>\n\u003Ch3>Chronologie de récupération\u003C/h3>\n\u003Cul>\n\u003Cli>\u003Cstrong>J+0\u003C/strong> (mardi) : fix déployé, recrawl forcé sur top 50 pages.\u003C/li>\n\u003Cli>\u003Cstrong>J+3\u003C/strong> : Search Console montre 340 pages re-crawlées avec \u003Ccode>&#x3C;title>\u003C/code> détecté.\u003C/li>\n\u003Cli>\u003Cstrong>J+7\u003C/strong> : les alertes « Title tag absent » passent de 847 à 210.\u003C/li>\n\u003Cli>\u003Cstrong>J+14\u003C/strong> : toutes les pages sont re-crawlées. Les alertes tombent à 0.\u003C/li>\n\u003Cli>\u003Cstrong>J+21\u003C/strong> : le trafic organique revient au niveau pré-incident. La perte cumulée est estimée à ~45K clics sur la période, soit environ 12% du trafic mensuel.\u003C/li>\n\u003C/ul>\n\u003Cp>Les pages qui avaient perdu leur position 1 sur des requêtes longue traîne (« canapé velours bleu 3 places livraison gratuite ») ont mis jusqu'à 18 jours pour retrouver leur rang. Les pages à fort Domain Authority (catégories principales) ont récupéré en 4-5 jours.\u003C/p>\n\u003Cp>L'impact aurait été pire sur un site plus récent ou avec moins d'autorité. Et il aurait été détecté plus tard sans la vérification systématique du lundi matin dans Search Console. D'autres migrations silencieuses — comme celle documentée sur \u003Ca href=\"/blog/migration-nuxt-2-vers-nuxt-3-200-pages-en-fallback-layout-pendant-6-semaines\">Nuxt 2 vers Nuxt 3 avec 200 pages en fallback layout\u003C/a> — ont mis six semaines avant d'être repérées.\u003C/p>\n\u003Ch2>Ce qu'on en retient\u003C/h2>\n\u003Cp>La migration React 17 → 18 ne casse rien visuellement. Le navigateur affiche le bon titre, les bonnes metas. C'est ce qui rend le bug invisible aux yeux de toute l'équipe — devs, QA, product.\u003C/p>\n\u003Cp>Le danger est dans l'écart entre le DOM hydraté et le HTML initial. Cet écart n'existe pas en React 17. Il apparaît dès qu'un \u003Ccode>&#x3C;Suspense>\u003C/code> boundary englobe un composant qui appelle \u003Ccode>next/head\u003C/code>. Et aucun outil de test front standard ne le détecte par défaut.\u003C/p>\n\u003Cp>Trois règles à appliquer après une migration React 18 avec streaming :\u003C/p>\n\u003Col>\n\u003Cli>\u003Cstrong>Ne jamais wrapper un composant contenant \u003Ccode>&#x3C;Head>\u003C/code> dans un \u003Ccode>&#x3C;Suspense>\u003C/code> au niveau \u003Ccode>_app\u003C/code>.\u003C/strong>\u003C/li>\n\u003Cli>\u003Cstrong>Tester le HTML brut en CI\u003C/strong>, pas seulement le DOM post-hydratation.\u003C/li>\n\u003Cli>\u003Cstrong>Monitorer la divergence SSR/CSR en continu.\u003C/strong> Un outil comme Seogard compare automatiquement le HTML initial et le DOM rendu pour chaque page crawlée — ce type de régression est détecté en minutes, pas en semaines.\u003C/li>\n\u003C/ol>\n\u003Cp>Le streaming SSR est un vrai gain de performance. Mais les meta tags SEO doivent être dans le shell, jamais dans un chunk différé. C'est la règle non écrite de React 18 que la \u003Ca href=\"https://react.dev/reference/react-dom/server/renderToPipeableStream\">documentation officielle\u003C/a> ne mentionne qu'en passant.\u003C/p>\n\u003Cpre>\u003Ccode>\u003C/code>\u003C/pre>",null,12,[18,19,20,21,22],"react 18","suspense","ssr","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)",[26,42,55],{"_id":27,"slug":28,"__v":6,"author":7,"canonical":29,"category":10,"createdAt":30,"date":31,"description":32,"image":15,"imageAlt":15,"readingTime":33,"tags":34,"title":40,"updatedAt":41},"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.",11,[35,36,37,38,39],"vue 3","migration","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)",{"_id":43,"slug":44,"__v":6,"author":7,"canonical":45,"category":10,"createdAt":46,"date":31,"description":47,"image":15,"imageAlt":15,"readingTime":16,"tags":48,"title":53,"updatedAt":54},"6a1312d1aa6b273b0cadb52b","migration-next-js-pages-router-vers-app-router-les-metadata-ignorees-sur-les-pages-client","https://seogard.io/blog/migration-next-js-pages-router-vers-app-router-les-metadata-ignorees-sur-les-pages-client","2026-05-24T15:01:37.060Z","Migration Next.js Pages Router vers App Router : les metadata disparaissent sur les composants 'use client'. Récit d'incident, diagnostic et fix complet.",[49,50,51,52,36],"next.js","app router","metadata","use client","Next.js App Router : metadata ignorées sur les pages client","Sun May 24 2026 15:01:37 GMT+0000 (Coordinated Universal Time)",{"_id":56,"slug":57,"__v":6,"author":7,"canonical":58,"category":10,"createdAt":59,"date":31,"description":60,"image":15,"imageAlt":15,"readingTime":16,"tags":61,"title":65,"updatedAt":66},"6a133d06aa6b273b0cd08cc0","migration-nuxt-2-vers-nuxt-3-200-pages-en-fallback-layout-pendant-6-semaines","https://seogard.io/blog/migration-nuxt-2-vers-nuxt-3-200-pages-en-fallback-layout-pendant-6-semaines","2026-05-24T18:01:42.250Z","Récit d'une migration Nuxt 2 → Nuxt 3 où head() disparaît, 200 pages servent un title générique, et le trafic chute de 38%. Diagnostic et fix.",[62,36,63,64],"nuxt 3","head","seo regression","Nuxt 2 vers Nuxt 3 : 200 pages SEO cassées 6 semaines","Sun May 24 2026 18:01:42 GMT+0000 (Coordinated Universal Time)"]