Design system : un div remplace le H1 sur 800 pages

Refonte header : quand le design system remplace le H1 par un div sur 800 pages

Jeudi 16h40. L'équipe front d'une marketplace française de 12 000 pages merge la branche ds/header-v2. Le nouveau composant <Title> du design system remplace l'ancien <PageHeader>. 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.

Lundi, T+18 jours — L'alerte que personne n'attendait

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.

Premier réflexe : vérifier si un core update Google est passé. La timeline ne colle pas tout à fait. Le May 2026 Core Update 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.

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.

À 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.

Il relance le crawl sur /produit/. Même constat : 0 H1 sur les 4 200 pages produit qui utilisent le nouveau header.

À 10h15, Slack explose. Le Lead SEO poste une capture d'écran du rapport Screaming Frog dans #seo-alerts :

"803 pages catégorie sans H1. 4 200 pages produit sans H1. Depuis le 7 mai. Qui a touché le header ?"

Le Tech Lead front répond en trois minutes : "On a migré vers <Title> du design system. C'est le même rendu visuel."

Ce n'est pas le même rendu HTML. Et c'est exactement le problème.

À 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 €.

Personne ne pensait qu'un changement de composant header pouvait coûter aussi cher.

Le bug : un composant générique, un prop manquant, un H1 volatilisé

Pour comprendre la régression, il faut remonter à l'architecture du design system.

L'équipe front utilise un design system interne construit en React 18 avec TypeScript. Le système expose un composant <Title> pensé pour être générique. Il sert dans les modales, les cartes produit, les sidebars, les headers de page. Un seul composant, partout.

Voici sa signature simplifiée :

// design-system/src/components/Title/Title.tsx
import React from 'react';

interface TitleProps {
  children: React.ReactNode;
  size?: 'sm' | 'md' | 'lg' | 'xl';
  weight?: 'regular' | 'bold';
  as?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'span' | 'div';
  className?: string;
}

const Title: React.FC<TitleProps> = ({
  children,
  size = 'lg',
  weight = 'bold',
  as = 'div',  // <-- ici
  className = '',
}) => {
  const Tag = as;
  return (
    <Tag
      className={`ds-title ds-title--${size} ds-title--${weight} ${className}`}
    >
      {children}
    </Tag>
  );
};

export default Title;

Le 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.

L'ancien composant <PageHeader> avait un comportement différent :

// legacy/components/PageHeader.tsx
const PageHeader: React.FC<{ title: string }> = ({ title }) => {
  return (
    <header className="page-header">
      <h1 className="page-header__title">{title}</h1>
    </header>
  );
};

Le H1 était codé en dur. Impossible de l'oublier.

Lors de la migration, le développeur a remplacé les appels ainsi :

// Avant
<PageHeader title={category.name} />

// Après
<Title size="xl" weight="bold">{category.name}</Title>

Pas 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.

Ce que voit le navigateur vs ce que voit Googlebot

Dans Chrome, voici le DOM inspecté sur une page catégorie :

<!-- Rendu HTML réel après migration -->
<div class="ds-title ds-title--xl ds-title--bold">
  Chaussures de randonnée homme
</div>

Avant la migration :

<!-- Rendu HTML avant migration -->
<header class="page-header">
  <h1 class="page-header__title">
    Chaussures de randonnée homme
  </h1>
</header>

Pour 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.

Pour 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.

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 :

Outil d'inspection d'URL → "Afficher la page testée" → Onglet HTML
Recherche : "<h1" → 0 résultat
Recherche : "ds-title--xl" → 1 résultat (div)

Aucun H1 dans le rendu servi à Google.

Pourquoi les tests n'ont rien détecté

L'équipe avait pourtant une suite de tests. Mais aucun ne testait la sémantique HTML.

Les tests unitaires du composant <Title> vérifient que le rendu correspond aux props :

// design-system/src/components/Title/Title.test.tsx
it('renders with default props', () => {
  const { container } = render(<Title>Hello</Title>);
  expect(container.querySelector('.ds-title')).toBeInTheDocument();
  // Aucune assertion sur le tag HTML
});

