[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"$fuIMmPOB2YY_5qgqSYVTnNiaGsmqIqP84rMMtI40PDnU":3,"$fqer0jyiDUNarzKgktgF_vgIYvPj6VDMHVHaZexoPgI0":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},"6a1312d1aa6b273b0cadb52b","migration-next-js-pages-router-vers-app-router-les-metadata-ignorees-sur-les-pages-client",0,"Equipe Seogard","# Migration Next.js App Router : 1 200 pages sans metadata pendant 19 jours\n\nJeudi 6 mars, 16h40. L'équipe frontend d'une marketplace française de mobilier (14 000 pages, 380K visites organiques mensuelles) déploie la dernière tranche de sa migration Next.js 13 Pages Router vers Next.js 14 App Router. Le ticket Jira passe en \"Done\". Les smoke tests sont verts. Dans le navigateur, chaque page affiche le bon `\u003Ctitle>` et la bonne `\u003Cmeta name=\"description\">`. Personne ne regarde le HTML source. Le week-end passe. Puis trois semaines.\n\n## Lundi 24 mars, 9h12 — L'alerte Search Console\n\nLe Lead SEO ouvre son rapport hebdomadaire Search Console. La courbe des clics sur les pages catégories et produits s'est affaissée. Pas un effondrement brutal — une glissade lente, régulière, qui commence pile le 7 mars. Moins 31% de clics sur les requêtes transactionnelles en 18 jours. Environ 118K clics perdus.\n\nPremier réflexe : vérifier les Core Web Vitals. Tout est stable. Deuxième réflexe : chercher un changement d'algorithme. Le [core update de mai 2026](/blog/google-may-2026-core-update-rolling-out-now) n'a pas encore commencé à cette date. Troisième réflexe : regarder les pages impactées une par une dans le rapport \"Performances\".\n\nLe pattern saute aux yeux. Les pages dont le CTR a chuté sont exactement celles migrées vers l'App Router le 6 mars. 1 247 URLs. Toutes les fiches produit et les pages catégories avec filtres dynamiques.\n\nLe Lead SEO ouvre l'une de ces pages dans Chrome, fait un clic droit, \"Afficher le code source\". Le `\u003Ctitle>` affiché dans l'onglet du navigateur est correct : \"Canapé convertible 3 places — NomDuSite\". Mais dans le HTML source brut servi par le serveur, c'est autre chose :\n\n```html\n\u003C!DOCTYPE html>\n\u003Chtml lang=\"fr\">\n\u003Chead>\n\u003Cmeta charSet=\"utf-8\"/>\n\u003Cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1\"/>\n\u003Clink rel=\"stylesheet\" href=\"/_next/static/css/app.css\"/>\n\u003C/head>\n\u003Cbody>\n\u003C!-- ... -->\n```\n\nPas de `\u003Ctitle>`. Pas de `\u003Cmeta name=\"description\">`. Pas de `\u003Cmeta property=\"og:title\">`. Rien.\n\nIl lance un crawl Screaming Frog sur les 1 247 URLs. Résultat : 1 203 pages renvoient un `\u003Ctitle>` vide. 44 pages ont conservé leurs metadata — ce sont celles qui n'avaient pas été migrées vers l'App Router.\n\nLe Slack explose à 10h15. Le CTO est loopé. L'hypothèse initiale du frontend lead : \"un problème de cache CDN qui sert une ancienne version\". L'équipe purge le cache Vercel. Re-crawl Screaming Frog. Toujours vide. Ce n'est pas le cache.\n\nLe diagnostic prend encore deux heures pour converger vers la vraie cause. Deux heures pendant lesquelles quelqu'un suggère un bug de Next.js 14, un autre pointe le middleware, un troisième accuse Vercel. La réponse est plus simple — et plus douloureuse.\n\n## Le bug : `export const metadata` ignoré dans les composants \"use client\"\n\nLe système de metadata de Next.js App Router repose sur un export statique dans les fichiers `page.tsx` ou `layout.tsx` :\n\n```typescript\n// app/products/[slug]/page.tsx — ce que l'équipe pensait avoir écrit\n\nexport const metadata = {\n  title: \"Canapé convertible 3 places — NomDuSite\",\n  description: \"Découvrez notre canapé convertible 3 places en tissu...\",\n  openGraph: {\n    title: \"Canapé convertible 3 places\",\n    images: [\"/images/canape-3p.jpg\"],\n  },\n};\n\nexport default function ProductPage({ params }) {\n  // ...\n}\n```\n\nCet export fonctionne parfaitement quand le fichier est un Server Component — le comportement par défaut dans l'App Router. Next.js lit `metadata` au moment du rendu serveur, l'injecte dans le `\u003Chead>` HTML, et le navigateur (et Googlebot) le reçoit tel quel.\n\nMais voici ce que les fichiers contenaient réellement après migration :\n\n```typescript\n// app/products/[slug]/page.tsx — la version déployée\n\n\"use client\";\n\nimport { useState, useEffect } from \"react\";\nimport { useCart } from \"@/hooks/useCart\";\nimport { ProductGallery } from \"@/components/ProductGallery\";\n\nexport const metadata = {\n  title: \"Canapé convertible 3 places — NomDuSite\",\n  description: \"Découvrez notre canapé convertible 3 places en tissu...\",\n};\n\nexport default function ProductPage({ params }) {\n  const [product, setProduct] = useState(null);\n  const { addToCart } = useCart();\n\n  useEffect(() => {\n    fetch(`/api/products/${params.slug}`).then(/* ... */);\n  }, [params.slug]);\n\n  // ...\n}\n```\n\nLa directive `\"use client\"` en haut du fichier transforme le composant en Client Component. Et dans un Client Component, **Next.js ignore silencieusement l'export `metadata`**. Pas d'erreur. Pas de warning dans la console. Pas de message dans le build log. L'export existe dans le code, il est syntaxiquement valide, mais le framework ne le consomme jamais.\n\nC'est documenté dans la [documentation officielle de Next.js sur les metadata](https://nextjs.org/docs/app/building-your-application/optimizing/metadata) — une ligne, facile à rater : *\"metadata can only be exported from Server Components.\"*\n\n### Pourquoi l'équipe a ajouté `\"use client\"` partout\n\nLa raison est classique. Les pages produit utilisent :\n\n- `useState` pour gérer l'état du panier côté client\n- `useEffect` pour du lazy loading d'avis clients\n- `useCart`, un hook custom basé sur `useContext`\n- Un composant galerie avec gestion de swipe (`onTouchStart`, `onTouchEnd`)\n\nEn Pages Router, tout cela fonctionnait dans un composant React standard. En App Router, tout composant utilisant des hooks React côté client ou des event handlers doit porter la directive `\"use client\"`. L'équipe a donc ajouté la directive en haut de chaque `page.tsx` migré.\n\nLe problème : en faisant ça, ils ont aussi rendu l'export `metadata` invisible pour le serveur.\n\n### Ce que voit le développeur vs ce que voit Googlebot\n\nDans le navigateur, le `\u003Ctitle>` apparaît correctement dans l'onglet. Pourquoi ? Parce que l'application Next.js, une fois hydratée côté client, exécute du JavaScript qui modifie le DOM — y compris le `\u003Chead>`. Le développeur qui inspecte avec les DevTools de Chrome voit le DOM final, après hydratation. Tout semble correct.\n\nMais Googlebot, dans sa phase de crawl initiale, reçoit le HTML brut du serveur. Et ce HTML brut ne contient aucune balise `\u003Ctitle>` ni `\u003Cmeta name=\"description\">`. Googlebot peut exécuter du JavaScript (il utilise une version de Chromium), mais avec un délai — parfois plusieurs jours — et le rendering n'est pas garanti pour chaque crawl.\n\nPour reproduire exactement ce que reçoit Googlebot en première instance :\n\n```bash\ncurl -s https://www.exemple.com/products/canape-convertible-3p \\\n  -H \"User-Agent: Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)\" \\\n  | grep -i \"\u003Ctitle>\\|\u003Cmeta name=\\\"description\\\"\"\n```\n\nRésultat sur les pages buggées : aucune ligne en sortie. Zéro. Le `grep` ne renvoie rien.\n\nSur une page non migrée (restée en Pages Router avec `next/head`) :\n\n```bash\ncurl -s https://www.exemple.com/inspirations/salon-scandinave \\\n  -H \"User-Agent: Googlebot\" \\\n  | grep -i \"\u003Ctitle>\"\n```\n\n```html\n\u003Ctitle>Salon scandinave : 15 idées déco — NomDuSite\u003C/title>\n```\n\n### Pourquoi les tests n'ont rien détecté\n\nL'équipe avait une suite de tests Playwright qui vérifiait les titres de page :\n\n```typescript\n// tests/product-page.spec.ts\ntest(\"product page has correct title\", async ({ page }) => {\n  await page.goto(\"/products/canape-convertible-3p\");\n  await expect(page).toHaveTitle(/Canapé convertible 3 places/);\n});\n```\n\nCe test passe. Playwright lance un vrai navigateur Chromium, attend le chargement complet, et vérifie le `\u003Ctitle>` du DOM final — après hydratation JavaScript. Exactement comme un humain dans Chrome. Le test ne vérifie jamais le HTML initial servi par le serveur.\n\nAucun test de la CI ne faisait un `fetch` HTTP brut pour inspecter la réponse HTML avant exécution JavaScript. C'est la faille.\n\nL'inspecteur d'URL de Search Console aurait pu révéler le problème — il affiche le HTML tel que Googlebot le voit après rendering. Mais personne ne l'a utilisé entre le déploiement et l'alerte, 19 jours plus tard.\n\n## Le fix : séparer Server Component et Client Component\n\nLa solution architecturale est celle recommandée par la documentation Next.js : ne jamais mettre `\"use client\"` directement dans le fichier `page.tsx`. Le page component reste un Server Component. La logique interactive est extraite dans un composant enfant marqué `\"use client\"`.\n\n### Étape 1 — Extraire la logique client\n\nCréer un composant client dédié :\n\n```typescript\n// app/products/[slug]/ProductPageClient.tsx\n\"use client\";\n\nimport { useState, useEffect } from \"react\";\nimport { useCart } from \"@/hooks/useCart\";\nimport { ProductGallery } from \"@/components/ProductGallery\";\n\ninterface ProductPageClientProps {\n  initialProduct: {\n    name: string;\n    description: string;\n    images: string[];\n    price: number;\n  };\n}\n\nexport default function ProductPageClient({ initialProduct }: ProductPageClientProps) {\n  const { addToCart } = useCart();\n  const [reviews, setReviews] = useState([]);\n\n  useEffect(() => {\n    fetch(`/api/reviews/${initialProduct.name}`)\n      .then((res) => res.json())\n      .then(setReviews);\n  }, [initialProduct.name]);\n\n  return (\n    \u003Cmain>\n      \u003CProductGallery images={initialProduct.images} />\n      \u003Ch1>{initialProduct.name}\u003C/h1>\n      \u003Cp>{initialProduct.description}\u003C/p>\n      \u003Cbutton onClick={() => addToCart(initialProduct)}>\n        Ajouter au panier — {initialProduct.price} €\n      \u003C/button>\n      {/* reviews section */}\n    \u003C/main>\n  );\n}\n```\n\n### Étape 2 — Page Server Component avec metadata\n\nLe fichier `page.tsx` redevient un Server Component pur. Les metadata sont exportées — soit statiquement, soit dynamiquement via `generateMetadata` :\n\n```typescript\n// app/products/[slug]/page.tsx\nimport { Metadata } from \"next\";\nimport { getProduct } from \"@/lib/api\";\nimport ProductPageClient from \"./ProductPageClient\";\n\nexport async function generateMetadata({\n  params,\n}: {\n  params: { slug: string };\n}): Promise\u003CMetadata> {\n  const product = await getProduct(params.slug);\n\n  return {\n    title: `${product.name} — NomDuSite`,\n    description: product.shortDescription,\n    openGraph: {\n      title: product.name,\n      description: product.shortDescription,\n      images: [product.images[0]],\n    },\n    alternates: {\n      canonical: `https://www.exemple.com/products/${params.slug}`,\n    },\n  };\n}\n\nexport default async function ProductPage({\n  params,\n}: {\n  params: { slug: string };\n}) {\n  const product = await getProduct(params.slug);\n\n  return \u003CProductPageClient initialProduct={product} />;\n}\n```\n\nLe `\u003Ctitle>` et les `\u003Cmeta>` sont maintenant injectés dans le HTML initial par le serveur. Le composant client gère l'interactivité. Les deux coexistent sans conflit.\n\n### Étape 3 — Vérification avant déploiement\n\nAvant de push en production, l'équipe a ajouté un test qui vérifie le HTML brut :\n\n```typescript\n// tests/seo-metadata.spec.ts\nimport { test, expect } from \"@playwright/test\";\n\ntest(\"product page SSR contains metadata\", async ({ request }) => {\n  const response = await request.get(\"/products/canape-convertible-3p\");\n  const html = await response.text();\n\n  expect(html).toContain(\"\u003Ctitle>\");\n  expect(html).toMatch(/\u003Cmeta name=\"description\" content=\".+\"/);\n  expect(html).toMatch(/\u003Cmeta property=\"og:title\" content=\".+\"/);\n});\n```\n\nCe test utilise `request.get` de Playwright — un appel HTTP brut, sans navigateur, sans exécution JavaScript. Il vérifie le HTML tel qu'un crawler le reçoit.\n\n### Étape 4 — Déploiement et invalidation\n\nLe fix a été déployé sur Vercel un mercredi à 11h. L'équipe a :\n\n1. Purgé le cache CDN Vercel via le dashboard\n2. Soumis les 1 203 URLs impactées à l'indexation via l'API Indexing de Search Console (par lots de 200)\n3. Forcé un recrawl du sitemap en le re-soumettant dans Search Console\n\n### Temps de récupération\n\nLes premiers résultats sont revenus en 4 jours. Le `\u003Ctitle>` correct réapparaissait dans les SERPs pour les pages les plus crawlées. La récupération complète du trafic a pris 16 jours. Au total, l'équipe estime la perte à environ 200K clics sur la période cumulée (19 jours d'incident + 16 jours de récupération).\n\nLe CTR moyen des pages produit est remonté de 1.8% (pendant l'incident — Google générait ses propres titres à partir du contenu de la page) à 3.4% (niveau pré-incident). La différence de CTR explique à elle seule la majorité de la perte de clics : les titres auto-générés par Google étaient souvent tronqués ou non pertinents.\n\n### Mesures préventives mises en place\n\nL'équipe a ajouté trois garde-fous :\n\n1. **Lint rule ESLint custom** qui interdit `export const metadata` ou `export async function generateMetadata` dans tout fichier contenant `\"use client\"`. Le build échoue si la règle est violée.\n\n2. **Test CI systématique** : un script crawle 50 URLs échantillonnées en staging après chaque déploiement, vérifie la présence de `\u003Ctitle>`, `\u003Cmeta name=\"description\">`, et `\u003Clink rel=\"canonical\">` dans le HTML brut.\n\n3. **Alerte Search Console** : un check quotidien automatisé via l'API Search Console qui compare le nombre d'impressions J-1 vs J-8. Si la baisse dépasse 15% sur un segment d'URLs, une alerte Slack est envoyée.\n\nCe type de régression silencieuse — où l'interface visuelle fonctionne parfaitement mais le HTML servi au crawler est incomplet — est un classique des migrations de framework frontend. L'équipe qui a vécu l'[incident Vue 2 vers Vue 3](/blog/migration-vue-2-vers-vue-3-47-pages-produit-en-panne-seo-en-silence) a rencontré un pattern quasi identique : le navigateur compense, le crawler non.\n\n## Ce qu'on en retient\n\nTrois règles pour toute migration vers Next.js App Router :\n\n**Un.** Le fichier `page.tsx` ne porte jamais `\"use client\"`. L'interactivité descend dans des composants enfants. C'est un choix architectural, pas une préférence de style.\n\n**Deux.** Les tests SEO doivent vérifier le HTML brut, pas le DOM après hydratation. Un `curl` vaut mieux qu'un test Playwright classique pour détecter ce type de divergence.\n\n**Trois.** Le délai entre un déploiement cassé et sa détection en Search Console est mesuré en semaines, pas en heures. Un monitoring continu type Seogard détecte cette divergence SSR/CSR en quelques minutes — pas 19 jours et 200K clics plus tard.\n\nLa migration App Router apporte des gains réels (streaming, Server Components, meilleure colocation des data). Mais chaque `\"use client\"` placé trop haut dans l'arbre de composants est une bombe à retardement SEO. Il faut la désamorcer avant le déploiement, pas après le rapport Search Console du lundi matin.\n```","https://seogard.io/blog/migration-next-js-pages-router-vers-app-router-les-metadata-ignorees-sur-les-pages-client","Migration","2026-05-24T15:01:37.060Z","2026-05-24","Migration Next.js Pages Router vers App Router : les metadata disparaissent sur les composants 'use client'. Récit d'incident, diagnostic et fix complet.","\u003Ch1>Migration Next.js App Router : 1 200 pages sans metadata pendant 19 jours\u003C/h1>\n\u003Cp>Jeudi 6 mars, 16h40. L'équipe frontend d'une marketplace française de mobilier (14 000 pages, 380K visites organiques mensuelles) déploie la dernière tranche de sa migration Next.js 13 Pages Router vers Next.js 14 App Router. Le ticket Jira passe en \"Done\". Les smoke tests sont verts. Dans le navigateur, chaque page affiche le bon \u003Ccode>&#x3C;title>\u003C/code> et la bonne \u003Ccode>&#x3C;meta name=\"description\">\u003C/code>. Personne ne regarde le HTML source. Le week-end passe. Puis trois semaines.\u003C/p>\n\u003Ch2>Lundi 24 mars, 9h12 — L'alerte Search Console\u003C/h2>\n\u003Cp>Le Lead SEO ouvre son rapport hebdomadaire Search Console. La courbe des clics sur les pages catégories et produits s'est affaissée. Pas un effondrement brutal — une glissade lente, régulière, qui commence pile le 7 mars. Moins 31% de clics sur les requêtes transactionnelles en 18 jours. Environ 118K clics perdus.\u003C/p>\n\u003Cp>Premier réflexe : vérifier les Core Web Vitals. Tout est stable. Deuxième réflexe : chercher un changement d'algorithme. Le \u003Ca href=\"/blog/google-may-2026-core-update-rolling-out-now\">core update de mai 2026\u003C/a> n'a pas encore commencé à cette date. Troisième réflexe : regarder les pages impactées une par une dans le rapport \"Performances\".\u003C/p>\n\u003Cp>Le pattern saute aux yeux. Les pages dont le CTR a chuté sont exactement celles migrées vers l'App Router le 6 mars. 1 247 URLs. Toutes les fiches produit et les pages catégories avec filtres dynamiques.\u003C/p>\n\u003Cp>Le Lead SEO ouvre l'une de ces pages dans Chrome, fait un clic droit, \"Afficher le code source\". Le \u003Ccode>&#x3C;title>\u003C/code> affiché dans l'onglet du navigateur est correct : \"Canapé convertible 3 places — NomDuSite\". Mais dans le HTML source brut servi par le serveur, c'est autre chose :\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\">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, initial-scale=1\"\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\">\"/_next/static/css/app.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:#6A737D\">&#x3C;!-- ... -->\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Pas de \u003Ccode>&#x3C;title>\u003C/code>. Pas de \u003Ccode>&#x3C;meta name=\"description\">\u003C/code>. Pas de \u003Ccode>&#x3C;meta property=\"og:title\">\u003C/code>. Rien.\u003C/p>\n\u003Cp>Il lance un crawl Screaming Frog sur les 1 247 URLs. Résultat : 1 203 pages renvoient un \u003Ccode>&#x3C;title>\u003C/code> vide. 44 pages ont conservé leurs metadata — ce sont celles qui n'avaient pas été migrées vers l'App Router.\u003C/p>\n\u003Cp>Le Slack explose à 10h15. Le CTO est loopé. L'hypothèse initiale du frontend lead : \"un problème de cache CDN qui sert une ancienne version\". L'équipe purge le cache Vercel. Re-crawl Screaming Frog. Toujours vide. Ce n'est pas le cache.\u003C/p>\n\u003Cp>Le diagnostic prend encore deux heures pour converger vers la vraie cause. Deux heures pendant lesquelles quelqu'un suggère un bug de Next.js 14, un autre pointe le middleware, un troisième accuse Vercel. La réponse est plus simple — et plus douloureuse.\u003C/p>\n\u003Ch2>Le bug : \u003Ccode>export const metadata\u003C/code> ignoré dans les composants \"use client\"\u003C/h2>\n\u003Cp>Le système de metadata de Next.js App Router repose sur un export statique dans les fichiers \u003Ccode>page.tsx\u003C/code> ou \u003Ccode>layout.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\">// app/products/[slug]/page.tsx — ce que l'équipe pensait avoir écrit\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\"> metadata\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  title: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"Canapé convertible 3 places — NomDuSite\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  description: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"Découvrez notre canapé convertible 3 places en tissu...\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  openGraph: {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    title: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"Canapé convertible 3 places\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    images: [\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"/images/canape-3p.jpg\"\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\"> 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\">params\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> }) {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">  // ...\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">}\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Cet export fonctionne parfaitement quand le fichier est un Server Component — le comportement par défaut dans l'App Router. Next.js lit \u003Ccode>metadata\u003C/code> au moment du rendu serveur, l'injecte dans le \u003Ccode>&#x3C;head>\u003C/code> HTML, et le navigateur (et Googlebot) le reçoit tel quel.\u003C/p>\n\u003Cp>Mais voici ce que les fichiers contenaient réellement après migration :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">// app/products/[slug]/page.tsx — la version déployée\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">\"use client\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { useState, useEffect } \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\"> { useCart } \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> \"@/hooks/useCart\"\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\"> { ProductGallery } \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> \"@/components/ProductGallery\"\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\"> metadata\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  title: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"Canapé convertible 3 places — NomDuSite\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  description: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"Découvrez notre canapé convertible 3 places en tissu...\"\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\">\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\">params\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> }) {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  const\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> [\u003C/span>\u003Cspan style=\"color:#79B8FF\">product\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#79B8FF\">setProduct\u003C/span>\u003Cspan style=\"color:#E1E4E8\">] \u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#B392F0\"> useState\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#79B8FF\">null\u003C/span>\u003Cspan style=\"color:#E1E4E8\">);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  const\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { \u003C/span>\u003Cspan style=\"color:#79B8FF\">addToCart\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> } \u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#B392F0\"> useCart\u003C/span>\u003Cspan style=\"color:#E1E4E8\">();\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">  useEffect\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:#B392F0\">    fetch\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">`/api/products/${\u003C/span>\u003Cspan style=\"color:#E1E4E8\">params\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>\u003Cspan style=\"color:#B392F0\">then\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#6A737D\">/* ... */\u003C/span>\u003Cspan style=\"color:#E1E4E8\">);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  }, [params.slug]);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">  // ...\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">}\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>La directive \u003Ccode>\"use client\"\u003C/code> en haut du fichier transforme le composant en Client Component. Et dans un Client Component, \u003Cstrong>Next.js ignore silencieusement l'export \u003Ccode>metadata\u003C/code>\u003C/strong>. Pas d'erreur. Pas de warning dans la console. Pas de message dans le build log. L'export existe dans le code, il est syntaxiquement valide, mais le framework ne le consomme jamais.\u003C/p>\n\u003Cp>C'est documenté dans la \u003Ca href=\"https://nextjs.org/docs/app/building-your-application/optimizing/metadata\">documentation officielle de Next.js sur les metadata\u003C/a> — une ligne, facile à rater : \u003Cem>\"metadata can only be exported from Server Components.\"\u003C/em>\u003C/p>\n\u003Ch3>Pourquoi l'équipe a ajouté \u003Ccode>\"use client\"\u003C/code> partout\u003C/h3>\n\u003Cp>La raison est classique. Les pages produit utilisent :\u003C/p>\n\u003Cul>\n\u003Cli>\u003Ccode>useState\u003C/code> pour gérer l'état du panier côté client\u003C/li>\n\u003Cli>\u003Ccode>useEffect\u003C/code> pour du lazy loading d'avis clients\u003C/li>\n\u003Cli>\u003Ccode>useCart\u003C/code>, un hook custom basé sur \u003Ccode>useContext\u003C/code>\u003C/li>\n\u003Cli>Un composant galerie avec gestion de swipe (\u003Ccode>onTouchStart\u003C/code>, \u003Ccode>onTouchEnd\u003C/code>)\u003C/li>\n\u003C/ul>\n\u003Cp>En Pages Router, tout cela fonctionnait dans un composant React standard. En App Router, tout composant utilisant des hooks React côté client ou des event handlers doit porter la directive \u003Ccode>\"use client\"\u003C/code>. L'équipe a donc ajouté la directive en haut de chaque \u003Ccode>page.tsx\u003C/code> migré.\u003C/p>\n\u003Cp>Le problème : en faisant ça, ils ont aussi rendu l'export \u003Ccode>metadata\u003C/code> invisible pour le serveur.\u003C/p>\n\u003Ch3>Ce que voit le développeur vs ce que voit Googlebot\u003C/h3>\n\u003Cp>Dans le navigateur, le \u003Ccode>&#x3C;title>\u003C/code> apparaît correctement dans l'onglet. Pourquoi ? Parce que l'application Next.js, une fois hydratée côté client, exécute du JavaScript qui modifie le DOM — y compris le \u003Ccode>&#x3C;head>\u003C/code>. Le développeur qui inspecte avec les DevTools de Chrome voit le DOM final, après hydratation. Tout semble correct.\u003C/p>\n\u003Cp>Mais Googlebot, dans sa phase de crawl initiale, reçoit le HTML brut du serveur. Et ce HTML brut ne contient aucune balise \u003Ccode>&#x3C;title>\u003C/code> ni \u003Ccode>&#x3C;meta name=\"description\">\u003C/code>. Googlebot peut exécuter du JavaScript (il utilise une version de Chromium), mais avec un délai — parfois plusieurs jours — et le rendering n'est pas garanti pour chaque crawl.\u003C/p>\n\u003Cp>Pour reproduire exactement ce que reçoit Googlebot en première instance :\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.exemple.com/products/canape-convertible-3p\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\"> \"User-Agent: Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)\"\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\"> -i\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> \"&#x3C;title>\\|&#x3C;meta name=\u003C/span>\u003Cspan style=\"color:#79B8FF\">\\\"\u003C/span>\u003Cspan style=\"color:#9ECBFF\">description\u003C/span>\u003Cspan style=\"color:#79B8FF\">\\\"\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Résultat sur les pages buggées : aucune ligne en sortie. Zéro. Le \u003Ccode>grep\u003C/code> ne renvoie rien.\u003C/p>\n\u003Cp>Sur une page non migrée (restée en Pages Router avec \u003Ccode>next/head\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.exemple.com/inspirations/salon-scandinave\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\"> \"User-Agent: Googlebot\"\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\"> -i\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> \"&#x3C;title>\"\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\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\">title\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>Salon scandinave : 15 idées déco — NomDuSite&#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">title\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Ch3>Pourquoi les tests n'ont rien détecté\u003C/h3>\n\u003Cp>L'équipe avait une suite de tests Playwright qui vérifiait les titres de page :\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/product-page.spec.ts\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">test\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"product page has correct title\"\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\">page\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\">  await\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> page.\u003C/span>\u003Cspan style=\"color:#B392F0\">goto\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"/products/canape-convertible-3p\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  await\u003C/span>\u003Cspan style=\"color:#B392F0\"> expect\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(page).\u003C/span>\u003Cspan style=\"color:#B392F0\">toHaveTitle\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">/\u003C/span>\u003Cspan style=\"color:#DBEDFF\">Canapé convertible 3 places\u003C/span>\u003Cspan style=\"color:#9ECBFF\">/\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 passe. Playwright lance un vrai navigateur Chromium, attend le chargement complet, et vérifie le \u003Ccode>&#x3C;title>\u003C/code> du DOM final — après hydratation JavaScript. Exactement comme un humain dans Chrome. Le test ne vérifie jamais le HTML initial servi par le serveur.\u003C/p>\n\u003Cp>Aucun test de la CI ne faisait un \u003Ccode>fetch\u003C/code> HTTP brut pour inspecter la réponse HTML avant exécution JavaScript. C'est la faille.\u003C/p>\n\u003Cp>L'inspecteur d'URL de Search Console aurait pu révéler le problème — il affiche le HTML tel que Googlebot le voit après rendering. Mais personne ne l'a utilisé entre le déploiement et l'alerte, 19 jours plus tard.\u003C/p>\n\u003Ch2>Le fix : séparer Server Component et Client Component\u003C/h2>\n\u003Cp>La solution architecturale est celle recommandée par la documentation Next.js : ne jamais mettre \u003Ccode>\"use client\"\u003C/code> directement dans le fichier \u003Ccode>page.tsx\u003C/code>. Le page component reste un Server Component. La logique interactive est extraite dans un composant enfant marqué \u003Ccode>\"use client\"\u003C/code>.\u003C/p>\n\u003Ch3>Étape 1 — Extraire la logique client\u003C/h3>\n\u003Cp>Créer un composant client dédié :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">// app/products/[slug]/ProductPageClient.tsx\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">\"use client\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { useState, useEffect } \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\"> { useCart } \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> \"@/hooks/useCart\"\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\"> { ProductGallery } \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> \"@/components/ProductGallery\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">interface\u003C/span>\u003Cspan style=\"color:#B392F0\"> ProductPageClientProps\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FFAB70\">  initialProduct\u003C/span>\u003Cspan style=\"color:#F97583\">:\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FFAB70\">    name\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\">\u003Cspan style=\"color:#FFAB70\">    description\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\">\u003Cspan style=\"color:#FFAB70\">    images\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\">\u003Cspan style=\"color:#FFAB70\">    price\u003C/span>\u003Cspan style=\"color:#F97583\">:\u003C/span>\u003Cspan style=\"color:#79B8FF\"> number\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\"> default\u003C/span>\u003Cspan style=\"color:#F97583\"> function\u003C/span>\u003Cspan style=\"color:#B392F0\"> ProductPageClient\u003C/span>\u003Cspan style=\"color:#E1E4E8\">({ \u003C/span>\u003Cspan style=\"color:#FFAB70\">initialProduct\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> }\u003C/span>\u003Cspan style=\"color:#F97583\">:\u003C/span>\u003Cspan style=\"color:#B392F0\"> ProductPageClientProps\u003C/span>\u003Cspan style=\"color:#E1E4E8\">) {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  const\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { \u003C/span>\u003Cspan style=\"color:#79B8FF\">addToCart\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> } \u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#B392F0\"> useCart\u003C/span>\u003Cspan style=\"color:#E1E4E8\">();\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  const\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> [\u003C/span>\u003Cspan style=\"color:#79B8FF\">reviews\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#79B8FF\">setReviews\u003C/span>\u003Cspan style=\"color:#E1E4E8\">] \u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#B392F0\"> useState\u003C/span>\u003Cspan style=\"color:#E1E4E8\">([]);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">  useEffect\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:#B392F0\">    fetch\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">`/api/reviews/${\u003C/span>\u003Cspan style=\"color:#E1E4E8\">initialProduct\u003C/span>\u003Cspan style=\"color:#9ECBFF\">.\u003C/span>\u003Cspan style=\"color:#E1E4E8\">name\u003C/span>\u003Cspan style=\"color:#9ECBFF\">}`\u003C/span>\u003Cspan style=\"color:#E1E4E8\">)\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      .\u003C/span>\u003Cspan style=\"color:#B392F0\">then\u003C/span>\u003Cspan style=\"color:#E1E4E8\">((\u003C/span>\u003Cspan style=\"color:#FFAB70\">res\u003C/span>\u003Cspan style=\"color:#E1E4E8\">) \u003C/span>\u003Cspan style=\"color:#F97583\">=>\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> res.\u003C/span>\u003Cspan style=\"color:#B392F0\">json\u003C/span>\u003Cspan style=\"color:#E1E4E8\">())\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      .\u003C/span>\u003Cspan style=\"color:#B392F0\">then\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(setReviews);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  }, [initialProduct.name]);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\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:#B392F0\">main\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">      &#x3C;\u003C/span>\u003Cspan style=\"color:#E1E4E8\">ProductGallery images\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#E1E4E8\">{initialProduct.images} \u003C/span>\u003Cspan style=\"color:#F97583\">/>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      &#x3C;\u003C/span>\u003Cspan style=\"color:#B392F0\">h1\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>{initialProduct.name}\u003C/span>\u003Cspan style=\"color:#F97583\">&#x3C;/\u003C/span>\u003Cspan style=\"color:#E1E4E8\">h1\u003C/span>\u003Cspan style=\"color:#F97583\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      &#x3C;\u003C/span>\u003Cspan style=\"color:#B392F0\">p\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>{initialProduct.description}\u003C/span>\u003Cspan style=\"color:#F97583\">&#x3C;/\u003C/span>\u003Cspan style=\"color:#E1E4E8\">p\u003C/span>\u003Cspan style=\"color:#F97583\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">      &#x3C;\u003C/span>\u003Cspan style=\"color:#E1E4E8\">button onClick\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#E1E4E8\">{() => \u003C/span>\u003Cspan style=\"color:#B392F0\">addToCart\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#FFAB70\">initialProduct\u003C/span>\u003Cspan style=\"color:#E1E4E8\">)}\u003C/span>\u003Cspan style=\"color:#F97583\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">        Ajouter au panier — {\u003C/span>\u003Cspan style=\"color:#FFAB70\">initialProduct\u003C/span>\u003Cspan style=\"color:#E1E4E8\">.\u003C/span>\u003Cspan style=\"color:#FFAB70\">price\u003C/span>\u003Cspan style=\"color:#E1E4E8\">} €\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">      &#x3C;/\u003C/span>\u003Cspan style=\"color:#E1E4E8\">button\u003C/span>\u003Cspan style=\"color:#F97583\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      {\u003C/span>\u003Cspan style=\"color:#6A737D\">/* reviews section */\u003C/span>\u003Cspan style=\"color:#E1E4E8\">}\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">    &#x3C;/\u003C/span>\u003Cspan style=\"color:#E1E4E8\">main\u003C/span>\u003Cspan style=\"color:#F97583\">>\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>Étape 2 — Page Server Component avec metadata\u003C/h3>\n\u003Cp>Le fichier \u003Ccode>page.tsx\u003C/code> redevient un Server Component pur. Les metadata sont exportées — soit statiquement, soit dynamiquement via \u003Ccode>generateMetadata\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\">// app/products/[slug]/page.tsx\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { Metadata } \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\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { getProduct } \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> \"@/lib/api\"\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\"> ProductPageClient \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> \"./ProductPageClient\"\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\"> async\u003C/span>\u003Cspan style=\"color:#F97583\"> function\u003C/span>\u003Cspan style=\"color:#B392F0\"> generateMetadata\u003C/span>\u003Cspan style=\"color:#E1E4E8\">({\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FFAB70\">  params\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\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:#FFAB70\">  params\u003C/span>\u003Cspan style=\"color:#F97583\">:\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { \u003C/span>\u003Cspan style=\"color:#FFAB70\">slug\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\">\u003Cspan style=\"color:#E1E4E8\">})\u003C/span>\u003Cspan style=\"color:#F97583\">:\u003C/span>\u003Cspan style=\"color:#B392F0\"> Promise\u003C/span>\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C/span>\u003Cspan style=\"color:#B392F0\">Metadata\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\"> product\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#F97583\"> await\u003C/span>\u003Cspan style=\"color:#B392F0\"> getProduct\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(params.slug);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\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\">    title: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">`${\u003C/span>\u003Cspan style=\"color:#E1E4E8\">product\u003C/span>\u003Cspan style=\"color:#9ECBFF\">.\u003C/span>\u003Cspan style=\"color:#E1E4E8\">name\u003C/span>\u003Cspan style=\"color:#9ECBFF\">} — NomDuSite`\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    description: product.shortDescription,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    openGraph: {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      title: product.name,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      description: product.shortDescription,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      images: [product.images[\u003C/span>\u003Cspan style=\"color:#79B8FF\">0\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\">    alternates: {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      canonical: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">`https://www.exemple.com/products/${\u003C/span>\u003Cspan style=\"color:#E1E4E8\">params\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\">    },\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\"> default\u003C/span>\u003Cspan style=\"color:#F97583\"> async\u003C/span>\u003Cspan style=\"color:#F97583\"> function\u003C/span>\u003Cspan style=\"color:#B392F0\"> ProductPage\u003C/span>\u003Cspan style=\"color:#E1E4E8\">({\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FFAB70\">  params\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\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:#FFAB70\">  params\u003C/span>\u003Cspan style=\"color:#F97583\">:\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { \u003C/span>\u003Cspan style=\"color:#FFAB70\">slug\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\">\u003Cspan style=\"color:#E1E4E8\">}) {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> product\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#F97583\"> await\u003C/span>\u003Cspan style=\"color:#B392F0\"> getProduct\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(params.slug);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  return\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> &#x3C;\u003C/span>\u003Cspan style=\"color:#B392F0\">ProductPageClient\u003C/span>\u003Cspan style=\"color:#B392F0\"> initialProduct\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:#E1E4E8\">}\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Le \u003Ccode>&#x3C;title>\u003C/code> et les \u003Ccode>&#x3C;meta>\u003C/code> sont maintenant injectés dans le HTML initial par le serveur. Le composant client gère l'interactivité. Les deux coexistent sans conflit.\u003C/p>\n\u003Ch3>Étape 3 — Vérification avant déploiement\u003C/h3>\n\u003Cp>Avant de push en production, l'équipe a ajouté un test qui vérifie le HTML brut :\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-metadata.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\">\"product page SSR contains metadata\"\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\">\"/products/canape-convertible-3p\"\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\"> response.\u003C/span>\u003Cspan style=\"color:#B392F0\">text\u003C/span>\u003Cspan style=\"color:#E1E4E8\">();\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\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\">toMatch\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">/\u003C/span>\u003Cspan style=\"color:#DBEDFF\">&#x3C;meta name=\"description\" content=\"\u003C/span>\u003Cspan style=\"color:#79B8FF\">.\u003C/span>\u003Cspan style=\"color:#F97583\">+\u003C/span>\u003Cspan style=\"color:#DBEDFF\">\"\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\">toMatch\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">/\u003C/span>\u003Cspan style=\"color:#DBEDFF\">&#x3C;meta property=\"og:title\" content=\"\u003C/span>\u003Cspan style=\"color:#79B8FF\">.\u003C/span>\u003Cspan style=\"color:#F97583\">+\u003C/span>\u003Cspan style=\"color:#DBEDFF\">\"\u003C/span>\u003Cspan style=\"color:#9ECBFF\">/\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 utilise \u003Ccode>request.get\u003C/code> de Playwright — un appel HTTP brut, sans navigateur, sans exécution JavaScript. Il vérifie le HTML tel qu'un crawler le reçoit.\u003C/p>\n\u003Ch3>Étape 4 — Déploiement et invalidation\u003C/h3>\n\u003Cp>Le fix a été déployé sur Vercel un mercredi à 11h. L'équipe a :\u003C/p>\n\u003Col>\n\u003Cli>Purgé le cache CDN Vercel via le dashboard\u003C/li>\n\u003Cli>Soumis les 1 203 URLs impactées à l'indexation via l'API Indexing de Search Console (par lots de 200)\u003C/li>\n\u003Cli>Forcé un recrawl du sitemap en le re-soumettant dans Search Console\u003C/li>\n\u003C/ol>\n\u003Ch3>Temps de récupération\u003C/h3>\n\u003Cp>Les premiers résultats sont revenus en 4 jours. Le \u003Ccode>&#x3C;title>\u003C/code> correct réapparaissait dans les SERPs pour les pages les plus crawlées. La récupération complète du trafic a pris 16 jours. Au total, l'équipe estime la perte à environ 200K clics sur la période cumulée (19 jours d'incident + 16 jours de récupération).\u003C/p>\n\u003Cp>Le CTR moyen des pages produit est remonté de 1.8% (pendant l'incident — Google générait ses propres titres à partir du contenu de la page) à 3.4% (niveau pré-incident). La différence de CTR explique à elle seule la majorité de la perte de clics : les titres auto-générés par Google étaient souvent tronqués ou non pertinents.\u003C/p>\n\u003Ch3>Mesures préventives mises en place\u003C/h3>\n\u003Cp>L'équipe a ajouté trois garde-fous :\u003C/p>\n\u003Col>\n\u003Cli>\n\u003Cp>\u003Cstrong>Lint rule ESLint custom\u003C/strong> qui interdit \u003Ccode>export const metadata\u003C/code> ou \u003Ccode>export async function generateMetadata\u003C/code> dans tout fichier contenant \u003Ccode>\"use client\"\u003C/code>. Le build échoue si la règle est violée.\u003C/p>\n\u003C/li>\n\u003Cli>\n\u003Cp>\u003Cstrong>Test CI systématique\u003C/strong> : un script crawle 50 URLs échantillonnées en staging après chaque déploiement, vérifie la présence de \u003Ccode>&#x3C;title>\u003C/code>, \u003Ccode>&#x3C;meta name=\"description\">\u003C/code>, et \u003Ccode>&#x3C;link rel=\"canonical\">\u003C/code> dans le HTML brut.\u003C/p>\n\u003C/li>\n\u003Cli>\n\u003Cp>\u003Cstrong>Alerte Search Console\u003C/strong> : un check quotidien automatisé via l'API Search Console qui compare le nombre d'impressions J-1 vs J-8. Si la baisse dépasse 15% sur un segment d'URLs, une alerte Slack est envoyée.\u003C/p>\n\u003C/li>\n\u003C/ol>\n\u003Cp>Ce type de régression silencieuse — où l'interface visuelle fonctionne parfaitement mais le HTML servi au crawler est incomplet — est un classique des migrations de framework frontend. L'équipe qui a vécu l'\u003Ca href=\"/blog/migration-vue-2-vers-vue-3-47-pages-produit-en-panne-seo-en-silence\">incident Vue 2 vers Vue 3\u003C/a> a rencontré un pattern quasi identique : le navigateur compense, le crawler non.\u003C/p>\n\u003Ch2>Ce qu'on en retient\u003C/h2>\n\u003Cp>Trois règles pour toute migration vers Next.js App Router :\u003C/p>\n\u003Cp>\u003Cstrong>Un.\u003C/strong> Le fichier \u003Ccode>page.tsx\u003C/code> ne porte jamais \u003Ccode>\"use client\"\u003C/code>. L'interactivité descend dans des composants enfants. C'est un choix architectural, pas une préférence de style.\u003C/p>\n\u003Cp>\u003Cstrong>Deux.\u003C/strong> Les tests SEO doivent vérifier le HTML brut, pas le DOM après hydratation. Un \u003Ccode>curl\u003C/code> vaut mieux qu'un test Playwright classique pour détecter ce type de divergence.\u003C/p>\n\u003Cp>\u003Cstrong>Trois.\u003C/strong> Le délai entre un déploiement cassé et sa détection en Search Console est mesuré en semaines, pas en heures. Un monitoring continu type Seogard détecte cette divergence SSR/CSR en quelques minutes — pas 19 jours et 200K clics plus tard.\u003C/p>\n\u003Cp>La migration App Router apporte des gains réels (streaming, Server Components, meilleure colocation des data). Mais chaque \u003Ccode>\"use client\"\u003C/code> placé trop haut dans l'arbre de composants est une bombe à retardement SEO. Il faut la désamorcer avant le déploiement, pas après le rapport Search Console du lundi matin.\u003C/p>\n\u003Cpre>\u003Ccode>\u003C/code>\u003C/pre>",null,12,[18,19,20,21,22],"next.js","app router","metadata","use client","migration","Next.js App Router : metadata ignorées sur les pages client","Sun May 24 2026 15:01:37 GMT+0000 (Coordinated Universal Time)",[26,40,53],{"_id":27,"slug":28,"__v":6,"author":7,"canonical":29,"category":10,"createdAt":30,"date":12,"description":31,"image":15,"imageAlt":15,"readingTime":32,"tags":33,"title":38,"updatedAt":39},"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","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,[34,22,35,36,37],"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)",{"_id":41,"slug":42,"__v":6,"author":7,"canonical":43,"category":10,"createdAt":44,"date":45,"description":46,"image":15,"imageAlt":15,"readingTime":47,"tags":48,"title":51,"updatedAt":52},"69d7e9c3aa6b273b0c95cc57","migration-http-vers-https-checklist-seo-complete","https://seogard.io/blog/migration-http-vers-https-checklist-seo-complete","2026-04-09T18:02:43.120Z","2026-04-09","Checklist technique pour migrer de HTTP à HTTPS sans perdre de trafic organique. Redirections, HSTS, Search Console, mixed content.",14,[49,22,50,37],"https","redirections","Migration HTTP vers HTTPS : checklist SEO complète","Thu Apr 09 2026 18:02:43 GMT+0000 (Coordinated Universal Time)",{"_id":54,"slug":55,"__v":6,"author":7,"canonical":56,"category":10,"createdAt":57,"date":45,"description":58,"image":15,"imageAlt":15,"readingTime":47,"tags":59,"title":62,"updatedAt":63},"69d8372aaa6b273b0cd3ab6d","refonte-de-site-les-20-verifications-seo-indispensables","https://seogard.io/blog/refonte-de-site-les-20-verifications-seo-indispensables","2026-04-09T23:32:58.408Z","Checklist technique complète pour réussir une refonte sans perdre de trafic organique. 20 points de contrôle concrets avec code et config.",[60,22,61,37],"refonte","checklist","Refonte de site : 20 vérifications SEO indispensables","Thu Apr 09 2026 23:32:58 GMT+0000 (Coordinated Universal Time)"]