[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"$fXDffIeP5q4eE2rR9Xw8UT3bUtkuoN2xdNp9xNKGQzYA":3,"$f60oAxnFHk1clpmAlHFzQGM1hwawOYFRPu229U_Pl-oM":25},{"_id":4,"slug":5,"__v":6,"author":7,"body":8,"canonical":9,"category":10,"createdAt":11,"date":12,"description":13,"htmlContent":14,"image":15,"imageAlt":15,"readingTime":16,"tags":17,"title":23,"updatedAt":24},"6a1dace0aa6b273b0c6f01ee","refonte-header-le-h1-remplace-par-un-div-title-par-le-design-system",0,"Equipe Seogard","# Refonte header : quand le design system remplace le H1 par un div sur 800 pages\n\nJeudi 16h40. L'équipe front d'une marketplace française de 12 000 pages merge la branche `ds/header-v2`. Le nouveau composant `\u003CTitle>` du design system remplace l'ancien `\u003CPageHeader>`. Dans le navigateur, rien ne change. La font est la même, le margin est le même, le texte est le même. Visuellement, c'est pixel-perfect. Trois semaines plus tard, Search Console affiche −14 000 clics hebdomadaires sur les pages catégorie et produit. Le H1 a disparu de 800 pages. Personne ne l'a vu partir.\n\n## Lundi, T+18 jours — L'alerte que personne n'attendait\n\nLe Head of SEO ouvre Search Console à 8h52, comme chaque lundi. L'onglet Performance affiche une courbe descendante nette sur les 21 derniers jours. Les clics organiques sur les pages catégorie sont passés de 78 000 par semaine à 64 000. Les pages produit suivent : −11 % d'impressions.\n\nPremier réflexe : vérifier si un core update Google est passé. La timeline ne colle pas tout à fait. Le [May 2026 Core Update](/blog/google-may-2026-core-update-rolling-out-now) a démarré mi-mai, mais le drop est antérieur — il commence le 8 mai, soit le lendemain du déploiement du nouveau header.\n\nDeuxième hypothèse : un problème d'indexation. Le rapport \"Pages\" de Search Console ne montre pas d'anomalie flagrante. Pas de spike de \"Non indexée — Détectée, actuellement non indexée\". Les pages sont toujours dans l'index.\n\nÀ 9h30, le Lead SEO lance un crawl Screaming Frog sur le sous-dossier `/categorie/`. 847 URLs crawlées. Le filtre H1 renvoie un résultat qui glace : **0 H1 détecté sur 803 pages**. Les 44 restantes sont des pages statiques héritées, non migrées vers le nouveau composant.\n\nIl relance le crawl sur `/produit/`. Même constat : 0 H1 sur les 4 200 pages produit qui utilisent le nouveau header.\n\nÀ 10h15, Slack explose. Le Lead SEO poste une capture d'écran du rapport Screaming Frog dans `#seo-alerts` :\n\n> \"803 pages catégorie sans H1. 4 200 pages produit sans H1. Depuis le 7 mai. Qui a touché le header ?\"\n\nLe Tech Lead front répond en trois minutes : \"On a migré vers `\u003CTitle>` du design system. C'est le même rendu visuel.\"\n\nCe n'est pas le même rendu HTML. Et c'est exactement le problème.\n\nÀ 11h, l'équipe estime l'impact : 5 003 pages affectées. Le trafic organique sur ces pages représente 62 % du trafic total du site. La chute mesurée : −18 % de clics sur 21 jours, soit environ 42 000 clics perdus. Le panier moyen étant à 67 €, le manque à gagner estimé frôle les 85 000 €.\n\nPersonne ne pensait qu'un changement de composant header pouvait coûter aussi cher.\n\n## Le bug : un composant générique, un prop manquant, un H1 volatilisé\n\nPour comprendre la régression, il faut remonter à l'architecture du design system.\n\nL'équipe front utilise un design system interne construit en React 18 avec TypeScript. Le système expose un composant `\u003CTitle>` pensé pour être générique. Il sert dans les modales, les cartes produit, les sidebars, les headers de page. Un seul composant, partout.\n\nVoici sa signature simplifiée :\n\n```tsx\n// design-system/src/components/Title/Title.tsx\nimport React from 'react';\n\ninterface TitleProps {\n  children: React.ReactNode;\n  size?: 'sm' | 'md' | 'lg' | 'xl';\n  weight?: 'regular' | 'bold';\n  as?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'span' | 'div';\n  className?: string;\n}\n\nconst Title: React.FC\u003CTitleProps> = ({\n  children,\n  size = 'lg',\n  weight = 'bold',\n  as = 'div',  // \u003C-- ici\n  className = '',\n}) => {\n  const Tag = as;\n  return (\n    \u003CTag\n      className={`ds-title ds-title--${size} ds-title--${weight} ${className}`}\n    >\n      {children}\n    \u003C/Tag>\n  );\n};\n\nexport default Title;\n```\n\nLe défaut de `as` est `div`. Pas `h2`, pas `h1`. Un `div`. Le choix a une logique côté design system : dans une modale ou une carte, un H1 serait sémantiquement faux. Le composant est neutre par défaut.\n\nL'ancien composant `\u003CPageHeader>` avait un comportement différent :\n\n```tsx\n// legacy/components/PageHeader.tsx\nconst PageHeader: React.FC\u003C{ title: string }> = ({ title }) => {\n  return (\n    \u003Cheader className=\"page-header\">\n      \u003Ch1 className=\"page-header__title\">{title}\u003C/h1>\n    \u003C/header>\n  );\n};\n```\n\nLe H1 était codé en dur. Impossible de l'oublier.\n\nLors de la migration, le développeur a remplacé les appels ainsi :\n\n```tsx\n// Avant\n\u003CPageHeader title={category.name} />\n\n// Après\n\u003CTitle size=\"xl\" weight=\"bold\">{category.name}\u003C/Title>\n```\n\nPas de prop `as=\"h1\"`. Le développeur n'y a pas pensé — le rendu visuel était identique. La review de PR non plus. Les deux reviewers ont validé le diff en se focalisant sur le style.\n\n### Ce que voit le navigateur vs ce que voit Googlebot\n\nDans Chrome, voici le DOM inspecté sur une page catégorie :\n\n```html\n\u003C!-- Rendu HTML réel après migration -->\n\u003Cdiv class=\"ds-title ds-title--xl ds-title--bold\">\n  Chaussures de randonnée homme\n\u003C/div>\n```\n\nAvant la migration :\n\n```html\n\u003C!-- Rendu HTML avant migration -->\n\u003Cheader class=\"page-header\">\n  \u003Ch1 class=\"page-header__title\">\n    Chaussures de randonnée homme\n  \u003C/h1>\n\u003C/header>\n```\n\nPour un humain, les deux rendus sont visuellement identiques. La font-size est la même (`ds-title--xl` applique `font-size: 2.25rem`, exactement comme `page-header__title`). Le font-weight est le même. Le margin-bottom est le même.\n\nPour Googlebot, la différence est structurelle. Le H1 est un signal sémantique fort. Il confirme le sujet principal de la page. Un `div` avec une classe CSS ne porte aucune valeur sémantique. Google ne lit pas les classes CSS pour déduire la hiérarchie de contenu.\n\nPour vérifier ce que Googlebot perçoit réellement, l'outil d'inspection d'URL dans Search Console permet de voir le HTML rendu. Sur une page catégorie affectée :\n\n```\nOutil d'inspection d'URL → \"Afficher la page testée\" → Onglet HTML\nRecherche : \"\u003Ch1\" → 0 résultat\nRecherche : \"ds-title--xl\" → 1 résultat (div)\n```\n\nAucun H1 dans le rendu servi à Google.\n\n### Pourquoi les tests n'ont rien détecté\n\nL'équipe avait pourtant une suite de tests. Mais aucun ne testait la sémantique HTML.\n\nLes tests unitaires du composant `\u003CTitle>` vérifient que le rendu correspond aux props :\n\n```tsx\n// design-system/src/components/Title/Title.test.tsx\nit('renders with default props', () => {\n  const { container } = render(\u003CTitle>Hello\u003C/Title>);\n  expect(container.querySelector('.ds-title')).toBeInTheDocument();\n  // Aucune assertion sur le tag HTML\n});\n```\n\nLes tests E2E (Cypress) vérifient la visibilité du texte :\n\n```ts\n// cypress/e2e/category.cy.ts\nit('displays the category name', () => {\n  cy.visit('/categorie/chaussures-randonnee-homme');\n  cy.contains('Chaussures de randonnée homme').should('be.visible');\n  // Aucune assertion sur la balise h1\n});\n```\n\nLes tests visuels (Chromatic) comparent des screenshots pixel par pixel. Le design n'a pas changé, donc les snapshots passent au vert.\n\nAucun test ne pose la question : \"Est-ce que cette page a un H1 ?\"\n\nC'est un angle mort classique des design systems. Le système est conçu pour l'UI, pas pour le SEO. Les tests valident l'apparence et le comportement interactif, jamais la sémantique HTML. Le H1 n'existe dans aucune assertion, dans aucun contrat d'interface, dans aucun linter.\n\n### La propagation silencieuse\n\nLe composant `\u003CTitle>` a été intégré progressivement. La PR initiale ne touchait que 3 templates. Mais une fois mergée, d'autres développeurs ont suivi le pattern sans poser de questions. En 18 jours, le composant a été adopté sur :\n\n- 803 pages catégorie\n- 4 200 pages produit (via le template produit unifié)\n\nLe tout en 7 PRs distinctes, dont 5 mergées sans review SEO. L'effet boule de neige est typique des design systems : un composant adopté devient un standard de facto. Si le standard a un défaut, le défaut se propage à l'échelle du site.\n\n## Le fix : trois lignes et un process\n\n### Le patch immédiat\n\nLe correctif le plus rapide : ajouter `as=\"h1\"` dans chaque template de page qui utilise `\u003CTitle>` comme titre principal.\n\n```tsx\n// templates/CategoryPage.tsx — fix\n\u003CTitle size=\"xl\" weight=\"bold\" as=\"h1\">\n  {category.name}\n\u003C/Title>\n\n// templates/ProductPage.tsx — fix\n\u003CTitle size=\"xl\" weight=\"bold\" as=\"h1\">\n  {product.name}\n\u003C/Title>\n```\n\nTrois caractères ajoutés par template. Le fix est mergé le lundi à 14h20, déployé à 14h35.\n\n### Le filet de sécurité dans le design system\n\nPour éviter la récidive, le Tech Lead ajoute une règle ESLint custom :\n\n```js\n// .eslintrc.js — règle custom\nmodule.exports = {\n  rules: {\n    'no-restricted-syntax': [\n      'error',\n      {\n        selector:\n          'JSXElement[openingElement.name.name=\"Title\"]:not([openingElement.attributes[name.name=\"as\"]])',\n        message:\n          'Le composant \u003CTitle> doit toujours recevoir un prop \"as\" explicite. Aucun défaut implicite n\\'est autorisé pour les balises sémantiques.',\n      },\n    ],\n  },\n};\n```\n\nDésormais, tout usage de `\u003CTitle>` sans prop `as` explicite déclenche une erreur de lint. Le développeur est forcé de choisir : `h1`, `h2`, `h3`, `span`, ou `div`. Le choix est conscient.\n\n### Le test E2E sémantique\n\nL'équipe ajoute un test dédié :\n\n```ts\n// cypress/e2e/seo/heading-structure.cy.ts\nconst criticalPages = [\n  '/categorie/chaussures-randonnee-homme',\n  '/categorie/vestes-ski-femme',\n  '/produit/trail-x500-gore-tex',\n];\n\ncriticalPages.forEach((url) => {\n  it(`${url} has exactly one H1`, () => {\n    cy.visit(url);\n    cy.get('h1').should('have.length', 1);\n    cy.get('h1').invoke('text').should('not.be.empty');\n  });\n});\n```\n\n### La vérification crawl post-fix\n\nLe lendemain du déploiement, un crawl Screaming Frog confirme la correction :\n\n```\nscreaming-frog --headless \\\n  --crawl https://www.example.com/categorie/ \\\n  --export-tabs \"H1\" \\\n  --output /tmp/h1-audit.csv\n```\n\nRésultat : 803/803 pages catégorie avec un H1 unique. 4 200/4 200 pages produit avec un H1 unique.\n\n### Invalidation et re-crawl\n\nLe fix HTML est immédiat côté serveur — pas de cache CDN sur le HTML des pages catégorie et produit (SSR dynamique). Pour accélérer la prise en compte par Google, l'équipe soumet les sitemaps mis à jour via Search Console et demande une inspection d'URL sur 10 pages critiques.\n\n### Le temps de récupération\n\nLa reprise n'est pas instantanée. Voici la chronologie observée :\n\n- **J+2 après fix** : Google recrawle 60 % des pages catégorie (vérifié via les logs serveur, user-agent Googlebot).\n- **J+5** : 95 % des pages recrawlées. L'outil d'inspection d'URL confirme la présence du H1 sur les pages testées.\n- **J+9** : les impressions Search Console repartent à la hausse sur les pages catégorie.\n- **J+14** : les clics retrouvent le niveau pré-incident sur les catégories. Les pages produit suivent avec 3 jours de retard.\n- **J+21** : retour complet au niveau de trafic antérieur.\n\nAu total, 21 jours de baisse, 21 jours de récupération. L'impact cumulé estimé : −84 000 clics, soit environ 170 000 € de manque à gagner sur un mois et demi. Pour un prop manquant de trois caractères.\n\nLe contexte du [May 2026 Core Update](/blog/google-begins-rolling-out-may-2026-core-update-via-sejournal-mattgsouthern) a probablement amplifié l'effet. Un core update réévalue les signaux on-page. Une page sans H1 pendant un recalcul de ranking est une page qui perd plus vite qu'en temps normal. L'équipe a joué de malchance sur le timing — mais la cause racine reste la même.\n\nLe Lead SEO ajoute désormais un crawl sémantique hebdomadaire automatisé via un script Node qui vérifie la présence d'un H1 unique sur un échantillon de 200 pages. Toute anomalie déclenche une alerte Slack dans `#seo-alerts`.\n\nCe type de régression silencieuse rappelle d'autres incidents documentés. Lors d'une [migration Next.js Pages Router vers App Router](/blog/migration-next-js-pages-router-vers-app-router-les-metadata-ignorees-sur-les-pages-client), des métadonnées disparaissaient sans alerte. Sur une [migration Nuxt 2 vers Nuxt 3](/blog/migration-nuxt-2-vers-nuxt-3-200-pages-en-fallback-layout-pendant-6-semaines), 200 pages ont tourné sur un layout par défaut pendant 6 semaines. Le pattern est toujours le même : un changement invisible côté navigateur, dévastateur côté crawler.\n\n## Ce qu'on en retient\n\nLes design systems sont faits pour l'UI, pas pour le SEO. Les defaults d'un composant générique ne connaissent pas le contexte de la page. Un `div` est un choix raisonnable pour un composant universel. Mais quand ce composant atterrit en haut d'une page catégorie, le `div` est une bombe silencieuse.\n\nLa seule défense fiable : des assertions sémantiques dans la CI, un lint strict sur les props critiques, et un monitoring continu du HTML rendu tel que Googlebot le voit. Seogard détecte exactement ce type de divergence — un H1 présent lundi, absent mardi — et alerte avant que Search Console ne montre la courbe descendante trois semaines plus tard.\n\nLes tests visuels ne protègent pas la sémantique. Seul un test qui inspecte le DOM protège le DOM.\n```","https://seogard.io/blog/refonte-header-le-h1-remplace-par-un-div-title-par-le-design-system","Refonte","2026-06-01T16:01:36.649Z","2026-06-01","Un composant générique remplace silencieusement le H1 par un div sur 800 pages. Récit du bug, diagnostic technique et fix complet.","\u003Ch1>Refonte header : quand le design system remplace le H1 par un div sur 800 pages\u003C/h1>\n\u003Cp>Jeudi 16h40. L'équipe front d'une marketplace française de 12 000 pages merge la branche \u003Ccode>ds/header-v2\u003C/code>. Le nouveau composant \u003Ccode>&#x3C;Title>\u003C/code> du design system remplace l'ancien \u003Ccode>&#x3C;PageHeader>\u003C/code>. Dans le navigateur, rien ne change. La font est la même, le margin est le même, le texte est le même. Visuellement, c'est pixel-perfect. Trois semaines plus tard, Search Console affiche −14 000 clics hebdomadaires sur les pages catégorie et produit. Le H1 a disparu de 800 pages. Personne ne l'a vu partir.\u003C/p>\n\u003Ch2>Lundi, T+18 jours — L'alerte que personne n'attendait\u003C/h2>\n\u003Cp>Le Head of SEO ouvre Search Console à 8h52, comme chaque lundi. L'onglet Performance affiche une courbe descendante nette sur les 21 derniers jours. Les clics organiques sur les pages catégorie sont passés de 78 000 par semaine à 64 000. Les pages produit suivent : −11 % d'impressions.\u003C/p>\n\u003Cp>Premier réflexe : vérifier si un core update Google est passé. La timeline ne colle pas tout à fait. Le \u003Ca href=\"/blog/google-may-2026-core-update-rolling-out-now\">May 2026 Core Update\u003C/a> a démarré mi-mai, mais le drop est antérieur — il commence le 8 mai, soit le lendemain du déploiement du nouveau header.\u003C/p>\n\u003Cp>Deuxième hypothèse : un problème d'indexation. Le rapport \"Pages\" de Search Console ne montre pas d'anomalie flagrante. Pas de spike de \"Non indexée — Détectée, actuellement non indexée\". Les pages sont toujours dans l'index.\u003C/p>\n\u003Cp>À 9h30, le Lead SEO lance un crawl Screaming Frog sur le sous-dossier \u003Ccode>/categorie/\u003C/code>. 847 URLs crawlées. Le filtre H1 renvoie un résultat qui glace : \u003Cstrong>0 H1 détecté sur 803 pages\u003C/strong>. Les 44 restantes sont des pages statiques héritées, non migrées vers le nouveau composant.\u003C/p>\n\u003Cp>Il relance le crawl sur \u003Ccode>/produit/\u003C/code>. Même constat : 0 H1 sur les 4 200 pages produit qui utilisent le nouveau header.\u003C/p>\n\u003Cp>À 10h15, Slack explose. Le Lead SEO poste une capture d'écran du rapport Screaming Frog dans \u003Ccode>#seo-alerts\u003C/code> :\u003C/p>\n\u003Cblockquote>\n\u003Cp>\"803 pages catégorie sans H1. 4 200 pages produit sans H1. Depuis le 7 mai. Qui a touché le header ?\"\u003C/p>\n\u003C/blockquote>\n\u003Cp>Le Tech Lead front répond en trois minutes : \"On a migré vers \u003Ccode>&#x3C;Title>\u003C/code> du design system. C'est le même rendu visuel.\"\u003C/p>\n\u003Cp>Ce n'est pas le même rendu HTML. Et c'est exactement le problème.\u003C/p>\n\u003Cp>À 11h, l'équipe estime l'impact : 5 003 pages affectées. Le trafic organique sur ces pages représente 62 % du trafic total du site. La chute mesurée : −18 % de clics sur 21 jours, soit environ 42 000 clics perdus. Le panier moyen étant à 67 €, le manque à gagner estimé frôle les 85 000 €.\u003C/p>\n\u003Cp>Personne ne pensait qu'un changement de composant header pouvait coûter aussi cher.\u003C/p>\n\u003Ch2>Le bug : un composant générique, un prop manquant, un H1 volatilisé\u003C/h2>\n\u003Cp>Pour comprendre la régression, il faut remonter à l'architecture du design system.\u003C/p>\n\u003Cp>L'équipe front utilise un design system interne construit en React 18 avec TypeScript. Le système expose un composant \u003Ccode>&#x3C;Title>\u003C/code> pensé pour être générique. Il sert dans les modales, les cartes produit, les sidebars, les headers de page. Un seul composant, partout.\u003C/p>\n\u003Cp>Voici sa signature simplifié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\">// design-system/src/components/Title/Title.tsx\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> React \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'react'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">interface\u003C/span>\u003Cspan style=\"color:#B392F0\"> TitleProps\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FFAB70\">  children\u003C/span>\u003Cspan style=\"color:#F97583\">:\u003C/span>\u003Cspan style=\"color:#B392F0\"> React\u003C/span>\u003Cspan style=\"color:#E1E4E8\">.\u003C/span>\u003Cspan style=\"color:#B392F0\">ReactNode\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FFAB70\">  size\u003C/span>\u003Cspan style=\"color:#F97583\">?:\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'sm'\u003C/span>\u003Cspan style=\"color:#F97583\"> |\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'md'\u003C/span>\u003Cspan style=\"color:#F97583\"> |\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'lg'\u003C/span>\u003Cspan style=\"color:#F97583\"> |\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'xl'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FFAB70\">  weight\u003C/span>\u003Cspan style=\"color:#F97583\">?:\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'regular'\u003C/span>\u003Cspan style=\"color:#F97583\"> |\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'bold'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FFAB70\">  as\u003C/span>\u003Cspan style=\"color:#F97583\">?:\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'h1'\u003C/span>\u003Cspan style=\"color:#F97583\"> |\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'h2'\u003C/span>\u003Cspan style=\"color:#F97583\"> |\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'h3'\u003C/span>\u003Cspan style=\"color:#F97583\"> |\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'h4'\u003C/span>\u003Cspan style=\"color:#F97583\"> |\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'h5'\u003C/span>\u003Cspan style=\"color:#F97583\"> |\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'h6'\u003C/span>\u003Cspan style=\"color:#F97583\"> |\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'span'\u003C/span>\u003Cspan style=\"color:#F97583\"> |\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'div'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FFAB70\">  className\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\">const\u003C/span>\u003Cspan style=\"color:#B392F0\"> Title\u003C/span>\u003Cspan style=\"color:#F97583\">:\u003C/span>\u003Cspan style=\"color:#B392F0\"> React\u003C/span>\u003Cspan style=\"color:#E1E4E8\">.\u003C/span>\u003Cspan style=\"color:#B392F0\">FC\u003C/span>\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C/span>\u003Cspan style=\"color:#B392F0\">TitleProps\u003C/span>\u003Cspan style=\"color:#E1E4E8\">> \u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> ({\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  children,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  size \u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'lg'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  weight \u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'bold'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  as\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> = \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'div'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,  \u003C/span>\u003Cspan style=\"color:#6A737D\">// &#x3C;-- ici\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  className \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>\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\"> Tag\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> as;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  return\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> (\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    &#x3C;\u003C/span>\u003Cspan style=\"color:#79B8FF\">Tag\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">      className\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#E1E4E8\">{\u003C/span>\u003Cspan style=\"color:#9ECBFF\">`ds-title ds-title--${\u003C/span>\u003Cspan style=\"color:#E1E4E8\">size\u003C/span>\u003Cspan style=\"color:#9ECBFF\">} ds-title--${\u003C/span>\u003Cspan style=\"color:#E1E4E8\">weight\u003C/span>\u003Cspan style=\"color:#9ECBFF\">} ${\u003C/span>\u003Cspan style=\"color:#E1E4E8\">className\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\">      {children}\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    &#x3C;/\u003C/span>\u003Cspan style=\"color:#79B8FF\">Tag\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:#E1E4E8\"> Title;\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Le défaut de \u003Ccode>as\u003C/code> est \u003Ccode>div\u003C/code>. Pas \u003Ccode>h2\u003C/code>, pas \u003Ccode>h1\u003C/code>. Un \u003Ccode>div\u003C/code>. Le choix a une logique côté design system : dans une modale ou une carte, un H1 serait sémantiquement faux. Le composant est neutre par défaut.\u003C/p>\n\u003Cp>L'ancien composant \u003Ccode>&#x3C;PageHeader>\u003C/code> avait un comportement différent :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">// legacy/components/PageHeader.tsx\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">const\u003C/span>\u003Cspan style=\"color:#B392F0\"> PageHeader\u003C/span>\u003Cspan style=\"color:#F97583\">:\u003C/span>\u003Cspan style=\"color:#B392F0\"> React\u003C/span>\u003Cspan style=\"color:#E1E4E8\">.\u003C/span>\u003Cspan style=\"color:#B392F0\">FC\u003C/span>\u003Cspan style=\"color:#E1E4E8\">&#x3C;{ \u003C/span>\u003Cspan style=\"color:#FFAB70\">title\u003C/span>\u003Cspan style=\"color:#F97583\">:\u003C/span>\u003Cspan style=\"color:#79B8FF\"> string\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> }> \u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> ({ \u003C/span>\u003Cspan style=\"color:#FFAB70\">title\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\">  return\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> (\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    &#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">header\u003C/span>\u003Cspan style=\"color:#B392F0\"> className\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"page-header\"\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:#B392F0\"> className\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"page-header__title\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>{title}&#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\">header\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  );\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">};\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Le H1 était codé en dur. Impossible de l'oublier.\u003C/p>\n\u003Cp>Lors de la migration, le développeur a remplacé les appels ainsi :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">// Avant\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C/span>\u003Cspan style=\"color:#79B8FF\">PageHeader\u003C/span>\u003Cspan style=\"color:#B392F0\"> title\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#E1E4E8\">{category.name} />\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">// Après\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C/span>\u003Cspan style=\"color:#79B8FF\">Title\u003C/span>\u003Cspan style=\"color:#B392F0\"> size\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"xl\"\u003C/span>\u003Cspan style=\"color:#B392F0\"> weight\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"bold\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>{category.name}&#x3C;/\u003C/span>\u003Cspan style=\"color:#79B8FF\">Title\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Pas de prop \u003Ccode>as=\"h1\"\u003C/code>. Le développeur n'y a pas pensé — le rendu visuel était identique. La review de PR non plus. Les deux reviewers ont validé le diff en se focalisant sur le style.\u003C/p>\n\u003Ch3>Ce que voit le navigateur vs ce que voit Googlebot\u003C/h3>\n\u003Cp>Dans Chrome, voici le DOM inspecté sur une page catégorie :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">&#x3C;!-- Rendu HTML réel après migration -->\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">div\u003C/span>\u003Cspan style=\"color:#B392F0\"> class\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"ds-title ds-title--xl ds-title--bold\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  Chaussures de randonnée homme\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">div\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Avant la migration :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">&#x3C;!-- Rendu HTML avant migration -->\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">header\u003C/span>\u003Cspan style=\"color:#B392F0\"> class\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"page-header\"\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:#B392F0\"> class\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"page-header__title\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    Chaussures de randonnée homme\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\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">header\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Pour un humain, les deux rendus sont visuellement identiques. La font-size est la même (\u003Ccode>ds-title--xl\u003C/code> applique \u003Ccode>font-size: 2.25rem\u003C/code>, exactement comme \u003Ccode>page-header__title\u003C/code>). Le font-weight est le même. Le margin-bottom est le même.\u003C/p>\n\u003Cp>Pour Googlebot, la différence est structurelle. Le H1 est un signal sémantique fort. Il confirme le sujet principal de la page. Un \u003Ccode>div\u003C/code> avec une classe CSS ne porte aucune valeur sémantique. Google ne lit pas les classes CSS pour déduire la hiérarchie de contenu.\u003C/p>\n\u003Cp>Pour vérifier ce que Googlebot perçoit réellement, l'outil d'inspection d'URL dans Search Console permet de voir le HTML rendu. Sur une page catégorie affectée :\u003C/p>\n\u003Cpre>\u003Ccode>Outil d'inspection d'URL → \"Afficher la page testée\" → Onglet HTML\nRecherche : \"&#x3C;h1\" → 0 résultat\nRecherche : \"ds-title--xl\" → 1 résultat (div)\n\u003C/code>\u003C/pre>\n\u003Cp>Aucun H1 dans le rendu servi à Google.\u003C/p>\n\u003Ch3>Pourquoi les tests n'ont rien détecté\u003C/h3>\n\u003Cp>L'équipe avait pourtant une suite de tests. Mais aucun ne testait la sémantique HTML.\u003C/p>\n\u003Cp>Les tests unitaires du composant \u003Ccode>&#x3C;Title>\u003C/code> vérifient que le rendu correspond aux props :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">// design-system/src/components/Title/Title.test.tsx\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">it\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">'renders with default props'\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:#E1E4E8\"> { \u003C/span>\u003Cspan style=\"color:#79B8FF\">container\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> } \u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#B392F0\"> render\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(&#x3C;\u003C/span>\u003Cspan style=\"color:#79B8FF\">Title\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>Hello&#x3C;/\u003C/span>\u003Cspan style=\"color:#79B8FF\">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\">(container.\u003C/span>\u003Cspan style=\"color:#B392F0\">querySelector\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">'.ds-title'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">)).\u003C/span>\u003Cspan style=\"color:#B392F0\">toBeInTheDocument\u003C/span>\u003Cspan style=\"color:#E1E4E8\">();\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">  // Aucune assertion sur le tag HTML\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">});\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Les tests E2E (Cypress) vérifient la visibilité du texte :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">// cypress/e2e/category.cy.ts\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">it\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">'displays the category name'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, () \u003C/span>\u003Cspan style=\"color:#F97583\">=>\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  cy.\u003C/span>\u003Cspan style=\"color:#B392F0\">visit\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">'/categorie/chaussures-randonnee-homme'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  cy.\u003C/span>\u003Cspan style=\"color:#B392F0\">contains\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">'Chaussures de randonnée homme'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">).\u003C/span>\u003Cspan style=\"color:#B392F0\">should\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">'be.visible'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">  // Aucune assertion sur la balise h1\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">});\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Les tests visuels (Chromatic) comparent des screenshots pixel par pixel. Le design n'a pas changé, donc les snapshots passent au vert.\u003C/p>\n\u003Cp>Aucun test ne pose la question : \"Est-ce que cette page a un H1 ?\"\u003C/p>\n\u003Cp>C'est un angle mort classique des design systems. Le système est conçu pour l'UI, pas pour le SEO. Les tests valident l'apparence et le comportement interactif, jamais la sémantique HTML. Le H1 n'existe dans aucune assertion, dans aucun contrat d'interface, dans aucun linter.\u003C/p>\n\u003Ch3>La propagation silencieuse\u003C/h3>\n\u003Cp>Le composant \u003Ccode>&#x3C;Title>\u003C/code> a été intégré progressivement. La PR initiale ne touchait que 3 templates. Mais une fois mergée, d'autres développeurs ont suivi le pattern sans poser de questions. En 18 jours, le composant a été adopté sur :\u003C/p>\n\u003Cul>\n\u003Cli>803 pages catégorie\u003C/li>\n\u003Cli>4 200 pages produit (via le template produit unifié)\u003C/li>\n\u003C/ul>\n\u003Cp>Le tout en 7 PRs distinctes, dont 5 mergées sans review SEO. L'effet boule de neige est typique des design systems : un composant adopté devient un standard de facto. Si le standard a un défaut, le défaut se propage à l'échelle du site.\u003C/p>\n\u003Ch2>Le fix : trois lignes et un process\u003C/h2>\n\u003Ch3>Le patch immédiat\u003C/h3>\n\u003Cp>Le correctif le plus rapide : ajouter \u003Ccode>as=\"h1\"\u003C/code> dans chaque template de page qui utilise \u003Ccode>&#x3C;Title>\u003C/code> comme titre principal.\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">// templates/CategoryPage.tsx — fix\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C/span>\u003Cspan style=\"color:#79B8FF\">Title\u003C/span>\u003Cspan style=\"color:#B392F0\"> size\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"xl\"\u003C/span>\u003Cspan style=\"color:#B392F0\"> weight\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"bold\"\u003C/span>\u003Cspan style=\"color:#B392F0\"> as\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"h1\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  {category.name}\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;/\u003C/span>\u003Cspan style=\"color:#79B8FF\">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\">// templates/ProductPage.tsx — fix\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C/span>\u003Cspan style=\"color:#79B8FF\">Title\u003C/span>\u003Cspan style=\"color:#B392F0\"> size\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"xl\"\u003C/span>\u003Cspan style=\"color:#B392F0\"> weight\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"bold\"\u003C/span>\u003Cspan style=\"color:#B392F0\"> as\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"h1\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  {product.name}\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;/\u003C/span>\u003Cspan style=\"color:#79B8FF\">Title\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Trois caractères ajoutés par template. Le fix est mergé le lundi à 14h20, déployé à 14h35.\u003C/p>\n\u003Ch3>Le filet de sécurité dans le design system\u003C/h3>\n\u003Cp>Pour éviter la récidive, le Tech Lead ajoute une règle ESLint custom :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">// .eslintrc.js — règle custom\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">module\u003C/span>\u003Cspan style=\"color:#E1E4E8\">.\u003C/span>\u003Cspan style=\"color:#79B8FF\">exports\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  rules: {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">    'no-restricted-syntax'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: [\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">      'error'\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\">        selector:\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">          'JSXElement[openingElement.name.name=\"Title\"]:not([openingElement.attributes[name.name=\"as\"]])'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">        message:\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">          'Le composant &#x3C;Title> doit toujours recevoir un prop \"as\" explicite. Aucun défaut implicite n\u003C/span>\u003Cspan style=\"color:#79B8FF\">\\'\u003C/span>\u003Cspan style=\"color:#9ECBFF\">est autorisé pour les balises sémantiques.'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      },\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    ],\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  },\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">};\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Désormais, tout usage de \u003Ccode>&#x3C;Title>\u003C/code> sans prop \u003Ccode>as\u003C/code> explicite déclenche une erreur de lint. Le développeur est forcé de choisir : \u003Ccode>h1\u003C/code>, \u003Ccode>h2\u003C/code>, \u003Ccode>h3\u003C/code>, \u003Ccode>span\u003C/code>, ou \u003Ccode>div\u003C/code>. Le choix est conscient.\u003C/p>\n\u003Ch3>Le test E2E sémantique\u003C/h3>\n\u003Cp>L'équipe ajoute un test dédié :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">// cypress/e2e/seo/heading-structure.cy.ts\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> criticalPages\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> [\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">  '/categorie/chaussures-randonnee-homme'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">  '/categorie/vestes-ski-femme'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">  '/produit/trail-x500-gore-tex'\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:#E1E4E8\">criticalPages.\u003C/span>\u003Cspan style=\"color:#B392F0\">forEach\u003C/span>\u003Cspan style=\"color:#E1E4E8\">((\u003C/span>\u003Cspan style=\"color:#FFAB70\">url\u003C/span>\u003Cspan style=\"color:#E1E4E8\">) \u003C/span>\u003Cspan style=\"color:#F97583\">=>\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">  it\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">`${\u003C/span>\u003Cspan style=\"color:#E1E4E8\">url\u003C/span>\u003Cspan style=\"color:#9ECBFF\">} has exactly one H1`\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, () \u003C/span>\u003Cspan style=\"color:#F97583\">=>\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    cy.\u003C/span>\u003Cspan style=\"color:#B392F0\">visit\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(url);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    cy.\u003C/span>\u003Cspan style=\"color:#B392F0\">get\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">'h1'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">).\u003C/span>\u003Cspan style=\"color:#B392F0\">should\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">'have.length'\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:#E1E4E8\">    cy.\u003C/span>\u003Cspan style=\"color:#B392F0\">get\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">'h1'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">).\u003C/span>\u003Cspan style=\"color:#B392F0\">invoke\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">'text'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">).\u003C/span>\u003Cspan style=\"color:#B392F0\">should\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">'not.be.empty'\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\u003Ch3>La vérification crawl post-fix\u003C/h3>\n\u003Cp>Le lendemain du déploiement, un crawl Screaming Frog confirme la correction :\u003C/p>\n\u003Cpre>\u003Ccode>screaming-frog --headless \\\n  --crawl https://www.example.com/categorie/ \\\n  --export-tabs \"H1\" \\\n  --output /tmp/h1-audit.csv\n\u003C/code>\u003C/pre>\n\u003Cp>Résultat : 803/803 pages catégorie avec un H1 unique. 4 200/4 200 pages produit avec un H1 unique.\u003C/p>\n\u003Ch3>Invalidation et re-crawl\u003C/h3>\n\u003Cp>Le fix HTML est immédiat côté serveur — pas de cache CDN sur le HTML des pages catégorie et produit (SSR dynamique). Pour accélérer la prise en compte par Google, l'équipe soumet les sitemaps mis à jour via Search Console et demande une inspection d'URL sur 10 pages critiques.\u003C/p>\n\u003Ch3>Le temps de récupération\u003C/h3>\n\u003Cp>La reprise n'est pas instantanée. Voici la chronologie observée :\u003C/p>\n\u003Cul>\n\u003Cli>\u003Cstrong>J+2 après fix\u003C/strong> : Google recrawle 60 % des pages catégorie (vérifié via les logs serveur, user-agent Googlebot).\u003C/li>\n\u003Cli>\u003Cstrong>J+5\u003C/strong> : 95 % des pages recrawlées. L'outil d'inspection d'URL confirme la présence du H1 sur les pages testées.\u003C/li>\n\u003Cli>\u003Cstrong>J+9\u003C/strong> : les impressions Search Console repartent à la hausse sur les pages catégorie.\u003C/li>\n\u003Cli>\u003Cstrong>J+14\u003C/strong> : les clics retrouvent le niveau pré-incident sur les catégories. Les pages produit suivent avec 3 jours de retard.\u003C/li>\n\u003Cli>\u003Cstrong>J+21\u003C/strong> : retour complet au niveau de trafic antérieur.\u003C/li>\n\u003C/ul>\n\u003Cp>Au total, 21 jours de baisse, 21 jours de récupération. L'impact cumulé estimé : −84 000 clics, soit environ 170 000 € de manque à gagner sur un mois et demi. Pour un prop manquant de trois caractères.\u003C/p>\n\u003Cp>Le contexte du \u003Ca href=\"/blog/google-begins-rolling-out-may-2026-core-update-via-sejournal-mattgsouthern\">May 2026 Core Update\u003C/a> a probablement amplifié l'effet. Un core update réévalue les signaux on-page. Une page sans H1 pendant un recalcul de ranking est une page qui perd plus vite qu'en temps normal. L'équipe a joué de malchance sur le timing — mais la cause racine reste la même.\u003C/p>\n\u003Cp>Le Lead SEO ajoute désormais un crawl sémantique hebdomadaire automatisé via un script Node qui vérifie la présence d'un H1 unique sur un échantillon de 200 pages. Toute anomalie déclenche une alerte Slack dans \u003Ccode>#seo-alerts\u003C/code>.\u003C/p>\n\u003Cp>Ce type de régression silencieuse rappelle d'autres incidents documentés. Lors d'une \u003Ca href=\"/blog/migration-next-js-pages-router-vers-app-router-les-metadata-ignorees-sur-les-pages-client\">migration Next.js Pages Router vers App Router\u003C/a>, des métadonnées disparaissaient sans alerte. Sur une \u003Ca href=\"/blog/migration-nuxt-2-vers-nuxt-3-200-pages-en-fallback-layout-pendant-6-semaines\">migration Nuxt 2 vers Nuxt 3\u003C/a>, 200 pages ont tourné sur un layout par défaut pendant 6 semaines. Le pattern est toujours le même : un changement invisible côté navigateur, dévastateur côté crawler.\u003C/p>\n\u003Ch2>Ce qu'on en retient\u003C/h2>\n\u003Cp>Les design systems sont faits pour l'UI, pas pour le SEO. Les defaults d'un composant générique ne connaissent pas le contexte de la page. Un \u003Ccode>div\u003C/code> est un choix raisonnable pour un composant universel. Mais quand ce composant atterrit en haut d'une page catégorie, le \u003Ccode>div\u003C/code> est une bombe silencieuse.\u003C/p>\n\u003Cp>La seule défense fiable : des assertions sémantiques dans la CI, un lint strict sur les props critiques, et un monitoring continu du HTML rendu tel que Googlebot le voit. Seogard détecte exactement ce type de divergence — un H1 présent lundi, absent mardi — et alerte avant que Search Console ne montre la courbe descendante trois semaines plus tard.\u003C/p>\n\u003Cp>Les tests visuels ne protègent pas la sémantique. Seul un test qui inspecte le DOM protège le DOM.\u003C/p>\n\u003Cpre>\u003Ccode>\u003C/code>\u003C/pre>",null,12,[18,19,20,21,22],"design system","h1","refonte","composant","régression SEO","Design system : un div remplace le H1 sur 800 pages","Mon Jun 01 2026 16:01:36 GMT+0000 (Coordinated Universal Time)",[]]