Les tests E2E (Cypress) vérifient la visibilité du texte :

// cypress/e2e/category.cy.ts
it('displays the category name', () => {
  cy.visit('/categorie/chaussures-randonnee-homme');
  cy.contains('Chaussures de randonnée homme').should('be.visible');
  // Aucune assertion sur la balise h1
});

Les tests visuels (Chromatic) comparent des screenshots pixel par pixel. Le design n'a pas changé, donc les snapshots passent au vert.

Aucun test ne pose la question : "Est-ce que cette page a un H1 ?"

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.

La propagation silencieuse

Le composant <Title> 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 :

  • 803 pages catégorie
  • 4 200 pages produit (via le template produit unifié)

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.

Le fix : trois lignes et un process

Le patch immédiat

Le correctif le plus rapide : ajouter as="h1" dans chaque template de page qui utilise <Title> comme titre principal.

// templates/CategoryPage.tsx — fix
<Title size="xl" weight="bold" as="h1">
  {category.name}
</Title>

// templates/ProductPage.tsx — fix
<Title size="xl" weight="bold" as="h1">
  {product.name}
</Title>

Trois caractères ajoutés par template. Le fix est mergé le lundi à 14h20, déployé à 14h35.

Le filet de sécurité dans le design system

Pour éviter la récidive, le Tech Lead ajoute une règle ESLint custom :

// .eslintrc.js — règle custom
module.exports = {
  rules: {
    'no-restricted-syntax': [
      'error',
      {
        selector:
          'JSXElement[openingElement.name.name="Title"]:not([openingElement.attributes[name.name="as"]])',
        message:
          'Le composant <Title> doit toujours recevoir un prop "as" explicite. Aucun défaut implicite n\'est autorisé pour les balises sémantiques.',
      },
    ],
  },
};

Désormais, tout usage de <Title> 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.

Le test E2E sémantique

L'équipe ajoute un test dédié :

// cypress/e2e/seo/heading-structure.cy.ts
const criticalPages = [
  '/categorie/chaussures-randonnee-homme',
  '/categorie/vestes-ski-femme',
  '/produit/trail-x500-gore-tex',
];

criticalPages.forEach((url) => {
  it(`${url} has exactly one H1`, () => {
    cy.visit(url);
    cy.get('h1').should('have.length', 1);
    cy.get('h1').invoke('text').should('not.be.empty');
  });
});

La vérification crawl post-fix

Le lendemain du déploiement, un crawl Screaming Frog confirme la correction :

screaming-frog --headless \
  --crawl https://www.example.com/categorie/ \
  --export-tabs "H1" \
  --output /tmp/h1-audit.csv

Résultat : 803/803 pages catégorie avec un H1 unique. 4 200/4 200 pages produit avec un H1 unique.

Invalidation et re-crawl

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.

Le temps de récupération

La reprise n'est pas instantanée. Voici la chronologie observée :

  • J+2 après fix : Google recrawle 60 % des pages catégorie (vérifié via les logs serveur, user-agent Googlebot).
  • J+5 : 95 % des pages recrawlées. L'outil d'inspection d'URL confirme la présence du H1 sur les pages testées.
  • J+9 : les impressions Search Console repartent à la hausse sur les pages catégorie.
  • J+14 : les clics retrouvent le niveau pré-incident sur les catégories. Les pages produit suivent avec 3 jours de retard.
  • J+21 : retour complet au niveau de trafic antérieur.

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.

Le contexte du May 2026 Core Update 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.

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 #seo-alerts.

Ce type de régression silencieuse rappelle d'autres incidents documentés. Lors d'une migration Next.js Pages Router vers App Router, des métadonnées disparaissaient sans alerte. Sur une migration Nuxt 2 vers Nuxt 3, 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.

Ce qu'on en retient

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 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.

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.

Les tests visuels ne protègent pas la sémantique. Seul un test qui inspecte le DOM protège le DOM.