Mode sombre : quand prefers-color-scheme injecte un meta noindex par erreur
Jeudi 16h40. Un développeur frontend merge une PR intitulée "feat: dark mode support — prefers-color-scheme". Trois reviewers ont approuvé. Le pipeline CI passe. Le Lighthouse score reste à 96. Le site — une marketplace B2B française de 8 400 pages — affiche un mode sombre impeccable. Dix-huit jours plus tard, Search Console signale que 1 247 pages portent une directive noindex. Personne n'a touché aux meta robots depuis six mois.
Lundi 9h12 — L'alerte que personne ne comprend
Le lead SEO ouvre Search Console le lundi matin. Routine hebdomadaire. L'onglet "Pages" affiche un pic dans la catégorie "Exclue — Page avec balise 'noindex'". Le compteur est passé de 14 (les pages légitimement exclues — CGV, politique de confidentialité, pages de compte) à 1 261.
Premier réflexe : vérifier si un déploiement a touché le fichier robots.txt ou les composants de <head>. Git blame sur les trois dernières semaines. Rien. Le robots.txt n'a pas bougé depuis quatre mois. Le composant <MetaTags> non plus.
9h38 — Le lead SEO ouvre une page produit dans Chrome, View Source. Pas de noindex. Il inspecte le DOM avec DevTools. Pas de noindex non plus. Il vérifie cinq pages. Dix pages. Aucune trace.
9h52 — Hypothèse : faux positif de Search Console, un bug d'indexation côté Google. L'équipe a déjà vécu ça sur des sous-domaines staging. Le CTO propose d'attendre 48 heures.
Mercredi 11h — Le compteur monte à 1 247 pages exclues. Le trafic organique a perdu 31 % sur les fiches produit en deux semaines. −74K clics sur la période. L'attente n'est plus une option.
13h15 — Un dev senior lance Screaming Frog sur un échantillon de 500 URLs. Configuration par défaut : user-agent Googlebot, rendering JavaScript activé. Résultat : 0 page avec noindex. Screaming Frog ne voit rien.
13h40 — Le lead SEO utilise l'outil d'inspection d'URL de Search Console sur une fiche produit spécifique. Le rendu HTML live affiche clairement :
<meta name="robots" content="noindex">
Le tag est là. Dans le <head>. Mais invisible dans le navigateur classique, invisible dans Screaming Frog, invisible dans curl. Le lead SEO prend une capture d'écran, la poste dans le canal Slack #incident-seo. Silence pendant dix secondes. Puis le CTO : "Comment c'est possible ?"
14h02 — L'équipe comprend que le bug ne se manifeste que dans un contexte de rendu bien précis. Pas un bug mineur. Un fantôme dans le <head>.
Le bug : un CSS-in-JS qui écrit du HTML dans une media query
Le site utilise React 18 avec Next.js 14 (App Router) et Styled Components v6 pour le styling. La PR "dark mode" a introduit un composant ThemeProvider qui injecte des styles globaux conditionnels via createGlobalStyle.
Voici le code mergé (simplifié mais fidèle à la structure réelle) :
// theme/GlobalStyles.tsx
import { createGlobalStyle } from 'styled-components';
export const GlobalStyles = createGlobalStyle`
body {
background-color: ${({ theme }) => theme.colors.background};
color: ${({ theme }) => theme.colors.text};
}
@media (prefers-color-scheme: dark) {
body {
background-color: #1a1a2e;
color: #e0e0e0;
}
${({ theme }) => theme.darkModeMetaOverride || ''}
}
`;
Le problème est dans theme.darkModeMetaOverride. Ce champ a été ajouté au thème par un autre développeur — celui qui travaillait sur l'intégration d'un outil tiers d'analytics. Il voulait pouvoir injecter dynamiquement des fragments dans le <head> depuis la configuration du thème. Son intention : permettre à l'équipe marketing de désactiver l'indexation de certaines variantes de pages A/B testées en mode sombre.
Voici ce qu'il a ajouté dans la configuration du thème :
// theme/config.ts
export const darkTheme = {
colors: {
background: '#1a1a2e',
text: '#e0e0e0',
primary: '#6c63ff',
},
darkModeMetaOverride: '<meta name="robots" content="noindex">',
};
export const lightTheme = {
colors: {
background: '#ffffff',
text: '#1a1a2e',
primary: '#6c63ff',
},
darkModeMetaOverride: '',
};
Dans le thème clair, darkModeMetaOverride est une chaîne vide. Dans le thème sombre, c'est une balise <meta> brute.
Le développeur pensait que cette chaîne serait injectée dans le <head> via un composant React dédié, comme next/head ou un Helmet. Mais elle finissait dans createGlobalStyle — un template literal qui produit une balise <style>. Le contenu brut <meta name="robots" content="noindex"> se retrouvait donc à l'intérieur d'un bloc <style>.
Ce que voit un navigateur
Le navigateur parse le HTML. Il rencontre la balise <style>. À l'intérieur, il trouve du CSS valide puis un fragment <meta ...>. Le parser CSS ignore silencieusement ce fragment invalide. Le navigateur ne crée aucun nœud DOM pour ce <meta>. Résultat : aucun noindex visible dans l'inspecteur DOM, aucun effet sur la page.
Ce que voit Googlebot
Googlebot utilise un rendering basé sur Chrome headless (WRS — Web Rendering Service), mais il effectue aussi un parsing du HTML brut avant le rendu JavaScript complet. Lors de cette phase de pre-parsing, le HTML brut côté serveur (SSR) contient ceci :
<head>
<meta charset="utf-8">
<title>Produit XYZ — Marketplace</title>
<meta name="description" content="...">
<style data-styled="active">
body{background-color:#ffffff;color:#1a1a2e;}
@media (prefers-color-scheme: dark){
body{background-color:#1a1a2e;color:#e0e0e0;}
<meta name="robots" content="noindex">
}
</style>
</head>
Le parser HTML de Googlebot — conforme à la spécification HTML5 — traite le contenu de <style> comme du texte brut (raw text element). Mais lors du pre-processing des directives d'indexation, Google extrait les balises <meta name="robots"> par pattern matching sur le HTML brut avant le parsing DOM complet. Le fragment <meta name="robots" content="noindex"> est détecté et appliqué, même s'il est techniquement à l'intérieur d'une balise <style>.
Ce comportement est documenté de manière oblique dans la documentation Google sur les directives robots : Google recommande de placer les meta robots dans le <head>, mais le crawler scanne l'intégralité du document pour détecter ces directives.
Pourquoi les tests n'ont rien détecté
Quatre raisons :
-
Le navigateur masque le bug. Chrome, Firefox, Safari — tous ignorent les balises HTML invalides à l'intérieur de
<style>. Le DOM résultant ne contient pas de<meta noindex>. Inspecter la page dans DevTools ne montre rien. -
Screaming Frog en mode rendering utilise un headless Chrome standard. Il parse le DOM final, pas le HTML brut SSR. Même résultat : pas de
noindexvisible. -
Le thème clair est le défaut SSR. Le serveur Next.js rend la page avec
lightThemepar défaut.darkModeMetaOverrideest une chaîne vide en thème clair. MaiscreateGlobalStylede Styled Components injecte les deux blocs media query dans le HTML SSR — le blocprefers-color-scheme: darkinclut la chaîne dudarkTheme, pas dulightTheme. La résolution du thème pour les media queries CSS se fait au niveau du template literal, pas au niveau du state React. -
Les tests unitaires et e2e ne vérifient pas le HTML brut SSR. Les tests Cypress et Jest/RTL interrogent le DOM. Le
<meta>fantôme n'existe pas dans le DOM.
Pour reproduire le problème, il fallait inspecter le HTML brut servi par le serveur :
curl -s -H "User-Agent: Googlebot" https://marketplace.example.com/produit/xyz \
| grep -i "noindex"
Résultat :
<meta name="robots" content="noindex">
Présent. En plein milieu d'un bloc <style>. Un curl simple suffit. Mais personne n'avait pensé à chercher un noindex dans une feuille de style.
La divergence entre ce que voit le développeur et ce que voit Googlebot est exactement le type de piège décrit dans le récit d'une migration Next.js Pages Router vers App Router où les metadata étaient ignorées sur les pages client. Le SSR produit un HTML que personne ne lit — et c'est précisément celui que Google lit en premier.
Le fix : supprimer, valider, purger
Étape 1 — Supprimer le vecteur d'injection
Le correctif immédiat : retirer darkModeMetaOverride du thème et du createGlobalStyle.
// theme/GlobalStyles.tsx — APRÈS CORRECTION
import { createGlobalStyle } from 'styled-components';
export const GlobalStyles = createGlobalStyle`
body {
background-color: ${({ theme }) => theme.colors.background};
color: ${({ theme }) => theme.colors.text};
}
@media (prefers-color-scheme: dark) {
body {
background-color: #1a1a2e;
color: #e0e0e0;
}
}
`;
// theme/config.ts — APRÈS CORRECTION
export const darkTheme = {
colors: {
background: '#1a1a2e',
text: '#e0e0e0',
primary: '#6c63ff',
},
// darkModeMetaOverride supprimé
};
Si l'équipe marketing a besoin de contrôler l'indexation de variantes A/B, cela doit passer par un composant React dédié dans le <head>, jamais par une interpolation dans un template CSS. Le récit d'un A/B test header servant un noindex à 50 % du trafic pendant 9 jours illustre exactement ce même anti-pattern dans un autre contexte.
Étape 2 — Valider le HTML SSR avant déploiement
Ajout d'un test d'intégration dans la CI qui vérifie le HTML brut SSR :
// __tests__/ssr-noindex-guard.test.ts
import { describe, it, expect } from 'vitest';
describe('SSR HTML safety checks', () => {
it('should not contain noindex in style tags', async () => {
const res = await fetch('http://localhost:3000/produit/xyz');
const html = await res.text();
// Extraire le contenu de toutes les balises <style>
const styleBlocks = html.match(/<style[^>]*>([\s\S]*?)<\/style>/gi) || [];
for (const block of styleBlocks) {
expect(block.toLowerCase()).not.toContain('noindex');
expect(block.toLowerCase()).not.toContain('<meta');
}
});
it('should not contain any meta tag outside of <head>', async () => {
const res = await fetch('http://localhost:3000/produit/xyz');
const html = await res.text();
const headEnd = html.indexOf('</head>');
const bodyContent = html.slice(headEnd);
const strayMetas = bodyContent.match(/<meta\s+name=["']robots["'][^>]*>/gi) || [];
expect(strayMetas).toHaveLength(0);
});
});
Ce test tourne sur chaque PR via un serveur Next.js éphémère dans le pipeline CI (GitHub Actions). Il attrape toute injection accidentelle de balises HTML dans des blocs <style>, <script>, ou dans le <body>.
Étape 3 — Forcer le re-crawl
Après déploiement du fix :
- Soumission d'un sitemap mis à jour dans Search Console avec
lastmodactualisé sur toutes les fiches produit. - Utilisation de l'API d'indexation Google pour soumettre les 50 pages à plus fort trafic en priorité.
- Invalidation du cache CDN (Vercel, dans ce cas) pour garantir que Googlebot reçoit le HTML corrigé dès le prochain passage.
# Invalider le cache Vercel sur les routes produit
npx vercel --force --scope=marketplace-prod \
&& curl -X PURGE "https://marketplace.example.com/produit/*"
Temps de récupération
- J+3 : les 50 pages soumises via l'API d'indexation repassent en
index, followdans Search Console. - J+7 : 60 % des 1 247 pages exclues sont réindexées.
- J+14 : 95 % des pages récupèrent leur statut indexé. Les 5 % restantes sont des fiches produit à faible PageRank, crawlées moins souvent.
- J+21 : le trafic organique revient à son niveau d'avant l'incident. Total de l'impact estimé : −112K clics sur 21 jours.
Le schéma de récupération est cohérent avec ce qui a été observé lors d'une migration Nuxt 2 vers Nuxt 3 où 200 pages sont restées en fallback layout pendant 6 semaines : les pages à forte autorité reviennent vite, les pages orphelines traînent.
Gardes-fous ajoutés post-incident
- Lint rule custom : un plugin ESLint qui interdit les chaînes contenant
<metaou<linkdans les fichiers de configuration de thème et dans les template literals decreateGlobalStyle,css, etstyled. - Alerte Search Console : un script cron quotidien qui requête l'API Search Console et déclenche une alerte Slack si le compteur de pages
noindexdépasse le seuil connu (14 pages légitimes + marge de 5). - Revue SSR obligatoire : toute PR touchant le
<head>, le thème, ou les styles globaux doit inclure uncurldu HTML SSR dans la description de PR. Pas de merge sans cette preuve.
Le récit du design system dont le composant heading rendait des <div> au lieu de <h1> montre le même angle mort : le composant fait ce qu'on lui demande, mais ce qu'on lui demande n'est pas ce que Google attend.
Ce qu'on en retient
Le CSS-in-JS est puissant. Les template literals acceptent n'importe quelle chaîne. C'est leur force — et leur surface d'attaque SEO. Une interpolation de thème dans un createGlobalStyle peut injecter du HTML brut dans une balise <style>. Le navigateur l'ignore. Googlebot ne l'ignore pas.
Le diagnostic a pris 18 jours parce que personne n'inspecte le HTML SSR brut. Les DevTools montrent le DOM, pas le source. Screaming Frog rend le JavaScript, pas le pré-parse. Seul un curl — ou un monitoring continu type Seogard qui compare le HTML SSR aux directives d'indexation effectives — aurait détecté la divergence en quelques minutes au lieu de trois semaines.
Règle simple : aucune donnée de configuration ne devrait contenir de balise HTML. Si un champ de thème peut finir dans un template literal CSS, il finira dans le HTML servi. Et si Google le voit, Google l'applique.