[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"$fdiukT28WeBHhjshaKMohY_8WBjX9EcWN1LfYk6lIbB8":3,"$fOcK2Ulpg34N3X7LAUq_inFpWEXxT0KzjSHMJd-bDwZA":24,"$fcTFjTaPPT1sVcnh1wWEGoTWMSaOD4bwmwntkMae6w5s":112},{"_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},"6a2f9542aa6b273b0c30f3ec","contentful-le-champ-seo-title-non-sync-vers-next-js-fallback-first-h1-genere",0,"Equipe Seogard","# Contentful + Next.js : quand le title SEO existe dans le CMS mais n'arrive jamais dans le HTML\n\nMercredi 14h. L'équipe content d'un site e-commerce français spécialisé dans le mobilier — 4 200 pages produit, 380K visites organiques mensuelles — termine de renseigner les champs SEO title et SEO description dans Contentful. Chaque variante de produit a enfin son title unique. Le lendemain, un dev déploie une refonte du content type \"Produit\" sur Next.js 14. Dans le navigateur, les pages s'affichent. Les titles, eux, ne bougent pas. 1 200 variantes produit servent le même `\u003Ctitle>` : le H1 du template — \"Canapé Oslo\". Pendant 26 jours, personne ne voit rien.\n\n## Lundi 9h12 — \"Pourquoi nos impressions fondent ?\"\n\nLa Lead SEO ouvre Search Console le lundi matin. Le rapport Performance affiche une courbe familière : impressions stables pendant trois mois, puis une glissade régulière depuis trois semaines. Pas un effondrement brutal — une érosion. Moins 18 % de clics sur le segment \"canapés\", moins 22 % sur \"fauteuils\".\n\nPremier réflexe : vérifier les positions. Les requêtes brandées tiennent. Les requêtes longue traîne — \"canapé oslo 3 places tissu gris\", \"canapé oslo 2 places velours bleu\" — perdent entre 4 et 9 positions. 47 URLs sortent du top 20.\n\nL'équipe ouvre un ticket Slack. Le CTO demande : \"On a touché quoi côté tech ces dernières semaines ?\" La réponse du lead dev : un refacto du content model Contentful, un changement de la query GraphQL, et une mise à jour du composant `\u003Chead>` pour passer à `generateMetadata` de Next.js 14.\n\nLa Lead SEO lance un crawl Screaming Frog sur le répertoire `/canapes/`. 1 247 URLs crawlées. Colonne `\u003Ctitle>` : 1 198 pages affichent \"Canapé Oslo\". 49 affichent un title différent — celles qui n'ont pas de variante.\n\nLe diagnostic initial est faux. L'équipe pense d'abord à un problème de cache CDN. Le site tourne sur Vercel, ISR activé, revalidation toutes les 60 secondes. Hypothèse : les pages auraient été mises en cache avant que les champs SEO soient remplis dans Contentful. Le lead dev purge le cache ISR. Nouveau crawl. Même résultat : 1 198 titles identiques.\n\nDeuxième hypothèse : les champs SEO sont vides dans Contentful. La content manager vérifie. Les champs `seoTitle` et `seoDescription` sont bien remplis pour chaque variante. \"Canapé Oslo 3 places — Tissu gris anthracite\", \"Canapé Oslo 2 places — Velours bleu nuit\". Tout est là.\n\nTroisième hypothèse, la bonne : le binding entre Contentful et Next.js ne récupère pas le bon champ. Le title affiché n'est pas le `seoTitle` du CMS. C'est le `name` du produit — celui qui sert aussi de H1.\n\nL'impact se précise. Sur 26 jours, Search Console montre :\n\n- **−38K clics** sur les pages variantes produit\n- **−210K impressions** sur les requêtes longue traîne\n- **47 URLs** passées sous le top 20\n- **CTR moyen** des variantes tombé de 4,2 % à 2,1 %\n\nLe problème n'est pas un bug. C'est un mapping manquant.\n\n## Le bug : un champ GraphQL ignoré, un fallback silencieux\n\nPour comprendre l'incident, il faut remonter au content model Contentful et à la query GraphQL qui alimente Next.js.\n\n### Le content model Contentful\n\nLe content type \"Produit\" dans Contentful a cette structure :\n\n| Field ID | Field Name | Type |\n|---|---|---|\n| `name` | Nom du produit | Short text |\n| `slug` | Slug | Short text |\n| `description` | Description | Rich text |\n| `seoTitle` | SEO Title | Short text |\n| `seoDescription` | SEO Description | Long text |\n| `variant` | Variante | Short text |\n| `images` | Images | Media (array) |\n\nLe champ `seoTitle` a été ajouté lors du refacto du content model — exactement le jour où le dev a aussi refactoré la query GraphQL. Le timing est la cause du bug.\n\n### La query GraphQL avant le refacto\n\n```graphql\nquery ProductPage($slug: String!) {\n  productCollection(where: { slug: $slug }, limit: 1) {\n    items {\n      name\n      slug\n      description {\n        json\n      }\n      seoTitle\n      seoDescription\n      variant\n      imagesCollection {\n        items {\n          url\n          description\n        }\n      }\n    }\n  }\n}\n```\n\nCette query existait dans l'ancienne codebase. Elle récupérait `seoTitle` et `seoDescription`. Sauf que le champ n'existait pas encore dans Contentful à cette époque — la query retournait `null` pour ces deux champs, et le composant `\u003CHead>` de Next.js Pages Router utilisait un fallback.\n\n### La query GraphQL après le refacto\n\nLe dev a migré de Pages Router vers App Router. Il a réécrit la query en utilisant le SDK Contentful pour TypeScript. Voici ce qu'il a produit :\n\n```typescript\n// lib/contentful.ts\nimport { createClient } from 'contentful';\n\nconst client = createClient({\n  space: process.env.CONTENTFUL_SPACE_ID!,\n  accessToken: process.env.CONTENTFUL_ACCESS_TOKEN!,\n});\n\nexport async function getProductBySlug(slug: string) {\n  const entries = await client.getEntries({\n    content_type: 'product',\n    'fields.slug': slug,\n    limit: 1,\n    select: [\n      'fields.name',\n      'fields.slug',\n      'fields.description',\n      'fields.variant',\n      'fields.images',\n    ],\n  });\n\n  return entries.items[0]?.fields ?? null;\n}\n```\n\nLe problème est là, ligne par ligne. Le paramètre `select` liste explicitement les champs à récupérer. `fields.seoTitle` et `fields.seoDescription` ne sont pas dans la liste. Le dev a copié les champs de l'ancien modèle, ajouté `variant` et `images`, et oublié les deux champs SEO qui venaient d'être créés dans Contentful au même moment.\n\nLe SDK Contentful ne lève aucune erreur. Il retourne simplement les champs demandés. Pas de warning, pas de log. Silence total.\n\n### Le composant metadata dans Next.js App Router\n\nCôté Next.js 14, le dev a implémenté `generateMetadata` dans le fichier page :\n\n```typescript\n// app/produits/[slug]/page.tsx\nimport { getProductBySlug } from '@/lib/contentful';\nimport type { Metadata } from 'next';\n\ntype Props = {\n  params: { slug: string };\n};\n\nexport async function generateMetadata({ params }: Props): Promise\u003CMetadata> {\n  const product = await getProductBySlug(params.slug);\n\n  return {\n    title: product?.seoTitle || product?.name || 'Produit',\n    description: product?.seoDescription || '',\n  };\n}\n\nexport default async function ProductPage({ params }: Props) {\n  const product = await getProductBySlug(params.slug);\n  // ...render\n}\n```\n\nLa logique de fallback est correcte en théorie : si `seoTitle` est absent, utiliser `name`. Le problème : `product.seoTitle` est **toujours** `undefined` parce que le champ n'est jamais récupéré depuis Contentful. Le fallback `product.name` s'active sur 100 % des pages.\n\nEt `name`, c'est le nom générique du produit — \"Canapé Oslo\" — sans la variante. Parce que la variante est un champ séparé, et personne n'a pensé à concaténer `name + variant` dans le fallback.\n\n### Ce que voit le navigateur vs ce que voit Googlebot\n\nLe résultat HTML servi par Next.js :\n\n```html\n\u003C!DOCTYPE html>\n\u003Chtml>\n\u003Chead>\n  \u003Ctitle>Canapé Oslo\u003C/title>\n  \u003Cmeta name=\"description\" content=\"\" />\n  \u003C!-- ... -->\n\u003C/head>\n\u003Cbody>\n  \u003Ch1>Canapé Oslo\u003C/h1>\n  \u003Cp class=\"variant-name\">3 places — Tissu gris anthracite\u003C/p>\n  \u003C!-- ... -->\n\u003C/body>\n\u003C/html>\n```\n\nLe title est identique au H1. La meta description est vide (même logique de fallback : `seoDescription` est `undefined`, fallback vers chaîne vide). Googlebot voit exactement la même chose — pas de divergence SSR/CSR ici, le rendu est cohérent. Le problème est purement un problème de données.\n\n1 198 pages avec le title \"Canapé Oslo\". Google ne peut pas distinguer les variantes. Les requêtes longue traîne (\"canapé oslo 3 places tissu gris\") matchent mal un title générique. Le CTR s'effondre. Les positions suivent.\n\n### Pourquoi personne n'a rien vu\n\nTrois raisons.\n\n**1. Les tests end-to-end ne vérifient pas le contenu des balises meta.** Le pipeline CI/CD utilise Playwright. Les tests vérifient que la page se charge, que le prix s'affiche, que le bouton \"Ajouter au panier\" fonctionne. Aucun `expect(page).toHaveTitle()` dans la suite de tests.\n\n**2. La preview Contentful montre le bon title.** L'interface preview de Contentful affiche les champs tels qu'ils existent dans le CMS. La content manager voit \"Canapé Oslo 3 places — Tissu gris anthracite\" dans le champ SEO Title. Elle n'a aucune raison de suspecter que ce champ ne parvient pas au front.\n\n**3. Le title \"Canapé Oslo\" est visuellement correct.** En naviguant sur le site, l'onglet du navigateur affiche \"Canapé Oslo\". C'est le nom du produit. Ça ne choque personne — sauf un SEO qui comparerait 1 200 onglets.\n\nCe type de régression silencieuse en architecture headless est un pattern récurrent. Le même phénomène de [title pris au mauvais niveau](/blog/astro-content-collections-frontmatter-title-non-passe-apres-refacto) a été documenté sur Astro, et le problème de [metadata async qui échoue silencieusement](/blog/next-js-metadata-async-qui-throw-la-page-sert-le-fallback-default-next-js) est un classique de Next.js App Router.\n\n## Le fix : trois lignes, un cache purge, et 19 jours de patience\n\n### Le patch\n\nLe correctif tient en une ligne dans la query Contentful :\n\n```typescript\n// lib/contentful.ts — PATCH\nexport async function getProductBySlug(slug: string) {\n  const entries = await client.getEntries({\n    content_type: 'product',\n    'fields.slug': slug,\n    limit: 1,\n    select: [\n      'fields.name',\n      'fields.slug',\n      'fields.description',\n      'fields.variant',\n      'fields.images',\n      'fields.seoTitle',       // ← ajouté\n      'fields.seoDescription', // ← ajouté\n    ],\n  });\n\n  return entries.items[0]?.fields ?? null;\n}\n```\n\nL'équipe en profite pour durcir le fallback dans `generateMetadata`, afin d'éviter un title générique même si le champ SEO est vide :\n\n```typescript\nexport async function generateMetadata({ params }: Props): Promise\u003CMetadata> {\n  const product = await getProductBySlug(params.slug);\n\n  const fallbackTitle = product?.variant\n    ? `${product.name} ${product.variant}`\n    : product?.name || 'Produit';\n\n  return {\n    title: product?.seoTitle || fallbackTitle,\n    description:\n      product?.seoDescription ||\n      `Découvrez ${fallbackTitle} — livraison offerte dès 500€.`,\n  };\n}\n```\n\nCe fallback amélioré garantit que même si un rédacteur oublie de renseigner le `seoTitle`, la combinaison `name + variant` produit un title unique.\n\n### Invalidation et redéploiement\n\nLe patch est mergé et déployé sur Vercel à 11h42. Mais le ISR cache sert encore les anciennes pages. L'équipe exécute une purge ciblée :\n\n```bash\n# Purge ISR pour toutes les pages produit via l'API Vercel\ncurl -X POST \"https://api.vercel.com/v1/projects/$PROJECT_ID/revalidate\" \\\n  -H \"Authorization: Bearer $VERCEL_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"paths\": [\"/produits/(.*)\"]}'\n```\n\nLa revalidation on-demand déclenche un re-render de chaque page lors de la prochaine visite. En 4 heures, les 1 200 pages variantes ont été re-rendues avec le bon title.\n\nVérification immédiate avec `curl` :\n\n```bash\ncurl -s https://www.example.com/produits/canape-oslo-3-places-tissu-gris | \\\n  grep -o '\u003Ctitle>[^\u003C]*\u003C/title>'\n# \u003Ctitle>Canapé Oslo 3 places — Tissu gris anthracite\u003C/title>\n```\n\nUn crawl Screaming Frog de contrôle confirme : 1 198 titles uniques sur 1 247 pages. Les 49 pages sans variante conservent le title générique — attendu, puisqu'il n'y a qu'un seul produit par page.\n\n### Les garde-fous ajoutés\n\nL'équipe met en place trois mesures pour éviter la récurrence.\n\n**1. Test Playwright sur les meta.** Ajout d'un test e2e qui compare le `\u003Ctitle>` rendu avec le champ `seoTitle` de Contentful pour un échantillon de 10 pages :\n\n```typescript\n// e2e/seo-titles.spec.ts\nimport { test, expect } from '@playwright/test';\nimport { getProductBySlug } from '../lib/contentful';\n\nconst SAMPLE_SLUGS = [\n  'canape-oslo-3-places-tissu-gris',\n  'fauteuil-bergen-velours-bleu',\n  // ... 8 autres\n];\n\nfor (const slug of SAMPLE_SLUGS) {\n  test(`title matches Contentful seoTitle for ${slug}`, async ({ page }) => {\n    const product = await getProductBySlug(slug);\n    await page.goto(`/produits/${slug}`);\n    const title = await page.title();\n    expect(title).toBe(product.seoTitle);\n  });\n}\n```\n\n**2. Alerte sur les titles dupliqués.** Un script cron hebdomadaire crawle `/produits/` avec Screaming Frog en mode CLI et alerte Slack si le ratio de titles dupliqués dépasse 5 %.\n\n**3. Validation du schéma de la query Contentful.** L'équipe ajoute un type TypeScript strict pour le retour de `getProductBySlug`, généré depuis le content model Contentful avec [`contentful-typescript-codegen`](https://github.com/intercom/contentful-typescript-codegen). Si un champ est supprimé de la query `select` mais utilisé dans le composant, TypeScript lève une erreur à la compilation.\n\n### La récupération\n\nLe fix est déployé le jour J. Voici la timeline de récupération observée dans Search Console :\n\n- **J+3** : Google recrawle 40 % des pages variantes. Les nouveaux titles apparaissent dans le rapport \"Pages\".\n- **J+7** : 92 % des pages recrawlées. Les impressions cessent de baisser.\n- **J+12** : les positions commencent à remonter sur les requêtes longue traîne.\n- **J+19** : retour à 94 % du trafic pré-incident. Les 6 % restants ne reviennent jamais complètement — probablement des requêtes où un concurrent a pris la position entre-temps.\n\nAu total, l'incident aura coûté 26 jours de régression + 19 jours de récupération. 45 jours. Environ 52K clics perdus.\n\nCe type de latence de récupération est cohérent avec d'autres incidents documentés, comme celui d'une [migration Vercel vers Railway qui a multiplié le TTFB par 4](/blog/migration-vercel-vers-railway-perte-du-edge-isr-ttfb-multiplie-par-4) — où le retour au trafic nominal avait pris 23 jours.\n\n## Ce qu'on en retient\n\nEn architecture headless, le CMS et le front sont deux mondes. Un champ peut exister dans Contentful, être rempli par la rédaction, validé en preview — et ne jamais atteindre le HTML servi à Googlebot. Le SDK ne protège pas. TypeScript sans types générés ne protège pas. Les tests e2e classiques ne protègent pas.\n\nLa seule protection fiable, c'est une vérification continue du HTML réellement servi, comparé aux données source. Un outil comme Seogard détecte ce type de divergence — un title identique sur 1 200 pages — en quelques heures, pas en 26 jours.\n\nLe champ existait. La donnée existait. Le mapping manquait. Trois lignes de code. 52 000 clics.\n```","https://seogard.io/blog/contentful-le-champ-seo-title-non-sync-vers-next-js-fallback-first-h1-genere","Headless","2026-06-15T06:01:38.446Z","2026-06-15","Un champ SEO title Contentful non mappé dans Next.js génère un fallback H1 identique sur 1 200 variantes produit. Récit, diagnostic, fix.","\u003Ch1>Contentful + Next.js : quand le title SEO existe dans le CMS mais n'arrive jamais dans le HTML\u003C/h1>\n\u003Cp>Mercredi 14h. L'équipe content d'un site e-commerce français spécialisé dans le mobilier — 4 200 pages produit, 380K visites organiques mensuelles — termine de renseigner les champs SEO title et SEO description dans Contentful. Chaque variante de produit a enfin son title unique. Le lendemain, un dev déploie une refonte du content type \"Produit\" sur Next.js 14. Dans le navigateur, les pages s'affichent. Les titles, eux, ne bougent pas. 1 200 variantes produit servent le même \u003Ccode>&#x3C;title>\u003C/code> : le H1 du template — \"Canapé Oslo\". Pendant 26 jours, personne ne voit rien.\u003C/p>\n\u003Ch2>Lundi 9h12 — \"Pourquoi nos impressions fondent ?\"\u003C/h2>\n\u003Cp>La Lead SEO ouvre Search Console le lundi matin. Le rapport Performance affiche une courbe familière : impressions stables pendant trois mois, puis une glissade régulière depuis trois semaines. Pas un effondrement brutal — une érosion. Moins 18 % de clics sur le segment \"canapés\", moins 22 % sur \"fauteuils\".\u003C/p>\n\u003Cp>Premier réflexe : vérifier les positions. Les requêtes brandées tiennent. Les requêtes longue traîne — \"canapé oslo 3 places tissu gris\", \"canapé oslo 2 places velours bleu\" — perdent entre 4 et 9 positions. 47 URLs sortent du top 20.\u003C/p>\n\u003Cp>L'équipe ouvre un ticket Slack. Le CTO demande : \"On a touché quoi côté tech ces dernières semaines ?\" La réponse du lead dev : un refacto du content model Contentful, un changement de la query GraphQL, et une mise à jour du composant \u003Ccode>&#x3C;head>\u003C/code> pour passer à \u003Ccode>generateMetadata\u003C/code> de Next.js 14.\u003C/p>\n\u003Cp>La Lead SEO lance un crawl Screaming Frog sur le répertoire \u003Ccode>/canapes/\u003C/code>. 1 247 URLs crawlées. Colonne \u003Ccode>&#x3C;title>\u003C/code> : 1 198 pages affichent \"Canapé Oslo\". 49 affichent un title différent — celles qui n'ont pas de variante.\u003C/p>\n\u003Cp>Le diagnostic initial est faux. L'équipe pense d'abord à un problème de cache CDN. Le site tourne sur Vercel, ISR activé, revalidation toutes les 60 secondes. Hypothèse : les pages auraient été mises en cache avant que les champs SEO soient remplis dans Contentful. Le lead dev purge le cache ISR. Nouveau crawl. Même résultat : 1 198 titles identiques.\u003C/p>\n\u003Cp>Deuxième hypothèse : les champs SEO sont vides dans Contentful. La content manager vérifie. Les champs \u003Ccode>seoTitle\u003C/code> et \u003Ccode>seoDescription\u003C/code> sont bien remplis pour chaque variante. \"Canapé Oslo 3 places — Tissu gris anthracite\", \"Canapé Oslo 2 places — Velours bleu nuit\". Tout est là.\u003C/p>\n\u003Cp>Troisième hypothèse, la bonne : le binding entre Contentful et Next.js ne récupère pas le bon champ. Le title affiché n'est pas le \u003Ccode>seoTitle\u003C/code> du CMS. C'est le \u003Ccode>name\u003C/code> du produit — celui qui sert aussi de H1.\u003C/p>\n\u003Cp>L'impact se précise. Sur 26 jours, Search Console montre :\u003C/p>\n\u003Cul>\n\u003Cli>\u003Cstrong>−38K clics\u003C/strong> sur les pages variantes produit\u003C/li>\n\u003Cli>\u003Cstrong>−210K impressions\u003C/strong> sur les requêtes longue traîne\u003C/li>\n\u003Cli>\u003Cstrong>47 URLs\u003C/strong> passées sous le top 20\u003C/li>\n\u003Cli>\u003Cstrong>CTR moyen\u003C/strong> des variantes tombé de 4,2 % à 2,1 %\u003C/li>\n\u003C/ul>\n\u003Cp>Le problème n'est pas un bug. C'est un mapping manquant.\u003C/p>\n\u003Ch2>Le bug : un champ GraphQL ignoré, un fallback silencieux\u003C/h2>\n\u003Cp>Pour comprendre l'incident, il faut remonter au content model Contentful et à la query GraphQL qui alimente Next.js.\u003C/p>\n\u003Ch3>Le content model Contentful\u003C/h3>\n\u003Cp>Le content type \"Produit\" dans Contentful a cette structure :\u003C/p>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Field ID\u003C/th>\n\u003Cth>Field Name\u003C/th>\n\u003Cth>Type\u003C/th>\n\u003C/tr>\n\u003C/thead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>\u003Ccode>name\u003C/code>\u003C/td>\n\u003Ctd>Nom du produit\u003C/td>\n\u003Ctd>Short text\u003C/td>\n\u003C/tr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>slug\u003C/code>\u003C/td>\n\u003Ctd>Slug\u003C/td>\n\u003Ctd>Short text\u003C/td>\n\u003C/tr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>description\u003C/code>\u003C/td>\n\u003Ctd>Description\u003C/td>\n\u003Ctd>Rich text\u003C/td>\n\u003C/tr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>seoTitle\u003C/code>\u003C/td>\n\u003Ctd>SEO Title\u003C/td>\n\u003Ctd>Short text\u003C/td>\n\u003C/tr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>seoDescription\u003C/code>\u003C/td>\n\u003Ctd>SEO Description\u003C/td>\n\u003Ctd>Long text\u003C/td>\n\u003C/tr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>variant\u003C/code>\u003C/td>\n\u003Ctd>Variante\u003C/td>\n\u003Ctd>Short text\u003C/td>\n\u003C/tr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>images\u003C/code>\u003C/td>\n\u003Ctd>Images\u003C/td>\n\u003Ctd>Media (array)\u003C/td>\n\u003C/tr>\n\u003C/tbody>\n\u003C/table>\n\u003Cp>Le champ \u003Ccode>seoTitle\u003C/code> a été ajouté lors du refacto du content model — exactement le jour où le dev a aussi refactoré la query GraphQL. Le timing est la cause du bug.\u003C/p>\n\u003Ch3>La query GraphQL avant le refacto\u003C/h3>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">query\u003C/span>\u003Cspan style=\"color:#B392F0\"> ProductPage\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#FFAB70\">$slug\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: \u003C/span>\u003Cspan style=\"color:#79B8FF\">String\u003C/span>\u003Cspan style=\"color:#F97583\">!\u003C/span>\u003Cspan style=\"color:#E1E4E8\">) {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FFAB70\">  productCollection\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#FFAB70\">where\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: { \u003C/span>\u003Cspan style=\"color:#9ECBFF\">slug\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: \u003C/span>\u003Cspan style=\"color:#FFAB70\">$slug\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> }, \u003C/span>\u003Cspan style=\"color:#FFAB70\">limit\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: \u003C/span>\u003Cspan style=\"color:#79B8FF\">1\u003C/span>\u003Cspan style=\"color:#E1E4E8\">) {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FFAB70\">    items\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FFAB70\">      name\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FFAB70\">      slug\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FFAB70\">      description\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FFAB70\">        json\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      }\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FFAB70\">      seoTitle\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FFAB70\">      seoDescription\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FFAB70\">      variant\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FFAB70\">      imagesCollection\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FFAB70\">        items\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FFAB70\">          url\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FFAB70\">          description\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\">\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>Cette query existait dans l'ancienne codebase. Elle récupérait \u003Ccode>seoTitle\u003C/code> et \u003Ccode>seoDescription\u003C/code>. Sauf que le champ n'existait pas encore dans Contentful à cette époque — la query retournait \u003Ccode>null\u003C/code> pour ces deux champs, et le composant \u003Ccode>&#x3C;Head>\u003C/code> de Next.js Pages Router utilisait un fallback.\u003C/p>\n\u003Ch3>La query GraphQL après le refacto\u003C/h3>\n\u003Cp>Le dev a migré de Pages Router vers App Router. Il a réécrit la query en utilisant le SDK Contentful pour TypeScript. Voici ce qu'il a produit :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">// lib/contentful.ts\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { createClient } \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'contentful'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> client\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#B392F0\"> createClient\u003C/span>\u003Cspan style=\"color:#E1E4E8\">({\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  space: process.env.\u003C/span>\u003Cspan style=\"color:#79B8FF\">CONTENTFUL_SPACE_ID\u003C/span>\u003Cspan style=\"color:#F97583\">!\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  accessToken: process.env.\u003C/span>\u003Cspan style=\"color:#79B8FF\">CONTENTFUL_ACCESS_TOKEN\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\">\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\"> getProductBySlug\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:#F97583\">  const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> entries\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#F97583\"> await\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> client.\u003C/span>\u003Cspan style=\"color:#B392F0\">getEntries\u003C/span>\u003Cspan style=\"color:#E1E4E8\">({\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    content_type: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'product'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">    'fields.slug'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: slug,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    limit: \u003C/span>\u003Cspan style=\"color:#79B8FF\">1\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    select: [\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">      'fields.name'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">      'fields.slug'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">      'fields.description'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">      'fields.variant'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">      'fields.images'\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\">  return\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> entries.items[\u003C/span>\u003Cspan style=\"color:#79B8FF\">0\u003C/span>\u003Cspan style=\"color:#E1E4E8\">]?.fields \u003C/span>\u003Cspan style=\"color:#F97583\">??\u003C/span>\u003Cspan style=\"color:#79B8FF\"> null\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 problème est là, ligne par ligne. Le paramètre \u003Ccode>select\u003C/code> liste explicitement les champs à récupérer. \u003Ccode>fields.seoTitle\u003C/code> et \u003Ccode>fields.seoDescription\u003C/code> ne sont pas dans la liste. Le dev a copié les champs de l'ancien modèle, ajouté \u003Ccode>variant\u003C/code> et \u003Ccode>images\u003C/code>, et oublié les deux champs SEO qui venaient d'être créés dans Contentful au même moment.\u003C/p>\n\u003Cp>Le SDK Contentful ne lève aucune erreur. Il retourne simplement les champs demandés. Pas de warning, pas de log. Silence total.\u003C/p>\n\u003Ch3>Le composant metadata dans Next.js App Router\u003C/h3>\n\u003Cp>Côté Next.js 14, le dev a implémenté \u003Ccode>generateMetadata\u003C/code> dans le fichier 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\">// app/produits/[slug]/page.tsx\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { getProductBySlug } \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> '@/lib/contentful'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#F97583\"> type\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\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">type\u003C/span>\u003Cspan style=\"color:#B392F0\"> Props\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\">\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>\u003Cspan style=\"color:#FFAB70\">params\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> }\u003C/span>\u003Cspan style=\"color:#F97583\">:\u003C/span>\u003Cspan style=\"color:#B392F0\"> Props\u003C/span>\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\"> getProductBySlug\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?.seoTitle \u003C/span>\u003Cspan style=\"color:#F97583\">||\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> product?.name \u003C/span>\u003Cspan style=\"color:#F97583\">||\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'Produit'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    description: product?.seoDescription \u003C/span>\u003Cspan style=\"color:#F97583\">||\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\">\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>\u003Cspan style=\"color:#FFAB70\">params\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> }\u003C/span>\u003Cspan style=\"color:#F97583\">:\u003C/span>\u003Cspan style=\"color:#B392F0\"> Props\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\"> getProductBySlug\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(params.slug);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">  // ...render\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">}\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>La logique de fallback est correcte en théorie : si \u003Ccode>seoTitle\u003C/code> est absent, utiliser \u003Ccode>name\u003C/code>. Le problème : \u003Ccode>product.seoTitle\u003C/code> est \u003Cstrong>toujours\u003C/strong> \u003Ccode>undefined\u003C/code> parce que le champ n'est jamais récupéré depuis Contentful. Le fallback \u003Ccode>product.name\u003C/code> s'active sur 100 % des pages.\u003C/p>\n\u003Cp>Et \u003Ccode>name\u003C/code>, c'est le nom générique du produit — \"Canapé Oslo\" — sans la variante. Parce que la variante est un champ séparé, et personne n'a pensé à concaténer \u003Ccode>name + variant\u003C/code> dans le fallback.\u003C/p>\n\u003Ch3>Ce que voit le navigateur vs ce que voit Googlebot\u003C/h3>\n\u003Cp>Le résultat HTML servi par Next.js :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;!\u003C/span>\u003Cspan style=\"color:#85E89D\">DOCTYPE\u003C/span>\u003Cspan style=\"color:#B392F0\"> html\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">html\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">head\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  &#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">title\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>Canapé Oslo&#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:#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>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">body\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  &#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">h1\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>Canapé Oslo&#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">h1\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\">p\u003C/span>\u003Cspan style=\"color:#B392F0\"> class\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"variant-name\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>3 places — Tissu gris anthracite&#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">p\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\">body\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">html\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Le title est identique au H1. La meta description est vide (même logique de fallback : \u003Ccode>seoDescription\u003C/code> est \u003Ccode>undefined\u003C/code>, fallback vers chaîne vide). Googlebot voit exactement la même chose — pas de divergence SSR/CSR ici, le rendu est cohérent. Le problème est purement un problème de données.\u003C/p>\n\u003Cp>1 198 pages avec le title \"Canapé Oslo\". Google ne peut pas distinguer les variantes. Les requêtes longue traîne (\"canapé oslo 3 places tissu gris\") matchent mal un title générique. Le CTR s'effondre. Les positions suivent.\u003C/p>\n\u003Ch3>Pourquoi personne n'a rien vu\u003C/h3>\n\u003Cp>Trois raisons.\u003C/p>\n\u003Cp>\u003Cstrong>1. Les tests end-to-end ne vérifient pas le contenu des balises meta.\u003C/strong> Le pipeline CI/CD utilise Playwright. Les tests vérifient que la page se charge, que le prix s'affiche, que le bouton \"Ajouter au panier\" fonctionne. Aucun \u003Ccode>expect(page).toHaveTitle()\u003C/code> dans la suite de tests.\u003C/p>\n\u003Cp>\u003Cstrong>2. La preview Contentful montre le bon title.\u003C/strong> L'interface preview de Contentful affiche les champs tels qu'ils existent dans le CMS. La content manager voit \"Canapé Oslo 3 places — Tissu gris anthracite\" dans le champ SEO Title. Elle n'a aucune raison de suspecter que ce champ ne parvient pas au front.\u003C/p>\n\u003Cp>\u003Cstrong>3. Le title \"Canapé Oslo\" est visuellement correct.\u003C/strong> En naviguant sur le site, l'onglet du navigateur affiche \"Canapé Oslo\". C'est le nom du produit. Ça ne choque personne — sauf un SEO qui comparerait 1 200 onglets.\u003C/p>\n\u003Cp>Ce type de régression silencieuse en architecture headless est un pattern récurrent. Le même phénomène de \u003Ca href=\"/blog/astro-content-collections-frontmatter-title-non-passe-apres-refacto\">title pris au mauvais niveau\u003C/a> a été documenté sur Astro, et le problème de \u003Ca href=\"/blog/next-js-metadata-async-qui-throw-la-page-sert-le-fallback-default-next-js\">metadata async qui échoue silencieusement\u003C/a> est un classique de Next.js App Router.\u003C/p>\n\u003Ch2>Le fix : trois lignes, un cache purge, et 19 jours de patience\u003C/h2>\n\u003Ch3>Le patch\u003C/h3>\n\u003Cp>Le correctif tient en une ligne dans la query Contentful :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">// lib/contentful.ts — PATCH\u003C/span>\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\"> getProductBySlug\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:#F97583\">  const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> entries\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#F97583\"> await\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> client.\u003C/span>\u003Cspan style=\"color:#B392F0\">getEntries\u003C/span>\u003Cspan style=\"color:#E1E4E8\">({\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    content_type: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'product'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">    'fields.slug'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: slug,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    limit: \u003C/span>\u003Cspan style=\"color:#79B8FF\">1\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    select: [\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">      'fields.name'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">      'fields.slug'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">      'fields.description'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">      'fields.variant'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">      'fields.images'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">      'fields.seoTitle'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,       \u003C/span>\u003Cspan style=\"color:#6A737D\">// ← ajouté\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">      'fields.seoDescription'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#6A737D\">// ← ajouté\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\">  return\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> entries.items[\u003C/span>\u003Cspan style=\"color:#79B8FF\">0\u003C/span>\u003Cspan style=\"color:#E1E4E8\">]?.fields \u003C/span>\u003Cspan style=\"color:#F97583\">??\u003C/span>\u003Cspan style=\"color:#79B8FF\"> null\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>L'équipe en profite pour durcir le fallback dans \u003Ccode>generateMetadata\u003C/code>, afin d'éviter un title générique même si le champ SEO est vide :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\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>\u003Cspan style=\"color:#FFAB70\">params\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> }\u003C/span>\u003Cspan style=\"color:#F97583\">:\u003C/span>\u003Cspan style=\"color:#B392F0\"> Props\u003C/span>\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\"> getProductBySlug\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\"> fallbackTitle\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> product?.variant\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">    ?\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\">} ${\u003C/span>\u003Cspan style=\"color:#E1E4E8\">product\u003C/span>\u003Cspan style=\"color:#9ECBFF\">.\u003C/span>\u003Cspan style=\"color:#E1E4E8\">variant\u003C/span>\u003Cspan style=\"color:#9ECBFF\">}`\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">    :\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> product?.name \u003C/span>\u003Cspan style=\"color:#F97583\">||\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'Produit'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\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?.seoTitle \u003C/span>\u003Cspan style=\"color:#F97583\">||\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> fallbackTitle,\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\">      product?.seoDescription \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\">fallbackTitle\u003C/span>\u003Cspan style=\"color:#9ECBFF\">} — livraison offerte dès 500€.`\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 fallback amélioré garantit que même si un rédacteur oublie de renseigner le \u003Ccode>seoTitle\u003C/code>, la combinaison \u003Ccode>name + variant\u003C/code> produit un title unique.\u003C/p>\n\u003Ch3>Invalidation et redéploiement\u003C/h3>\n\u003Cp>Le patch est mergé et déployé sur Vercel à 11h42. Mais le ISR cache sert encore les anciennes pages. L'équipe exécute une purge ciblée :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\"># Purge ISR pour toutes les pages produit via l'API Vercel\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">curl\u003C/span>\u003Cspan style=\"color:#79B8FF\"> -X\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> POST\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> \"https://api.vercel.com/v1/projects/\u003C/span>\u003Cspan style=\"color:#E1E4E8\">$PROJECT_ID\u003C/span>\u003Cspan style=\"color:#9ECBFF\">/revalidate\"\u003C/span>\u003Cspan style=\"color:#79B8FF\"> \\\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">  -H\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> \"Authorization: Bearer \u003C/span>\u003Cspan style=\"color:#E1E4E8\">$VERCEL_TOKEN\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"\u003C/span>\u003Cspan style=\"color:#79B8FF\"> \\\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">  -H\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> \"Content-Type: application/json\"\u003C/span>\u003Cspan style=\"color:#79B8FF\"> \\\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">  -d\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> '{\"paths\": [\"/produits/(.*)\"]}'\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>La revalidation on-demand déclenche un re-render de chaque page lors de la prochaine visite. En 4 heures, les 1 200 pages variantes ont été re-rendues avec le bon title.\u003C/p>\n\u003Cp>Vérification immédiate avec \u003Ccode>curl\u003C/code> :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">curl\u003C/span>\u003Cspan style=\"color:#79B8FF\"> -s\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> https://www.example.com/produits/canape-oslo-3-places-tissu-gris\u003C/span>\u003Cspan style=\"color:#F97583\"> |\u003C/span>\u003Cspan style=\"color:#79B8FF\"> \\\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">  grep\u003C/span>\u003Cspan style=\"color:#79B8FF\"> -o\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> '&#x3C;title>[^&#x3C;]*&#x3C;/title>'\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\"># &#x3C;title>Canapé Oslo 3 places — Tissu gris anthracite&#x3C;/title>\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Un crawl Screaming Frog de contrôle confirme : 1 198 titles uniques sur 1 247 pages. Les 49 pages sans variante conservent le title générique — attendu, puisqu'il n'y a qu'un seul produit par page.\u003C/p>\n\u003Ch3>Les garde-fous ajoutés\u003C/h3>\n\u003Cp>L'équipe met en place trois mesures pour éviter la récurrence.\u003C/p>\n\u003Cp>\u003Cstrong>1. Test Playwright sur les meta.\u003C/strong> Ajout d'un test e2e qui compare le \u003Ccode>&#x3C;title>\u003C/code> rendu avec le champ \u003Ccode>seoTitle\u003C/code> de Contentful pour un échantillon de 10 pages :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">// e2e/seo-titles.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\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { getProductBySlug } \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> '../lib/contentful'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> SAMPLE_SLUGS\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> [\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">  'canape-oslo-3-places-tissu-gris'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">  'fauteuil-bergen-velours-bleu'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">  // ... 8 autres\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\"> slug\u003C/span>\u003Cspan style=\"color:#F97583\"> of\u003C/span>\u003Cspan style=\"color:#79B8FF\"> SAMPLE_SLUGS\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\">`title matches Contentful seoTitle for ${\u003C/span>\u003Cspan style=\"color:#E1E4E8\">slug\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\">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\">    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\"> getProductBySlug\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(slug);\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\">`/produits/${\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:#F97583\">    const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> title\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#F97583\"> await\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> page.\u003C/span>\u003Cspan style=\"color:#B392F0\">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\">(title).\u003C/span>\u003Cspan style=\"color:#B392F0\">toBe\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(product.seoTitle);\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>\u003Cstrong>2. Alerte sur les titles dupliqués.\u003C/strong> Un script cron hebdomadaire crawle \u003Ccode>/produits/\u003C/code> avec Screaming Frog en mode CLI et alerte Slack si le ratio de titles dupliqués dépasse 5 %.\u003C/p>\n\u003Cp>\u003Cstrong>3. Validation du schéma de la query Contentful.\u003C/strong> L'équipe ajoute un type TypeScript strict pour le retour de \u003Ccode>getProductBySlug\u003C/code>, généré depuis le content model Contentful avec \u003Ca href=\"https://github.com/intercom/contentful-typescript-codegen\">\u003Ccode>contentful-typescript-codegen\u003C/code>\u003C/a>. Si un champ est supprimé de la query \u003Ccode>select\u003C/code> mais utilisé dans le composant, TypeScript lève une erreur à la compilation.\u003C/p>\n\u003Ch3>La récupération\u003C/h3>\n\u003Cp>Le fix est déployé le jour J. Voici la timeline de récupération observée dans Search Console :\u003C/p>\n\u003Cul>\n\u003Cli>\u003Cstrong>J+3\u003C/strong> : Google recrawle 40 % des pages variantes. Les nouveaux titles apparaissent dans le rapport \"Pages\".\u003C/li>\n\u003Cli>\u003Cstrong>J+7\u003C/strong> : 92 % des pages recrawlées. Les impressions cessent de baisser.\u003C/li>\n\u003Cli>\u003Cstrong>J+12\u003C/strong> : les positions commencent à remonter sur les requêtes longue traîne.\u003C/li>\n\u003Cli>\u003Cstrong>J+19\u003C/strong> : retour à 94 % du trafic pré-incident. Les 6 % restants ne reviennent jamais complètement — probablement des requêtes où un concurrent a pris la position entre-temps.\u003C/li>\n\u003C/ul>\n\u003Cp>Au total, l'incident aura coûté 26 jours de régression + 19 jours de récupération. 45 jours. Environ 52K clics perdus.\u003C/p>\n\u003Cp>Ce type de latence de récupération est cohérent avec d'autres incidents documentés, comme celui d'une \u003Ca href=\"/blog/migration-vercel-vers-railway-perte-du-edge-isr-ttfb-multiplie-par-4\">migration Vercel vers Railway qui a multiplié le TTFB par 4\u003C/a> — où le retour au trafic nominal avait pris 23 jours.\u003C/p>\n\u003Ch2>Ce qu'on en retient\u003C/h2>\n\u003Cp>En architecture headless, le CMS et le front sont deux mondes. Un champ peut exister dans Contentful, être rempli par la rédaction, validé en preview — et ne jamais atteindre le HTML servi à Googlebot. Le SDK ne protège pas. TypeScript sans types générés ne protège pas. Les tests e2e classiques ne protègent pas.\u003C/p>\n\u003Cp>La seule protection fiable, c'est une vérification continue du HTML réellement servi, comparé aux données source. Un outil comme Seogard détecte ce type de divergence — un title identique sur 1 200 pages — en quelques heures, pas en 26 jours.\u003C/p>\n\u003Cp>Le champ existait. La donnée existait. Le mapping manquait. Trois lignes de code. 52 000 clics.\u003C/p>\n\u003Cpre>\u003Ccode>\u003C/code>\u003C/pre>",null,12,[18,19,20,21],"contentful","headless","next.js","mapping","Contentful + Next.js : title manquant, fallback H1 sur 1 200 pages","Mon Jun 15 2026 06:01:38 GMT+0000 (Coordinated Universal Time)",[25,40,55,69,85,97],{"_id":26,"slug":27,"__v":6,"author":7,"canonical":28,"category":29,"createdAt":30,"date":12,"description":31,"image":15,"imageAlt":15,"readingTime":16,"tags":32,"title":38,"updatedAt":39},"6a30222eaa6b273b0ca1e7dc","what-ai-overview-click-data-reveals-about-consumer-search-behavior-5-strategic-insights-for-cmos-via-sejournal-gregjarboe","https://seogard.io/blog/what-ai-overview-click-data-reveals-about-consumer-search-behavior-5-strategic-insights-for-cmos-via-sejournal-gregjarboe","Actualités SEO","2026-06-15T16:02:54.519Z","Les utilisateurs quotidiens d'AI Overview cliquent 3.5x plus sur les sources. Analyse technique des données et stratégies d'optimisation concrètes.",[33,34,35,36,37],"AI Overview","click data","search behavior","SGE","structured data","AI Overview Click Data : ce que les clics révèlent vraiment","Mon Jun 15 2026 16:02:54 GMT+0000 (Coordinated Universal Time)",{"_id":41,"slug":42,"__v":6,"author":7,"canonical":43,"category":29,"createdAt":44,"date":45,"description":46,"image":15,"imageAlt":15,"readingTime":16,"tags":47,"title":53,"updatedAt":54},"6a2e441caa6b273b0c22bc85","what-apple-s-gemini-powered-siri-means-for-search-visibility-via-sejournal-mattgsouthern","https://seogard.io/blog/what-apple-s-gemini-powered-siri-means-for-search-visibility-via-sejournal-mattgsouthern","2026-06-14T06:03:08.037Z","2026-06-14","Apple intègre Gemini dans Siri. Analyse technique des conséquences pour le crawl, le rendering, le structured data et la visibilité organique de vos pages.",[48,49,50,51,52],"siri","gemini","apple-intelligence","llm-seo","search-visibility","Siri + Gemini : impact concret sur la visibilité SEO","Sun Jun 14 2026 06:03:08 GMT+0000 (Coordinated Universal Time)",{"_id":56,"slug":57,"__v":6,"author":7,"canonical":58,"category":59,"createdAt":60,"date":45,"description":61,"image":15,"imageAlt":15,"readingTime":16,"tags":62,"title":67,"updatedAt":68},"6a2ed065aa6b273b0c9328d9","rank-math-update-nouveau-format-de-sitemaps-declenche-une-reindexation-complete","https://seogard.io/blog/rank-math-update-nouveau-format-de-sitemaps-declenche-une-reindexation-complete","CMS","2026-06-14T16:01:41.413Z","Un update Rank Math change le format des sitemaps. Google traite chaque URL comme nouvelle. Récit du pic de crawl, de la chute, et du fix.",[63,64,65,66],"rank math","sitemap","wordpress","reindexation","Rank Math sitemap : mise à jour qui force une réindexation","Sun Jun 14 2026 16:01:41 GMT+0000 (Coordinated Universal Time)",{"_id":70,"slug":71,"__v":6,"author":7,"canonical":72,"category":73,"createdAt":74,"date":75,"description":76,"image":15,"imageAlt":15,"readingTime":16,"tags":77,"title":83,"updatedAt":84},"6a2cf253aa6b273b0c0c9a5f","tanstack-router-ssr-title-pris-du-layout-au-lieu-de-la-leaf-route","https://seogard.io/blog/tanstack-router-ssr-title-pris-du-layout-au-lieu-de-la-leaf-route","Framework","2026-06-13T06:01:55.020Z","2026-06-13","Un e-commerce perd 40 % de clics organiques : TanStack Router applique le title du layout parent au lieu de la leaf route. Récit, diagnostic, fix.",[78,79,80,81,82],"tanstack router","react","ssr","title","meta tags","TanStack Router SSR : le title vient du layout, pas de la page","Sat Jun 13 2026 06:01:55 GMT+0000 (Coordinated Universal Time)",{"_id":86,"slug":87,"__v":6,"author":7,"canonical":88,"category":59,"createdAt":89,"date":75,"description":90,"image":15,"imageAlt":15,"readingTime":16,"tags":91,"title":95,"updatedAt":96},"6a2d7ef3aa6b273b0c80d69d","yoast-seo-desactive-par-un-update-plugin-fallback-meta-vides-sur-80-du-blog","https://seogard.io/blog/yoast-seo-desactive-par-un-update-plugin-fallback-meta-vides-sur-80-du-blog","2026-06-13T16:01:55.662Z","Un update WordPress désactive Yoast SEO sans alerte. 1 200 articles perdent leurs meta en silence. Récit, diagnostic technique et fix complet.",[65,92,93,94],"yoast","plugin","meta","Yoast SEO désactivé par un update : meta vides sur 80% du blog","Sat Jun 13 2026 16:01:55 GMT+0000 (Coordinated Universal Time)",{"_id":98,"slug":99,"__v":6,"author":7,"canonical":100,"category":73,"createdAt":101,"date":102,"description":103,"image":15,"imageAlt":15,"readingTime":104,"tags":105,"title":110,"updatedAt":111},"6a2ba0d0aa6b273b0cf5b507","astro-view-transitions-changement-de-route-ne-re-trigge-pas-le-head-update","https://seogard.io/blog/astro-view-transitions-changement-de-route-ne-re-trigge-pas-le-head-update","2026-06-12T06:01:52.619Z","2026-06-12","Un site Astro perd 40% de clics : les View Transitions ne mettent pas à jour les meta SEO lors des changements de route. Récit, diagnostic et fix.",11,[106,107,108,109],"astro","view transitions","head","spa","Astro View Transitions : meta head figées après navigation","Fri Jun 12 2026 06:01:52 GMT+0000 (Coordinated Universal Time)",{"categories":113},[114,117,121,125,128,131,135,138,141,145,149,152,155,159,162,165,168,171,175],{"category":29,"slug":115,"count":116},"actualites-seo",168,{"category":118,"slug":119,"count":120},"Migration","migration",18,{"category":122,"slug":123,"count":124},"Rendering","rendering",9,{"category":73,"slug":126,"count":127},"framework",8,{"category":129,"slug":130,"count":127},"Performance","performance",{"category":132,"slug":133,"count":134},"Crawl","crawl",7,{"category":136,"slug":137,"count":134},"Meta Tags","meta-tags",{"category":139,"slug":140,"count":134},"SEO Technique","seo-technique",{"category":142,"slug":143,"count":144},"Architecture","architecture",6,{"category":146,"slug":147,"count":148},"Monitoring","monitoring",5,{"category":150,"slug":151,"count":148},"JavaScript SEO","javascript-seo",{"category":153,"slug":154,"count":148},"Structured Data","structured-data",{"category":156,"slug":157,"count":158},"Outils","outils",4,{"category":160,"slug":161,"count":158},"Avancé","avance",{"category":163,"slug":164,"count":158},"Redirections","redirections",{"category":166,"slug":167,"count":158},"Refonte","refonte",{"category":169,"slug":170,"count":158},"E-commerce","e-commerce",{"category":172,"slug":173,"count":174},"Contenu","contenu",3,{"category":176,"slug":177,"count":174},"IA & SEO","ia-seo"]