Design system React : quand un composant Heading rend des div et détruit la sémantique de 1 200 pages
Mardi 14h20. Le design system interne, fraîchement déployé sur l'ensemble du catalogue produit, passe en production. Dans Storybook, chaque composant est impeccable. Dans le navigateur, les titres s'affichent avec les bonnes tailles, les bonnes couleurs, les bons espacements. Personne ne regarde le HTML rendu. Pendant 19 jours, Googlebot crawle 1 247 pages produit qui n'ont plus un seul heading sémantique. Que des <div>.
Mercredi, J+8 — "Pourquoi les impressions baissent ?"
Le premier signal arrive par Slack. L'acquisition manager partage une capture d'écran de Google Search Console. Le rapport Performance montre un décrochage net sur les requêtes de type [catégorie] + [marque]. Les impressions ont chuté de 31 % sur les sept derniers jours comparés à la période précédente. Les clics suivent : −2 400 clics hebdomadaires.
L'équipe SEO ouvre Screaming Frog. Un crawl du répertoire /produits/ remonte 1 247 URLs. Le rapport "H1" affiche un résultat brutal : 0 pages avec un H1 détecté. Zéro.
Premier réflexe : vérifier si Screaming Frog est bien configuré pour le rendu JavaScript. L'option "JavaScript Rendering" est active, le moteur Chromium est à jour. Le crawl est relancé en mode "View Rendered Page" sur cinq URLs au hasard. Même résultat.
L'hypothèse initiale de l'équipe : un problème de rendu côté serveur. Le site tourne sur Next.js 14 (App Router). Peut-être que le SSR ne rend pas les composants correctement. Un dev ouvre une URL dans Chrome, fait View Source. Le HTML brut arrive du serveur. Les balises sont là. Mais ce ne sont pas des <h1>, ni des <h2>. Ce sont des <div>.
L'équipe vérifie cinq, dix, vingt pages. Toujours pareil. Chaque titre de produit, chaque sous-titre de section — tout est rendu en <div> avec des classes CSS. Visuellement identique. Sémantiquement vide.
Le lead dev ouvre le composant Heading dans le repo du design system. Et il comprend.
Le bug : un composant polymorphe sans overrides
Le design system de cette marketplace française (15 000 SKU, 1 247 pages produit indexées, 85K visites organiques mensuelles) a été reconstruit six semaines plus tôt. L'équipe design a migré d'un système ad hoc vers une bibliothèque de composants React centralisée, versionée, publiée sur un registre npm interne.
Le composant Heading a été conçu comme un composant polymorphe. L'idée : un seul composant pour tous les niveaux de titre, avec une prop as qui contrôle la balise HTML rendue.
Voici le composant tel qu'il existait dans le design system (v2.4.0) :
// packages/ui/src/components/Heading/Heading.tsx
import React from 'react';
import { cn } from '../../utils/cn';
type HeadingLevel = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'div' | 'span';
interface HeadingProps {
as?: HeadingLevel;
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
children: React.ReactNode;
className?: string;
}
export const Heading: React.FC<HeadingProps> = ({
as = 'div',
size = 'md',
children,
className,
}) => {
const Component = as;
return (
<Component
className={cn(
'font-heading font-bold tracking-tight',
{
'text-xs': size === 'xs',
'text-sm': size === 'sm',
'text-base': size === 'md',
'text-xl': size === 'lg',
'text-2xl': size === 'xl',
'text-3xl': size === '2xl',
},
className
)}
>
{children}
</Component>
);
};
Le problème est sur la ligne 14 : as = 'div'.
La valeur par défaut de la prop as est div. Pas h2. Pas h3. Un div.
Le designer qui a créé le composant a raisonné en termes visuels. Dans Figma, le composant "Heading" a des variantes par taille : 2xl pour les titres principaux, lg pour les sous-titres, sm pour les titres de cards. La sémantique HTML — le choix entre h1, h2, h3 — n'apparaît nulle part dans les specs Figma. Elle était censée être gérée "côté dev, au moment de l'intégration".
Sauf que les développeurs, en consommant le composant, ont fait confiance à l'API. Voici comment le composant était utilisé dans le template produit :
// app/produits/[slug]/page.tsx
import { Heading } from '@acme/ui';
export default function ProductPage({ product }) {
return (
<main>
<Heading size="2xl">{product.name}</Heading>
<section>
<Heading size="lg">Caractéristiques</Heading>
{/* ... */}
</section>
<section>
<Heading size="lg">Avis clients</Heading>
{/* ... */}
</section>
<section>
<Heading size="md">Produits similaires</Heading>
{/* ... */}
</section>
</main>
);
}
Aucun as n'est passé. Le développeur a utilisé size pour contrôler l'apparence. La prop as prend sa valeur par défaut : div. Partout.
Le HTML rendu côté serveur pour chaque page produit ressemblait à ceci :
<main>
<div class="font-heading font-bold tracking-tight text-3xl">
Chaussure de trail UltraGrip 3000
</div>
<section>
<div class="font-heading font-bold tracking-tight text-xl">
Caractéristiques
</div>
<!-- ... -->
</section>
<section>
<div class="font-heading font-bold tracking-tight text-xl">
Avis clients
</div>
<!-- ... -->
</section>
<section>
<div class="font-heading font-bold tracking-tight text-base">
Produits similaires
</div>
<!-- ... -->
</section>
</main>
Zéro heading sémantique. Googlebot voit une page sans aucune structure de titres. Le nom du produit, qui était autrefois un <h1>, est devenu un <div> stylisé en gros.
Pourquoi personne n'a rien vu
Trois raisons.
1. Le rendu visuel était parfait. Le composant Heading appliquait les bonnes classes Tailwind. Dans le navigateur, les titres avaient la bonne taille, la bonne graisse. L'œil humain ne distingue pas un <div class="text-3xl font-bold"> d'un <h1 class="text-3xl font-bold">.
2. Les tests ne couvraient pas la sémantique. Le design system avait des tests unitaires — pour les variants CSS, pour les tailles, pour le rendu conditionnel. Aucun test ne vérifiait la balise HTML rendue. Le test existant ressemblait à ceci :
// Heading.test.tsx — tests existants (insuffisants)
it('renders with correct size class', () => {
const { container } = render(<Heading size="xl">Test</Heading>);
expect(container.firstChild).toHaveClass('text-2xl');
});
it('renders children', () => {
const { getByText } = render(<Heading>Mon titre</Heading>);
expect(getByText('Mon titre')).toBeInTheDocument();
});
Le test passe. Le composant rend bien du texte avec la bonne classe. Personne ne vérifie que container.firstChild?.tagName vaut 'H2' ou 'H1'.
3. L'inspection Chrome DevTools masque le problème. Quand un développeur inspecte un élément dans le panneau Elements, la balise <div> est visible — mais noyée dans l'arbre DOM. Sans aller vérifier explicitement l'onglet "Accessibility" ou lancer un audit Lighthouse sur la structure des headings, le bug est invisible.
Le diff qui a tout cassé
En remontant l'historique Git du design system, l'équipe retrouve le commit responsable. Lors de la v2.0.0 (refonte complète), le composant Heading de l'ancienne version avait as = 'h2' comme valeur par défaut. Le designer principal a changé cette valeur en div dans un commit intitulé "fix: Heading should not assume semantic level".
Le message de commit explique le raisonnement : "Le composant ne devrait pas imposer un niveau sémantique. C'est au consommateur de le définir." Un raisonnement défendable en théorie. Catastrophique en pratique, parce que personne n'a mis à jour les 43 fichiers qui utilisaient le composant sans passer la prop as.
Le git diff entre la v1.x et la v2.0.0 pour ce fichier :
- as = 'h2',
+ as = 'div',
Une ligne. 1 247 pages impactées.
L'impact mesuré
L'équipe reconstitue la timeline via Search Console (données disponibles avec 48h de décalage) et Google Analytics 4 :
- J+0 à J+3 : Googlebot recrawle 340 pages produit. Les impressions commencent à baisser sur les requêtes longue traîne.
- J+4 à J+8 : 890 pages recrawlées. Perte de 31 % des impressions sur le segment produit. Les positions moyennes passent de 8.2 à 14.7.
- J+8 à J+19 : L'intégralité des 1 247 pages est recrawlée. Perte cumulée estimée : −4 100 clics organiques. Les featured snippets sur 12 requêtes "caractéristiques [produit]" disparaissent.
L'outil de test des résultats enrichis de Google confirme : sans heading sémantique, les sections "Caractéristiques" et "Avis" ne sont plus identifiées comme des passages structurés.
Le fix : imposer la sémantique, tester la sémantique
Le correctif se déploie en deux temps.
1. Patch du composant (v2.4.1)
L'équipe change la valeur par défaut de as et ajoute un warning en développement quand la prop n'est pas explicitement passée :
// packages/ui/src/components/Heading/Heading.tsx — v2.4.1
import React from 'react';
import { cn } from '../../utils/cn';
type HeadingElement = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
interface HeadingProps {
as: HeadingElement; // requis — plus de valeur par défaut
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
children: React.ReactNode;
className?: string;
}
export const Heading: React.FC<HeadingProps> = ({
as,
size = 'md',
children,
className,
}) => {
const Component = as;
return (
<Component
className={cn(
'font-heading font-bold tracking-tight',
{
'text-xs': size === 'xs',
'text-sm': size === 'sm',
'text-base': size === 'md',
'text-xl': size === 'lg',
'text-2xl': size === 'xl',
'text-3xl': size === '2xl',
},
className
)}
>
{children}
</Component>
);
};
Le changement clé : as est maintenant requis (as: HeadingElement sans ?). Le type HeadingElement n'accepte plus div ni span. TypeScript refuse la compilation si un consommateur oublie la prop. Les 43 fichiers qui utilisaient <Heading> sans as cassent immédiatement au build — ce qui force le passage en revue de chaque usage.
Le template produit corrigé :
// app/produits/[slug]/page.tsx — corrigé
import { Heading } from '@acme/ui';
export default function ProductPage({ product }) {
return (
<main>
<Heading as="h1" size="2xl">{product.name}</Heading>
<section>
<Heading as="h2" size="lg">Caractéristiques</Heading>
{/* ... */}
</section>
<section>
<Heading as="h2" size="lg">Avis clients</Heading>
{/* ... */}
</section>
<section>
<Heading as="h3" size="md">Produits similaires</Heading>
{/* ... */}
</section>
</main>
);
}
2. Tests sémantiques ajoutés à la CI
L'équipe ajoute un test qui vérifie la balise rendue, pas seulement le style :
// Heading.test.tsx — tests ajoutés
it('renders the correct HTML element based on "as" prop', () => {
const { container } = render(<Heading as="h1" size="xl">Titre</Heading>);
expect(container.querySelector('h1')).toBeInTheDocument();
expect(container.querySelector('div')).not.toBeInTheDocument();
});
it('does not render a div or span', () => {
const levels: Array<'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'> = [
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
];
levels.forEach((level) => {
const { container } = render(<Heading as={level}>Test</Heading>);
const el = container.firstChild as HTMLElement;
expect(el.tagName.toLowerCase()).toBe(level);
cleanup();
});
});
3. Lint rule custom pour les templates
Un plugin ESLint custom avertit quand <Heading> est utilisé sans as explicite, en complément de la contrainte TypeScript (pour les fichiers .jsx non typés) :
npx eslint --rule '@acme/require-heading-as: error' app/
4. Validation post-déploiement
Après le déploiement du fix, l'équipe lance un crawl Screaming Frog ciblé :
# Vérification rapide via curl + grep sur un échantillon
for url in $(cat urls-produits-sample.txt); do
echo "--- $url ---"
curl -s "$url" | grep -oP '<h[1-6][^>]*>.*?</h[1-6]>' | head -5
done
Les 1 247 pages rendent désormais un <h1> pour le nom produit, des <h2> pour les sections principales.
Récupération
Le fix est déployé un mercredi soir. La récupération est progressive :
- J+3 post-fix : 410 pages recrawlées par Googlebot (vérifié via les logs serveur, user-agent
Googlebot). - J+7 : Les impressions cessent de baisser. Stabilisation.
- J+14 : 80 % du trafic organique produit est récupéré. Les positions moyennes reviennent à 9.1 (vs 8.2 avant l'incident).
- J+21 : Retour quasi complet. Les featured snippets "caractéristiques" reviennent sur 9 des 12 requêtes perdues.
Total de l'impact : 19 jours d'exposition, environ 4 100 clics perdus, 3 semaines de récupération. Le coût estimé, ramené au CPC moyen des requêtes concernées (0.85 €), dépasse 3 400 €.
L'incident a aussi révélé un problème similaire sur le composant Text, qui pouvait rendre des <p> ou des <div> selon un mécanisme identique. Ce composant n'avait pas d'impact SEO direct mais a été corrigé dans la foulée, par cohérence — un cas typique de régression liée au design system qu'il faut traiter à la racine.
Ce qu'on en retient
Un composant polymorphe sans valeur par défaut sémantique est une bombe à retardement. Le problème n'est pas le pattern as — c'est le div comme fallback silencieux.
Trois règles à graver dans la documentation du design system :
- La prop
asd'un composant Heading ne doit jamais accepterdivouspan. Le type TypeScript est le premier rempart. - Chaque composant sémantique doit avoir un test qui vérifie la balise HTML rendue, pas seulement le contenu ou le style.
- Un crawl sémantique post-déploiement doit vérifier la présence de
h1-h6sur un échantillon de pages critiques.
Ce type de régression — invisible à l'œil, invisible dans les tests classiques, invisible dans les métriques pendant une semaine — est exactement ce qu'un monitoring continu de la structure HTML permet de détecter avant que Search Console ne tire l'alarme. Un outil comme Seogard compare le HTML rendu côté serveur à chaque déploiement et alerte sur la disparition d'éléments sémantiques critiques — en minutes, pas en semaines.
Le design system est censé garantir la cohérence. Quand il la garantit uniquement sur le plan visuel et pas sur le plan sémantique, il devient le vecteur d'une régression silencieuse à l'échelle de tout le site. Le diff faisait une ligne. L'impact a duré trois semaines.