[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"$f8mvd9kpG38cdQyAiWTfD-oFXT1qV31nOBMeh22W3cDE":3,"$fEq4eLax0sNj_28Rm9XeY5tYTdHeEeF-ZosbApMU9J1E":24,"$fcTFjTaPPT1sVcnh1wWEGoTWMSaOD4bwmwntkMae6w5s":111},{"_id":4,"slug":5,"__v":6,"author":7,"body":8,"canonical":9,"category":10,"createdAt":11,"date":12,"description":13,"htmlContent":14,"image":15,"imageAlt":15,"readingTime":16,"tags":17,"title":22,"updatedAt":23},"6a27ac47aa6b273b0cb0f6f6","next-js-metadata-async-qui-throw-la-page-sert-le-fallback-default-next-js",0,"Equipe Seogard","# Next.js metadata async qui throw : 1 200 pages servent le title par défaut pendant 18 jours\n\nJeudi 16h42. L'équipe backend déploie un changement mineur sur l'API catalogue : le champ `seo_title` migre de `string | null` vers un objet `{ value: string, locale: string }`. Côté front, personne n'est prévenu. Le site Next.js 14.2 App Router continue de build, de se déployer, de servir des pages. Dans le navigateur, les titles semblent corrects — le layout affiche un `\u003Ch1>` dynamique. Mais dans le `\u003Chead>`, 1 247 pages produit portent désormais un title identique : **\"Next.js\"**. Le fallback par défaut du framework. Il faudra 18 jours avant que quelqu'un s'en aperçoive.\n\n## Lundi matin, T+4 jours — \"Pourquoi le CTR produit est en chute libre ?\"\n\n9h12, standup marketing. La responsable acquisition partage un export Search Console : le CTR moyen des pages `/produit/[slug]` est passé de 3.8 % à 1.1 % sur les quatre derniers jours. Les impressions tiennent encore — Google continue d'afficher les pages — mais personne ne clique. Les SERPs montrent un title générique : \"Next.js\".\n\nLe lead SEO ouvre Search Console, filtre sur le répertoire `/produit/`. L'outil d'inspection d'URL confirme : le HTML rendu par Google contient `\u003Ctitle>Next.js\u003C/title>` sur chaque page testée. Première hypothèse : un problème de cache CDN. L'équipe ops invalide le cache Vercel sur une poignée d'URLs. Retest. Le title reste \"Next.js\".\n\nDeuxième hypothèse : une régression dans le composant `\u003CHead>`. Un développeur front fouille le code. Aucun composant `\u003CHead>` n'existe — le projet utilise l'API `generateMetadata` du App Router. Le fichier `app/produit/[slug]/page.tsx` exporte bien une fonction async. Elle semble correcte.\n\n10h30. Le lead SEO lance un crawl Screaming Frog en mode \"JavaScript rendering\" sur 200 URLs produit. Résultat : 197 pages sur 200 retournent `\u003Ctitle>Next.js\u003C/title>`. Les 3 exceptions sont des pages dont le produit n'a pas de champ `seo_title` — le code fallback sur le nom du produit, qui lui n'a pas changé de format.\n\n11h15. Le dev front ouvre la console navigateur sur une page produit. Aucune erreur visible. Le title dans l'onglet affiche bien le nom du produit. Confusion totale. Comment le title peut-il être correct dans le navigateur et faux pour Google ?\n\nLa réponse est dans la différence entre ce que le navigateur affiche après hydratation côté client et ce que le serveur envoie dans la réponse HTML initiale. Le développeur n'avait jamais regardé le HTML brut. Un simple `curl` va tout révéler.\n\n```bash\ncurl -s https://www.example.com/produit/chaise-ergonomique-pro \\\n  | grep -i '\u003Ctitle>'\n```\n\nSortie :\n\n```html\n\u003Ctitle>Next.js\u003C/title>\n```\n\nLe serveur envoie le fallback. Le navigateur corrige ensuite côté client via un `useEffect` ou un mécanisme d'hydratation — mais Googlebot, lui, indexe le HTML initial. Le problème est confirmé. Reste à comprendre pourquoi `generateMetadata` ne fait pas son travail.\n\n## Le bug : une promise qui throw en silence dans generateMetadata\n\nLe fichier incriminé ressemble à ceci :\n\n```typescript\n// app/produit/[slug]/page.tsx\nimport { Metadata } from 'next'\nimport { getProduct } from '@/lib/api'\n\nexport async function generateMetadata(\n  { params }: { params: { slug: string } }\n): Promise\u003CMetadata> {\n  const product = await getProduct(params.slug)\n\n  return {\n    title: product.seo_title.trim(),\n    description: product.seo_description.trim(),\n    openGraph: {\n      title: product.seo_title.trim(),\n      description: product.seo_description.trim(),\n      images: [product.og_image],\n    },\n  }\n}\n```\n\nLe problème tient en six caractères : `.trim()`. Avant la migration API, `product.seo_title` était une string. Après la migration, c'est un objet `{ value: \"Chaise ergonomique Pro\", locale: \"fr\" }`. Appeler `.trim()` sur un objet ne throw pas immédiatement en JavaScript — `undefined` est retourné par la propriété inexistante, et `.trim()` sur `undefined` throw un `TypeError: Cannot read properties of undefined`.\n\nMais le vrai piège est ailleurs. Next.js App Router gère les erreurs dans `generateMetadata` d'une façon spécifique : **quand la fonction throw, le framework ne fait pas crasher la page**. Il sert la page normalement, mais avec les metadata par défaut. Et la metadata par défaut, si aucun `layout.tsx` parent ne la surcharge, c'est le title défini dans le package Next.js lui-même : `\"Next.js\"`.\n\nVoici ce qui se passe côté serveur, reconstruit à partir des logs :\n\n```\n[ERROR] app/produit/[slug]/page.tsx generateMetadata\nTypeError: Cannot read properties of undefined (reading 'trim')\n    at generateMetadata (app/produit/[slug]/page.tsx:9:36)\n    at resolveMetadata (next/dist/lib/metadata/resolve-metadata.js:142:23)\n    at async renderToHTMLOrFlight (next/dist/server/app-render.js:891:5)\n```\n\nL'erreur est loggée côté serveur — dans les logs Vercel, noyée parmi des centaines de lignes. Mais la page retourne un status 200. Le body HTML est complet. Seul le `\u003Chead>` porte les metadata fallback.\n\n### Pourquoi le navigateur affiche le bon title\n\nLe composant page contient un `\u003Ch1>{product.name}\u003C/h1>` qui, lui, fonctionne parfaitement : `product.name` est toujours une string. Le navigateur récupère le HTML serveur avec `\u003Ctitle>Next.js\u003C/title>`, puis l'hydratation client re-exécute le JavaScript. Côté client, un hook `useEffect` dans un composant analytics met à jour `document.title` pour le tracking. Le développeur, en testant dans Chrome, voit le title corrigé dans l'onglet sans jamais regarder le source initial.\n\n### Pourquoi les tests n'ont rien détecté\n\nL'équipe a trois couches de tests :\n\n1. **Tests unitaires** sur `getProduct` — ils mockent la réponse API avec l'ancien format. Le mock n'a pas été mis à jour.\n2. **Tests E2E Playwright** — ils vérifient `await page.title()` qui retourne le title *après* hydratation client. Le test passe.\n3. **Tests de build** (`next build`) — la build statique génère les pages sans erreur car `generateMetadata` est exécutée au build time avec des données de fallback qui n'appellent pas l'API réelle en mode ISR.\n\nAucune de ces trois couches ne vérifie le HTML brut retourné par le serveur en production. C'est le trou dans la raquette.\n\n### Ce que Googlebot voit vs ce que le dev voit\n\nPour rendre le diagnostic explicite, voici les deux versions du `\u003Chead>` :\n\n**HTML brut serveur (ce que Googlebot indexe) :**\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  \u003Ctitle>Next.js\u003C/title>\n  \u003Cmeta name=\"description\" content=\"\"/>\n  \u003Cmeta property=\"og:title\" content=\"\"/>\n  \u003Cmeta property=\"og:description\" content=\"\"/>\n  \u003C!-- ... scripts Next.js ... -->\n\u003C/head>\n```\n\n**DOM après hydratation client (ce que voit Chrome DevTools) :**\n\n```html\n\u003Chead>\n  \u003Cmeta charset=\"utf-8\"/>\n  \u003Cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1\"/>\n  \u003Ctitle>Chaise Ergonomique Pro | Example Store\u003C/title>\n  \u003Cmeta name=\"description\" content=\"Chaise de bureau ergonomique...\"/>\n  \u003C!-- ... -->\n\u003C/head>\n```\n\nLe décalage est total. Et comme l'outil d'inspection d'URL de Search Console montre le HTML *après* exécution JavaScript de Google, il peut parfois afficher le title corrigé — ce qui ajoute encore à la confusion. Mais le cache d'indexation, lui, a capturé le title serveur initial sur la majorité des URLs.\n\nUn passage dans l'onglet \"Pages\" de Search Console, filtre \"Title tag issues\", révèle 1 247 pages avec le title \"Next.js\". Le signal est sans ambiguïté.\n\n### L'ampleur du dégât\n\nSur les 18 jours d'exposition :\n- 1 247 pages produit affectées sur 1 580 au total (79 % du catalogue)\n- Clics organiques sur `/produit/` : de 8 400/semaine à 2 100/semaine (−75 %)\n- Impressions stables à ~210K/semaine — Google continue de montrer les pages, mais avec \"Next.js\" en title, personne ne clique\n- 3 mots-clés top 3 perdent entre 4 et 11 positions (le title est un signal de ranking direct)\n- Revenue organique estimé : −34K€ sur la période\n\nCe type de divergence entre rendu serveur et rendu client est un classique documenté. L'article sur le [composant heading qui rend un div selon la prop mal configurée](/blog/design-system-composant-heading-qui-rend-div-selon-la-prop-as-mal-configuree) décrit un mécanisme similaire : ce que le développeur voit dans le navigateur ne correspond pas à ce que le crawler reçoit.\n\n## Le fix : try/catch, fallback explicite et monitoring du HTML brut\n\n### Patch 1 — Adapter le type et protéger generateMetadata\n\nLe correctif immédiat est double : adapter l'accès au nouveau format API et envelopper la fonction dans un try/catch avec un fallback SEO-safe.\n\n```typescript\n// app/produit/[slug]/page.tsx\nimport { Metadata } from 'next'\nimport { getProduct } from '@/lib/api'\n\nfunction extractSeoTitle(field: unknown): string {\n  if (typeof field === 'string') return field.trim()\n  if (field && typeof field === 'object' && 'value' in field) {\n    return String((field as { value: string }).value).trim()\n  }\n  return ''\n}\n\nexport async function generateMetadata(\n  { params }: { params: { slug: string } }\n): Promise\u003CMetadata> {\n  try {\n    const product = await getProduct(params.slug)\n\n    const title = extractSeoTitle(product.seo_title) || product.name\n    const description =\n      extractSeoTitle(product.seo_description) ||\n      `Découvrez ${product.name} sur Example Store.`\n\n    return {\n      title,\n      description,\n      openGraph: {\n        title,\n        description,\n        images: product.og_image ? [product.og_image] : [],\n      },\n    }\n  } catch (error) {\n    console.error('[SEO] generateMetadata failed:', params.slug, error)\n\n    return {\n      title: 'Example Store — Produit',\n      description: 'Découvrez notre catalogue sur Example Store.',\n    }\n  }\n}\n```\n\nDeux changements critiques :\n1. `extractSeoTitle` gère les deux formats (ancien string, nouveau objet) — ce qui protège aussi pendant la période de migration progressive de l'API.\n2. Le `catch` retourne un fallback métier explicite, jamais le title par défaut du framework. Si `generateMetadata` échoue, la page sert au moins un title identifiable et brandé.\n\n### Patch 2 — Surcharger le title par défaut dans le layout racine\n\nPour se protéger globalement contre tout futur throw non capturé dans un `generateMetadata` enfant, le layout racine doit définir un title par défaut métier :\n\n```typescript\n// app/layout.tsx\nimport { Metadata } from 'next'\n\nexport const metadata: Metadata = {\n  title: {\n    template: '%s | Example Store',\n    default: 'Example Store — Catalogue en ligne',\n  },\n  description: 'Example Store, votre boutique en ligne.',\n}\n```\n\nAvec ce template, même si un `generateMetadata` enfant throw et que Next.js fallback sur le parent, le title sera \"Example Store — Catalogue en ligne\" et non \"Next.js\". C'est un filet de sécurité permanent. La [documentation officielle Next.js sur les metadata](https://nextjs.org/docs/app/building-your-application/optimizing/metadata) détaille ce mécanisme de template et default.\n\n### Patch 3 — Test E2E sur le HTML brut\n\nL'équipe ajoute un test Playwright qui vérifie le HTML serveur, pas le DOM post-hydratation :\n\n```typescript\n// tests/seo/metadata-ssr.spec.ts\nimport { test, expect } from '@playwright/test'\n\nconst PRODUCT_URLS = [\n  '/produit/chaise-ergonomique-pro',\n  '/produit/bureau-assis-debout-xl',\n  '/produit/repose-pieds-bambou',\n]\n\nfor (const url of PRODUCT_URLS) {\n  test(`SSR title is not default fallback: ${url}`, async ({ request }) => {\n    const response = await request.get(url)\n    const html = await response.text()\n\n    // Vérifie que le title n'est pas le fallback Next.js\n    expect(html).not.toContain('\u003Ctitle>Next.js\u003C/title>')\n\n    // Vérifie que le title contient le brand\n    const titleMatch = html.match(/\u003Ctitle>(.*?)\u003C\\/title>/)\n    expect(titleMatch).not.toBeNull()\n    expect(titleMatch![1]).toContain('Example Store')\n    expect(titleMatch![1].length).toBeGreaterThan(15)\n  })\n}\n```\n\nCe test utilise `request.get` — pas `page.goto`. Il récupère le HTML brut, comme le ferait `curl` ou Googlebot. C'est la différence fondamentale avec les tests E2E classiques qui naviguent dans un vrai navigateur. Ce pattern devrait être systématique sur tout site Next.js App Router qui repose sur `generateMetadata`.\n\n### Redéploiement et récupération\n\nLe fix est déployé un mardi à 14h. Les étapes de récupération :\n\n1. **Invalidation du cache ISR** sur toutes les pages `/produit/` via l'API `revalidatePath` de Next.js.\n2. **Soumission manuelle** des 50 URLs les plus stratégiques dans l'outil d'inspection d'URL Search Console (demande de réindexation).\n3. **Sitemap ping** vers Google via `https://www.google.com/ping?sitemap=https://www.example.com/sitemap-products.xml`.\n\nLe suivi montre une récupération en trois phases :\n- **J+2** : Google recrawle ~30 % des pages affectées. Les titles corrigés commencent à apparaître dans les SERPs.\n- **J+7** : 85 % des pages affichent le bon title. Le CTR remonte à 2.9 %.\n- **J+14** : CTR revenu à 3.6 %, proche du niveau initial. Les positions sur les 3 mots-clés top 3 mettent encore une semaine supplémentaire à se stabiliser.\n\nTotal : **~25 jours entre le déploiement du bug et la récupération complète.** 18 jours d'exposition + 7 jours de récupération effective. L'impact SEO d'un title cassé est long à résorber parce que Google doit recrawler, réindexer, puis recalculer le ranking.\n\nLe scénario rappelle d'autres incidents où un problème invisible côté navigateur cause des dégâts côté crawler. L'[article sur le A/B test qui servait un noindex à 50 % du trafic](/blog/a-b-test-header-la-variante-b-sert-un-noindex-a-50-du-trafic-pendant-9-jours) illustre le même pattern : le dev ne voit rien d'anormal, mais le crawler reçoit un signal destructeur. De même, la [migration Vercel vers Railway avec perte du edge ISR](/blog/migration-vercel-vers-railway-perte-du-edge-isr-ttfb-multiplie-par-4) montre comment un changement d'infrastructure peut dégrader le rendu serveur sans symptôme visible dans le navigateur.\n\n## Ce qu'on en retient\n\nTrois règles après cet incident.\n\n**Un.** Toute fonction `generateMetadata` async doit être enveloppée dans un try/catch avec un fallback métier explicite. Le fallback par défaut de Next.js — \"Next.js\" — ne doit jamais atteindre la production.\n\n**Deux.** Les tests E2E doivent vérifier le HTML brut serveur, pas le DOM post-hydratation. Un `request.get` dans Playwright coûte 5 lignes et détecte ce que `page.goto` manque.\n\n**Trois.** Le monitoring ne peut pas reposer uniquement sur des tests manuels ou des alertes Search Console (qui arrivent avec des jours de retard). Un outil de monitoring continu type Seogard détecte la divergence entre title SSR et title attendu en quelques minutes, pas en 18 jours. La différence entre les deux, ici, c'est 34K€.\n\nLe title `\u003Ctitle>Next.js\u003C/title>` en production est un signal d'alarme silencieux. Il ne casse rien visuellement. Il ne retourne pas de 500. Il passe tous les tests classiques. Et il détruit le trafic organique page par page, jour après jour.\n```","https://seogard.io/blog/next-js-metadata-async-qui-throw-la-page-sert-le-fallback-default-next-js","Framework","2026-06-09T06:01:43.334Z","2026-06-09","Une promise non gérée dans generateMetadata fait tomber les titles sur 1 200 pages produit. Récit technique, diagnostic et fix complet.","\u003Ch1>Next.js metadata async qui throw : 1 200 pages servent le title par défaut pendant 18 jours\u003C/h1>\n\u003Cp>Jeudi 16h42. L'équipe backend déploie un changement mineur sur l'API catalogue : le champ \u003Ccode>seo_title\u003C/code> migre de \u003Ccode>string | null\u003C/code> vers un objet \u003Ccode>{ value: string, locale: string }\u003C/code>. Côté front, personne n'est prévenu. Le site Next.js 14.2 App Router continue de build, de se déployer, de servir des pages. Dans le navigateur, les titles semblent corrects — le layout affiche un \u003Ccode>&#x3C;h1>\u003C/code> dynamique. Mais dans le \u003Ccode>&#x3C;head>\u003C/code>, 1 247 pages produit portent désormais un title identique : \u003Cstrong>\"Next.js\"\u003C/strong>. Le fallback par défaut du framework. Il faudra 18 jours avant que quelqu'un s'en aperçoive.\u003C/p>\n\u003Ch2>Lundi matin, T+4 jours — \"Pourquoi le CTR produit est en chute libre ?\"\u003C/h2>\n\u003Cp>9h12, standup marketing. La responsable acquisition partage un export Search Console : le CTR moyen des pages \u003Ccode>/produit/[slug]\u003C/code> est passé de 3.8 % à 1.1 % sur les quatre derniers jours. Les impressions tiennent encore — Google continue d'afficher les pages — mais personne ne clique. Les SERPs montrent un title générique : \"Next.js\".\u003C/p>\n\u003Cp>Le lead SEO ouvre Search Console, filtre sur le répertoire \u003Ccode>/produit/\u003C/code>. L'outil d'inspection d'URL confirme : le HTML rendu par Google contient \u003Ccode>&#x3C;title>Next.js&#x3C;/title>\u003C/code> sur chaque page testée. Première hypothèse : un problème de cache CDN. L'équipe ops invalide le cache Vercel sur une poignée d'URLs. Retest. Le title reste \"Next.js\".\u003C/p>\n\u003Cp>Deuxième hypothèse : une régression dans le composant \u003Ccode>&#x3C;Head>\u003C/code>. Un développeur front fouille le code. Aucun composant \u003Ccode>&#x3C;Head>\u003C/code> n'existe — le projet utilise l'API \u003Ccode>generateMetadata\u003C/code> du App Router. Le fichier \u003Ccode>app/produit/[slug]/page.tsx\u003C/code> exporte bien une fonction async. Elle semble correcte.\u003C/p>\n\u003Cp>10h30. Le lead SEO lance un crawl Screaming Frog en mode \"JavaScript rendering\" sur 200 URLs produit. Résultat : 197 pages sur 200 retournent \u003Ccode>&#x3C;title>Next.js&#x3C;/title>\u003C/code>. Les 3 exceptions sont des pages dont le produit n'a pas de champ \u003Ccode>seo_title\u003C/code> — le code fallback sur le nom du produit, qui lui n'a pas changé de format.\u003C/p>\n\u003Cp>11h15. Le dev front ouvre la console navigateur sur une page produit. Aucune erreur visible. Le title dans l'onglet affiche bien le nom du produit. Confusion totale. Comment le title peut-il être correct dans le navigateur et faux pour Google ?\u003C/p>\n\u003Cp>La réponse est dans la différence entre ce que le navigateur affiche après hydratation côté client et ce que le serveur envoie dans la réponse HTML initiale. Le développeur n'avait jamais regardé le HTML brut. Un simple \u003Ccode>curl\u003C/code> va tout révéler.\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.example.com/produit/chaise-ergonomique-pro\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\u003Cp>Sortie :\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\">title\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>Next.js&#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">title\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Le serveur envoie le fallback. Le navigateur corrige ensuite côté client via un \u003Ccode>useEffect\u003C/code> ou un mécanisme d'hydratation — mais Googlebot, lui, indexe le HTML initial. Le problème est confirmé. Reste à comprendre pourquoi \u003Ccode>generateMetadata\u003C/code> ne fait pas son travail.\u003C/p>\n\u003Ch2>Le bug : une promise qui throw en silence dans generateMetadata\u003C/h2>\n\u003Cp>Le fichier incriminé ressemble à ceci :\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/produit/[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>\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>\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:#E1E4E8\">  { \u003C/span>\u003Cspan style=\"color:#FFAB70\">params\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> }\u003C/span>\u003Cspan style=\"color:#F97583\">:\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { \u003C/span>\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: product.seo_title.\u003C/span>\u003Cspan style=\"color:#B392F0\">trim\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(),\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    description: product.seo_description.\u003C/span>\u003Cspan style=\"color:#B392F0\">trim\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: product.seo_title.\u003C/span>\u003Cspan style=\"color:#B392F0\">trim\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(),\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      description: product.seo_description.\u003C/span>\u003Cspan style=\"color:#B392F0\">trim\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(),\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      images: [product.og_image],\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>\u003C/code>\u003C/pre>\n\u003Cp>Le problème tient en six caractères : \u003Ccode>.trim()\u003C/code>. Avant la migration API, \u003Ccode>product.seo_title\u003C/code> était une string. Après la migration, c'est un objet \u003Ccode>{ value: \"Chaise ergonomique Pro\", locale: \"fr\" }\u003C/code>. Appeler \u003Ccode>.trim()\u003C/code> sur un objet ne throw pas immédiatement en JavaScript — \u003Ccode>undefined\u003C/code> est retourné par la propriété inexistante, et \u003Ccode>.trim()\u003C/code> sur \u003Ccode>undefined\u003C/code> throw un \u003Ccode>TypeError: Cannot read properties of undefined\u003C/code>.\u003C/p>\n\u003Cp>Mais le vrai piège est ailleurs. Next.js App Router gère les erreurs dans \u003Ccode>generateMetadata\u003C/code> d'une façon spécifique : \u003Cstrong>quand la fonction throw, le framework ne fait pas crasher la page\u003C/strong>. Il sert la page normalement, mais avec les metadata par défaut. Et la metadata par défaut, si aucun \u003Ccode>layout.tsx\u003C/code> parent ne la surcharge, c'est le title défini dans le package Next.js lui-même : \u003Ccode>\"Next.js\"\u003C/code>.\u003C/p>\n\u003Cp>Voici ce qui se passe côté serveur, reconstruit à partir des logs :\u003C/p>\n\u003Cpre>\u003Ccode>[ERROR] app/produit/[slug]/page.tsx generateMetadata\nTypeError: Cannot read properties of undefined (reading 'trim')\n    at generateMetadata (app/produit/[slug]/page.tsx:9:36)\n    at resolveMetadata (next/dist/lib/metadata/resolve-metadata.js:142:23)\n    at async renderToHTMLOrFlight (next/dist/server/app-render.js:891:5)\n\u003C/code>\u003C/pre>\n\u003Cp>L'erreur est loggée côté serveur — dans les logs Vercel, noyée parmi des centaines de lignes. Mais la page retourne un status 200. Le body HTML est complet. Seul le \u003Ccode>&#x3C;head>\u003C/code> porte les metadata fallback.\u003C/p>\n\u003Ch3>Pourquoi le navigateur affiche le bon title\u003C/h3>\n\u003Cp>Le composant page contient un \u003Ccode>&#x3C;h1>{product.name}&#x3C;/h1>\u003C/code> qui, lui, fonctionne parfaitement : \u003Ccode>product.name\u003C/code> est toujours une string. Le navigateur récupère le HTML serveur avec \u003Ccode>&#x3C;title>Next.js&#x3C;/title>\u003C/code>, puis l'hydratation client re-exécute le JavaScript. Côté client, un hook \u003Ccode>useEffect\u003C/code> dans un composant analytics met à jour \u003Ccode>document.title\u003C/code> pour le tracking. Le développeur, en testant dans Chrome, voit le title corrigé dans l'onglet sans jamais regarder le source initial.\u003C/p>\n\u003Ch3>Pourquoi les tests n'ont rien détecté\u003C/h3>\n\u003Cp>L'équipe a trois couches de tests :\u003C/p>\n\u003Col>\n\u003Cli>\u003Cstrong>Tests unitaires\u003C/strong> sur \u003Ccode>getProduct\u003C/code> — ils mockent la réponse API avec l'ancien format. Le mock n'a pas été mis à jour.\u003C/li>\n\u003Cli>\u003Cstrong>Tests E2E Playwright\u003C/strong> — ils vérifient \u003Ccode>await page.title()\u003C/code> qui retourne le title \u003Cem>après\u003C/em> hydratation client. Le test passe.\u003C/li>\n\u003Cli>\u003Cstrong>Tests de build\u003C/strong> (\u003Ccode>next build\u003C/code>) — la build statique génère les pages sans erreur car \u003Ccode>generateMetadata\u003C/code> est exécutée au build time avec des données de fallback qui n'appellent pas l'API réelle en mode ISR.\u003C/li>\n\u003C/ol>\n\u003Cp>Aucune de ces trois couches ne vérifie le HTML brut retourné par le serveur en production. C'est le trou dans la raquette.\u003C/p>\n\u003Ch3>Ce que Googlebot voit vs ce que le dev voit\u003C/h3>\n\u003Cp>Pour rendre le diagnostic explicite, voici les deux versions du \u003Ccode>&#x3C;head>\u003C/code> :\u003C/p>\n\u003Cp>\u003Cstrong>HTML brut serveur (ce que Googlebot indexe) :\u003C/strong>\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\">title\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>Next.js&#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\">\"\"\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\">\"\"\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:description\"\u003C/span>\u003Cspan style=\"color:#B392F0\"> content\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">/>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">  &#x3C;!-- ... scripts Next.js ... -->\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>\u003C/code>\u003C/pre>\n\u003Cp>\u003Cstrong>DOM après hydratation client (ce que voit Chrome DevTools) :\u003C/strong>\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\">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\">title\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>Chaise Ergonomique Pro | Example Store&#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\">\"Chaise de bureau ergonomique...\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">/>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">  &#x3C;!-- ... -->\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>\u003C/code>\u003C/pre>\n\u003Cp>Le décalage est total. Et comme l'outil d'inspection d'URL de Search Console montre le HTML \u003Cem>après\u003C/em> exécution JavaScript de Google, il peut parfois afficher le title corrigé — ce qui ajoute encore à la confusion. Mais le cache d'indexation, lui, a capturé le title serveur initial sur la majorité des URLs.\u003C/p>\n\u003Cp>Un passage dans l'onglet \"Pages\" de Search Console, filtre \"Title tag issues\", révèle 1 247 pages avec le title \"Next.js\". Le signal est sans ambiguïté.\u003C/p>\n\u003Ch3>L'ampleur du dégât\u003C/h3>\n\u003Cp>Sur les 18 jours d'exposition :\u003C/p>\n\u003Cul>\n\u003Cli>1 247 pages produit affectées sur 1 580 au total (79 % du catalogue)\u003C/li>\n\u003Cli>Clics organiques sur \u003Ccode>/produit/\u003C/code> : de 8 400/semaine à 2 100/semaine (−75 %)\u003C/li>\n\u003Cli>Impressions stables à ~210K/semaine — Google continue de montrer les pages, mais avec \"Next.js\" en title, personne ne clique\u003C/li>\n\u003Cli>3 mots-clés top 3 perdent entre 4 et 11 positions (le title est un signal de ranking direct)\u003C/li>\n\u003Cli>Revenue organique estimé : −34K€ sur la période\u003C/li>\n\u003C/ul>\n\u003Cp>Ce type de divergence entre rendu serveur et rendu client est un classique documenté. L'article sur le \u003Ca href=\"/blog/design-system-composant-heading-qui-rend-div-selon-la-prop-as-mal-configuree\">composant heading qui rend un div selon la prop mal configurée\u003C/a> décrit un mécanisme similaire : ce que le développeur voit dans le navigateur ne correspond pas à ce que le crawler reçoit.\u003C/p>\n\u003Ch2>Le fix : try/catch, fallback explicite et monitoring du HTML brut\u003C/h2>\n\u003Ch3>Patch 1 — Adapter le type et protéger generateMetadata\u003C/h3>\n\u003Cp>Le correctif immédiat est double : adapter l'accès au nouveau format API et envelopper la fonction dans un try/catch avec un fallback SEO-safe.\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/produit/[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>\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>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">function\u003C/span>\u003Cspan style=\"color:#B392F0\"> extractSeoTitle\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#FFAB70\">field\u003C/span>\u003Cspan style=\"color:#F97583\">:\u003C/span>\u003Cspan style=\"color:#79B8FF\"> unknown\u003C/span>\u003Cspan style=\"color:#E1E4E8\">)\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:#F97583\">  if\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> (\u003C/span>\u003Cspan style=\"color:#F97583\">typeof\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> field \u003C/span>\u003Cspan style=\"color:#F97583\">===\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'string'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">) \u003C/span>\u003Cspan style=\"color:#F97583\">return\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> field.\u003C/span>\u003Cspan style=\"color:#B392F0\">trim\u003C/span>\u003Cspan style=\"color:#E1E4E8\">()\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  if\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> (field \u003C/span>\u003Cspan style=\"color:#F97583\">&#x26;&#x26;\u003C/span>\u003Cspan style=\"color:#F97583\"> typeof\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> field \u003C/span>\u003Cspan style=\"color:#F97583\">===\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'object'\u003C/span>\u003Cspan style=\"color:#F97583\"> &#x26;&#x26;\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'value'\u003C/span>\u003Cspan style=\"color:#F97583\"> in\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> field) {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">    return\u003C/span>\u003Cspan style=\"color:#B392F0\"> String\u003C/span>\u003Cspan style=\"color:#E1E4E8\">((field \u003C/span>\u003Cspan style=\"color:#F97583\">as\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { \u003C/span>\u003Cspan style=\"color:#FFAB70\">value\u003C/span>\u003Cspan style=\"color:#F97583\">:\u003C/span>\u003Cspan style=\"color:#79B8FF\"> string\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> }).value).\u003C/span>\u003Cspan style=\"color:#B392F0\">trim\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\">  return\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> ''\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\"> 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:#E1E4E8\">  { \u003C/span>\u003Cspan style=\"color:#FFAB70\">params\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> }\u003C/span>\u003Cspan style=\"color:#F97583\">:\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { \u003C/span>\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\">  try\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\">    const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> title\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#B392F0\"> extractSeoTitle\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(product.seo_title) \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:#F97583\">    const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> description\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">      extractSeoTitle\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(product.seo_description) \u003C/span>\u003Cspan style=\"color:#F97583\">||\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">      `Découvrez ${\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\">} sur Example Store.`\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>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      description,\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>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">        description,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">        images: product.og_image \u003C/span>\u003Cspan style=\"color:#F97583\">?\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> [product.og_image] \u003C/span>\u003Cspan style=\"color:#F97583\">:\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>\u003Cspan style=\"color:#F97583\">catch\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> (error) {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    console.\u003C/span>\u003Cspan style=\"color:#B392F0\">error\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">'[SEO] generateMetadata failed:'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, params.slug, error)\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\">'Example Store — Produit'\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 catalogue sur Example Store.'\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>\u003C/code>\u003C/pre>\n\u003Cp>Deux changements critiques :\u003C/p>\n\u003Col>\n\u003Cli>\u003Ccode>extractSeoTitle\u003C/code> gère les deux formats (ancien string, nouveau objet) — ce qui protège aussi pendant la période de migration progressive de l'API.\u003C/li>\n\u003Cli>Le \u003Ccode>catch\u003C/code> retourne un fallback métier explicite, jamais le title par défaut du framework. Si \u003Ccode>generateMetadata\u003C/code> échoue, la page sert au moins un title identifiable et brandé.\u003C/li>\n\u003C/ol>\n\u003Ch3>Patch 2 — Surcharger le title par défaut dans le layout racine\u003C/h3>\n\u003Cp>Pour se protéger globalement contre tout futur throw non capturé dans un \u003Ccode>generateMetadata\u003C/code> enfant, le layout racine doit définir un title par défaut métier :\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/layout.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>\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:#B392F0\"> 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>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    template: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'%s | Example Store'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    default: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'Example Store — Catalogue en ligne'\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\">  description: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'Example Store, votre boutique en ligne.'\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>Avec ce template, même si un \u003Ccode>generateMetadata\u003C/code> enfant throw et que Next.js fallback sur le parent, le title sera \"Example Store — Catalogue en ligne\" et non \"Next.js\". C'est un filet de sécurité permanent. La \u003Ca href=\"https://nextjs.org/docs/app/building-your-application/optimizing/metadata\">documentation officielle Next.js sur les metadata\u003C/a> détaille ce mécanisme de template et default.\u003C/p>\n\u003Ch3>Patch 3 — Test E2E sur le HTML brut\u003C/h3>\n\u003Cp>L'équipe ajoute un test Playwright qui vérifie le HTML serveur, pas le DOM post-hydratation :\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-ssr.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>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> PRODUCT_URLS\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> [\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">  '/produit/chaise-ergonomique-pro'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">  '/produit/bureau-assis-debout-xl'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">  '/produit/repose-pieds-bambou'\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\">for\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> (\u003C/span>\u003Cspan style=\"color:#F97583\">const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> url\u003C/span>\u003Cspan style=\"color:#F97583\"> of\u003C/span>\u003Cspan style=\"color:#79B8FF\"> PRODUCT_URLS\u003C/span>\u003Cspan style=\"color:#E1E4E8\">) {\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\">`SSR title is not default fallback: ${\u003C/span>\u003Cspan style=\"color:#E1E4E8\">url\u003C/span>\u003Cspan style=\"color:#9ECBFF\">}`\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\">(url)\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:#6A737D\">    // Vérifie que le title n'est pas le fallback Next.js\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">    expect\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(html).not.\u003C/span>\u003Cspan style=\"color:#B392F0\">toContain\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">'&#x3C;title>Next.js&#x3C;/title>'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">)\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">    // Vérifie que le title contient le brand\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">    const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> titleMatch\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> html.\u003C/span>\u003Cspan style=\"color:#B392F0\">match\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">/\u003C/span>\u003Cspan style=\"color:#DBEDFF\">&#x3C;title>(\u003C/span>\u003Cspan style=\"color:#79B8FF\">.\u003C/span>\u003Cspan style=\"color:#F97583\">*?\u003C/span>\u003Cspan style=\"color:#DBEDFF\">)&#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D;font-weight:bold\">\\/\u003C/span>\u003Cspan style=\"color:#DBEDFF\">title>\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\">(titleMatch).not.\u003C/span>\u003Cspan style=\"color:#B392F0\">toBeNull\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\">(titleMatch\u003C/span>\u003Cspan style=\"color:#F97583\">!\u003C/span>\u003Cspan style=\"color:#E1E4E8\">[\u003C/span>\u003Cspan style=\"color:#79B8FF\">1\u003C/span>\u003Cspan style=\"color:#E1E4E8\">]).\u003C/span>\u003Cspan style=\"color:#B392F0\">toContain\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">'Example Store'\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\">(titleMatch\u003C/span>\u003Cspan style=\"color:#F97583\">!\u003C/span>\u003Cspan style=\"color:#E1E4E8\">[\u003C/span>\u003Cspan style=\"color:#79B8FF\">1\u003C/span>\u003Cspan style=\"color:#E1E4E8\">].\u003C/span>\u003Cspan style=\"color:#79B8FF\">length\u003C/span>\u003Cspan style=\"color:#E1E4E8\">).\u003C/span>\u003Cspan style=\"color:#B392F0\">toBeGreaterThan\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#79B8FF\">15\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 utilise \u003Ccode>request.get\u003C/code> — pas \u003Ccode>page.goto\u003C/code>. Il récupère le HTML brut, comme le ferait \u003Ccode>curl\u003C/code> ou Googlebot. C'est la différence fondamentale avec les tests E2E classiques qui naviguent dans un vrai navigateur. Ce pattern devrait être systématique sur tout site Next.js App Router qui repose sur \u003Ccode>generateMetadata\u003C/code>.\u003C/p>\n\u003Ch3>Redéploiement et récupération\u003C/h3>\n\u003Cp>Le fix est déployé un mardi à 14h. Les étapes de récupération :\u003C/p>\n\u003Col>\n\u003Cli>\u003Cstrong>Invalidation du cache ISR\u003C/strong> sur toutes les pages \u003Ccode>/produit/\u003C/code> via l'API \u003Ccode>revalidatePath\u003C/code> de Next.js.\u003C/li>\n\u003Cli>\u003Cstrong>Soumission manuelle\u003C/strong> des 50 URLs les plus stratégiques dans l'outil d'inspection d'URL Search Console (demande de réindexation).\u003C/li>\n\u003Cli>\u003Cstrong>Sitemap ping\u003C/strong> vers Google via \u003Ccode>https://www.google.com/ping?sitemap=https://www.example.com/sitemap-products.xml\u003C/code>.\u003C/li>\n\u003C/ol>\n\u003Cp>Le suivi montre une récupération en trois phases :\u003C/p>\n\u003Cul>\n\u003Cli>\u003Cstrong>J+2\u003C/strong> : Google recrawle ~30 % des pages affectées. Les titles corrigés commencent à apparaître dans les SERPs.\u003C/li>\n\u003Cli>\u003Cstrong>J+7\u003C/strong> : 85 % des pages affichent le bon title. Le CTR remonte à 2.9 %.\u003C/li>\n\u003Cli>\u003Cstrong>J+14\u003C/strong> : CTR revenu à 3.6 %, proche du niveau initial. Les positions sur les 3 mots-clés top 3 mettent encore une semaine supplémentaire à se stabiliser.\u003C/li>\n\u003C/ul>\n\u003Cp>Total : \u003Cstrong>~25 jours entre le déploiement du bug et la récupération complète.\u003C/strong> 18 jours d'exposition + 7 jours de récupération effective. L'impact SEO d'un title cassé est long à résorber parce que Google doit recrawler, réindexer, puis recalculer le ranking.\u003C/p>\n\u003Cp>Le scénario rappelle d'autres incidents où un problème invisible côté navigateur cause des dégâts côté crawler. L'\u003Ca href=\"/blog/a-b-test-header-la-variante-b-sert-un-noindex-a-50-du-trafic-pendant-9-jours\">article sur le A/B test qui servait un noindex à 50 % du trafic\u003C/a> illustre le même pattern : le dev ne voit rien d'anormal, mais le crawler reçoit un signal destructeur. De même, la \u003Ca href=\"/blog/migration-vercel-vers-railway-perte-du-edge-isr-ttfb-multiplie-par-4\">migration Vercel vers Railway avec perte du edge ISR\u003C/a> montre comment un changement d'infrastructure peut dégrader le rendu serveur sans symptôme visible dans le navigateur.\u003C/p>\n\u003Ch2>Ce qu'on en retient\u003C/h2>\n\u003Cp>Trois règles après cet incident.\u003C/p>\n\u003Cp>\u003Cstrong>Un.\u003C/strong> Toute fonction \u003Ccode>generateMetadata\u003C/code> async doit être enveloppée dans un try/catch avec un fallback métier explicite. Le fallback par défaut de Next.js — \"Next.js\" — ne doit jamais atteindre la production.\u003C/p>\n\u003Cp>\u003Cstrong>Deux.\u003C/strong> Les tests E2E doivent vérifier le HTML brut serveur, pas le DOM post-hydratation. Un \u003Ccode>request.get\u003C/code> dans Playwright coûte 5 lignes et détecte ce que \u003Ccode>page.goto\u003C/code> manque.\u003C/p>\n\u003Cp>\u003Cstrong>Trois.\u003C/strong> Le monitoring ne peut pas reposer uniquement sur des tests manuels ou des alertes Search Console (qui arrivent avec des jours de retard). Un outil de monitoring continu type Seogard détecte la divergence entre title SSR et title attendu en quelques minutes, pas en 18 jours. La différence entre les deux, ici, c'est 34K€.\u003C/p>\n\u003Cp>Le title \u003Ccode>&#x3C;title>Next.js&#x3C;/title>\u003C/code> en production est un signal d'alarme silencieux. Il ne casse rien visuellement. Il ne retourne pas de 500. Il passe tous les tests classiques. Et il détruit le trafic organique page par page, jour après jour.\u003C/p>\n\u003Cpre>\u003Ccode>\u003C/code>\u003C/pre>",null,12,[18,19,20,21],"next.js","metadata","async","error","Next.js metadata async throw : Google indexe \\\"Next.js\\\" en title","Tue Jun 09 2026 06:01:43 GMT+0000 (Coordinated Universal Time)",[25,40,53,68,82,97],{"_id":26,"slug":27,"__v":6,"author":7,"canonical":28,"category":10,"createdAt":29,"date":30,"description":31,"image":15,"imageAlt":15,"readingTime":32,"tags":33,"title":38,"updatedAt":39},"6a26e765aa6b273b0c0e5507","astro-content-collections-frontmatter-title-non-passe-apres-refacto","https://seogard.io/blog/astro-content-collections-frontmatter-title-non-passe-apres-refacto","2026-06-08T16:01:41.030Z","2026-06-08","Un upgrade Astro casse le mapping frontmatter → composant. 80 articles perdent leur title. Récit du bug, diagnostic technique et fix complet.",11,[34,35,36,37],"astro","content collections","frontmatter","refacto","Astro Content Collections : 80 titles vides après refacto","Mon Jun 08 2026 16:01:41 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":32,"tags":47,"title":51,"updatedAt":52},"6a2595ecaa6b273b0cf78369","astro-site-config-build-local-le-sitemap-entier-pointe-vers-un-domaine-inexistant","https://seogard.io/blog/astro-site-config-build-local-le-sitemap-entier-pointe-vers-un-domaine-inexistant","2026-06-07T16:01:48.153Z","2026-06-07","Un site Astro envoie un sitemap.xml avec 4 000 URLs build.local à Google. Récit de l'incident, diagnostic technique et fix complet.",[34,48,49,50],"sitemap","site config","build","Astro sitemap pointe vers build.local : 4 000 URLs perdues","Sun Jun 07 2026 16:01:48 GMT+0000 (Coordinated Universal Time)",{"_id":54,"slug":55,"__v":6,"author":7,"canonical":56,"category":57,"createdAt":58,"date":30,"description":59,"image":15,"imageAlt":15,"readingTime":16,"tags":60,"title":66,"updatedAt":67},"6a265b17aa6b273b0c9a6fff","your-next-ai-visitor-will-know-who-sent-it-via-sejournal-slobodanmanic","https://seogard.io/blog/your-next-ai-visitor-will-know-who-sent-it-via-sejournal-slobodanmanic","Actualités SEO","2026-06-08T06:03:03.429Z","Les agents AI arrivent avec le contexte utilisateur. Comment adapter votre contenu pour rester utile face au blended retrieval.",[61,62,63,64,65],"AI agents","blended retrieval","SEO technique","crawl AI","structured data","AI Visitors contextuels : préparer vos pages au blended retrieval","Mon Jun 08 2026 06:03:03 GMT+0000 (Coordinated Universal Time)",{"_id":69,"slug":70,"__v":6,"author":7,"canonical":71,"category":72,"createdAt":73,"date":45,"description":74,"image":15,"imageAlt":15,"readingTime":16,"tags":75,"title":80,"updatedAt":81},"6a250954aa6b273b0c8358fe","refonte-typo-variable-font-lazy-load-qui-degrade-les-core-web-vitals","https://seogard.io/blog/refonte-typo-variable-font-lazy-load-qui-degrade-les-core-web-vitals","Performance","2026-06-07T06:01:56.599Z","Une refonte typo charge la police en lazy. Le LCP passe de 1.8s à 3.0s. Aucune meta ne bouge. Le trafic chute de 18%. Récit, diagnostic, fix.",[76,77,78,79],"core web vitals","lcp","font","performance","Variable font lazy-load : LCP dégradé de 1.2s, ranking en chute","Sun Jun 07 2026 06:01:56 GMT+0000 (Coordinated Universal Time)",{"_id":83,"slug":84,"__v":6,"author":7,"canonical":85,"category":86,"createdAt":87,"date":88,"description":89,"image":15,"imageAlt":15,"readingTime":16,"tags":90,"title":95,"updatedAt":96},"6a23b7d0aa6b273b0c6c840f","splash-screen-noscript-mal-place-qui-contient-le-vrai-contenu-pour-googlebot-sansjs","https://seogard.io/blog/splash-screen-noscript-mal-place-qui-contient-le-vrai-contenu-pour-googlebot-sansjs","Rendering","2026-06-06T06:01:52.615Z","2026-06-06","Un e-commerce SPA cache son contenu dans une balise noscript pour les bots. Google détecte du cloaking. Récit, diagnostic et fix complet.",[91,92,93,94],"noscript","cloaking","spa","splash","noscript cloaking : splash screen SPA piège Google","Sat Jun 06 2026 06:01:52 GMT+0000 (Coordinated Universal Time)",{"_id":98,"slug":99,"__v":6,"author":7,"canonical":100,"category":57,"createdAt":101,"date":88,"description":102,"image":15,"imageAlt":15,"readingTime":16,"tags":103,"title":109,"updatedAt":110},"6a2444b8aa6b273b0ce0dbc7","cloudflare-bots-now-make-up-57-of-webpage-requests","https://seogard.io/blog/cloudflare-bots-now-make-up-57-of-webpage-requests","2026-06-06T16:03:04.236Z","Cloudflare révèle que 57% des requêtes web sont des bots. Analyse technique des impacts SEO et stratégies concrètes pour protéger votre crawl budget.",[104,105,106,107,108],"cloudflare","bots","crawl budget","seo technique","trafic automatisé","57% de bots : impact SEO et stratégies de défense technique","Sat Jun 06 2026 16:03:04 GMT+0000 (Coordinated Universal Time)",{"categories":112},[113,116,120,123,125,129,132,135,139,143,146,149,153,156,159,162,165,169,171],{"category":57,"slug":114,"count":115},"actualites-seo",163,{"category":117,"slug":118,"count":119},"Migration","migration",18,{"category":86,"slug":121,"count":122},"rendering",9,{"category":72,"slug":79,"count":124},8,{"category":126,"slug":127,"count":128},"Meta Tags","meta-tags",7,{"category":130,"slug":131,"count":128},"SEO Technique","seo-technique",{"category":133,"slug":134,"count":128},"Crawl","crawl",{"category":136,"slug":137,"count":138},"Architecture","architecture",6,{"category":140,"slug":141,"count":142},"Monitoring","monitoring",5,{"category":144,"slug":145,"count":142},"JavaScript SEO","javascript-seo",{"category":147,"slug":148,"count":142},"Structured Data","structured-data",{"category":150,"slug":151,"count":152},"Outils","outils",4,{"category":154,"slug":155,"count":152},"Avancé","avance",{"category":157,"slug":158,"count":152},"Redirections","redirections",{"category":160,"slug":161,"count":152},"Refonte","refonte",{"category":163,"slug":164,"count":152},"E-commerce","e-commerce",{"category":166,"slug":167,"count":168},"Contenu","contenu",3,{"category":10,"slug":170,"count":168},"framework",{"category":172,"slug":173,"count":168},"IA & SEO","ia-seo"]