noscript cloaking : splash screen SPA piège Google

Splash screen et noscript mal placé : quand aider Googlebot déclenche une alerte cloaking

Jeudi 14h. Un développeur front pousse un "quick win SEO" sur un site e-commerce français de 8 400 pages. L'idée : dupliquer le contenu principal dans une balise <noscript> pour que les bots sans JavaScript voient quelque chose d'utile derrière le splash screen d'initialisation du SPA React. Le build passe, la review est validée en douze minutes. Dix jours plus tard, Search Console affiche une action manuelle pour cloaking. 1 200 URLs produit disparaissent de l'index.

Mardi, T+5 — "Pourquoi les impressions chutent ?"

Le signal arrive par Slack, posté par la responsable acquisition à 8h52. Le dashboard Looker Studio montre un décrochage net des impressions organiques depuis le week-end. Moins 38 % sur les pages catégorie. Moins 61 % sur les fiches produit.

Premier réflexe : vérifier Search Console. L'onglet Couverture ne montre rien d'anormal — les pages sont toujours "Valides". Pas d'erreur de crawl. Pas de pic de 5xx dans les logs Cloudflare. Le lead SEO ouvre l'onglet Performances et filtre par page. Les fiches produit les plus rentables sont à zéro clic depuis samedi.

Hypothèse initiale : un problème de rendering côté Google. L'équipe lance une inspection d'URL sur une fiche produit clé. Le rendu affiché par Search Console montre… le splash screen. Un cercle de chargement, un logo, et rien d'autre. Le contenu produit n'apparaît pas dans le HTML rendu par Google.

Le lead SEO lance Screaming Frog en mode "JavaScript rendering" sur un échantillon de 200 URLs. Résultat : le contenu est bien présent dans le DOM final. Mais en basculant sur le mode "HTML brut", il découvre autre chose. Le <body> contient deux versions du contenu : une dans le <div id="root"> (le SPA React), et une copie complète dans un bloc <noscript> juste en dessous.

À 10h15, le CTO reçoit un email de Search Console. Objet : "Action manuelle — Cloaking et/ou redirections trompeuses". Le message cible 1 247 URLs. Google considère que le site présente un contenu différent aux utilisateurs et aux robots d'exploration.

La panique s'installe. L'action manuelle signifie une désindexation active, pas un simple bug de rendering. Le trafic organique, qui représente 42 % du chiffre d'affaires du site, est en chute libre. Le lead SEO calcule l'impact : environ 14 000 clics organiques perdus par semaine, soit un manque à gagner estimé à 35 000 € sur la période.

L'équipe remonte le fil Git. Le commit incriminé date de jeudi dernier. Un développeur front a ajouté un composant <SeoFallback> qui injecte le contenu statique des pages dans une balise <noscript>. Son intention était bonne : fournir un fallback lisible pour les crawlers qui n'exécutent pas JavaScript.

Le bug : deux contenus, une seule page, un diagnostic de cloaking

Pour comprendre pourquoi Google a déclenché l'action manuelle, il faut examiner ce que chaque visiteur reçoit — et ce que Googlebot interprète.

Ce que voit un utilisateur normal

L'utilisateur arrive sur une fiche produit. Le navigateur charge le bundle React (847 Ko gzippé). Pendant le chargement, un splash screen CSS s'affiche via le HTML initial :

<!DOCTYPE html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <title>Chargement...</title>
  <meta name="description" content="">
  <link rel="stylesheet" href="/assets/splash.css">
</head>
<body>
  <div id="root">
    <div class="splash-screen">
      <img src="/logo.svg" alt="Logo" class="splash-logo">
      <div class="spinner"></div>
    </div>
  </div>
  <noscript>
    <article class="product-detail">
      <h1>Sneakers running TrailMax 3000 — Noir/Rouge</h1>
      <p class="price">129,90 €</p>
      <div class="description">
        <p>Conçue pour les trails techniques, la TrailMax 3000 offre
        un amorti renforcé et une semelle Vibram...</p>
      </div>
      <ul class="specs">
        <li>Poids : 285g</li>
        <li>Drop : 8mm</li>
        <li>Semelle : Vibram Megagrip</li>
      </ul>
    </article>
  </noscript>
  <script src="/assets/app.bundle.js"></script>
</body>
</html>

Une fois React hydraté, le <div id="root"> se remplit avec le vrai contenu produit. Le splash screen disparaît. L'utilisateur voit la fiche complète. La balise <noscript> est ignorée par le navigateur puisque JavaScript est actif.

Le <title> reste "Chargement..." jusqu'à ce que React le remplace via react-helmet-async. La meta description est vide dans le HTML initial.

Ce que voit Googlebot (et pourquoi c'est un problème)

Googlebot utilise un processus en deux phases. D'abord le crawl HTTP brut, puis le rendering via Chromium headless (WRS — Web Rendering Service). Mais ces deux phases ne sont pas simultanées. Le rendering peut intervenir des heures ou des jours après le crawl initial.

Lors du crawl HTTP brut, Googlebot voit :

  • Un <title> qui dit "Chargement..."
  • Une meta description vide
  • Un <div id="root"> contenant uniquement un spinner
  • Un <noscript> contenant un article complet avec H1, prix, description, specs

Le problème fondamental : le contenu dans <noscript> est structurellement différent de ce qu'un utilisateur avec JavaScript voit. Pas dans le fond — c'est le même texte — mais dans la condition d'affichage. Pour un navigateur avec JS activé, ce contenu est invisible. Pour un crawler HTTP brut, c'est le seul contenu disponible.

Google interprète ce pattern comme du cloaking : un contenu riche est servi spécifiquement aux agents qui ne rendent pas JavaScript, tandis que les utilisateurs réels voient un splash screen suivi d'un contenu rendu côté client. La divergence entre les deux expériences déclenche l'alerte.

Le composant fautif

Le développeur avait créé un composant React dédié, injecté via un plugin Webpack html-webpack-plugin :

// webpack.config.js — extrait
const HtmlWebpackPlugin = require('html-webpack-plugin');
const generateNoscriptFallback = require('./scripts/noscript-fallback');

module.exports = {
  // ...
  plugins: [
    new HtmlWebpackPlugin({
      template: './public/index.html',
      templateParameters: async (compilation, assets, assetTags, options) => {
        const pages = await fetchAllProductPages(); // appel API Strapi
        return {
          noscriptBlocks: pages.map(page => generateNoscriptFallback(page)),
        };
      },
    }),
  ],
};

Le script noscript-fallback.js générait un bloc HTML complet pour chaque page :

// scripts/noscript-fallback.js
function generateNoscriptFallback(page) {
  return `
    <noscript>
      <article class="product-detail">
        <h1>${page.title}</h1>
        <p class="price">${page.price} €</p>
        <div class="description">${page.description}</div>
        <ul class="specs">
          ${page.specs.map(s => `<li>${s}</li>`).join('')}
        </ul>
      </article>
    </noscript>
  `;
}
module.exports = generateNoscriptFallback;

L'intention du développeur venait d'un article de blog de 2019 recommandant les fallbacks <noscript> pour le SEO des SPA. Ce conseil était déjà discutable à l'époque. En 2026, avec la sophistication du WRS de Google, c'est une bombe à retardement.

Pourquoi les tests n'ont rien détecté

L'équipe avait des tests Cypress end-to-end. Mais tous les tests s'exécutent avec JavaScript activé. Personne n'a jamais testé le HTML brut servi au premier hit HTTP.

Le pipeline CI/CD incluait un check Lighthouse. Lighthouse exécute JavaScript — il ne voit jamais le contenu <noscript> comme contenu principal.

Le seul outil qui aurait pu détecter la divergence : un diff entre le HTML brut (curl) et le DOM rendu après exécution JS. Ce test n'existait pas.

Pour reproduire le problème, une simple commande suffisait :

curl -s https://www.example.com/produit/trailmax-3000 \
  -H "User-Agent: Mozilla/5.0 (compatible; Googlebot/2.1)" \
  | grep -A 20 "<noscript>"

Le résultat affiche l'intégralité du contenu produit dans le <noscript>. En comparant avec ce que voit un utilisateur réel avant l'exécution JS (un spinner), la divergence est flagrante.

Ce pattern rappelle un autre piège courant des SPA : le contenu principal caché derrière des directives conditionnelles. Sur Vue.js, le même problème survient avec v-if sur les sections critiques, comme documenté dans cet incident sur une section hero invisible au fetch HTTP brut.

La nuance cloaking vs. fallback légitime

La documentation Google sur le cloaking est explicite : servir un contenu différent aux moteurs de recherche et aux utilisateurs constitue une violation des consignes. Le fait que l'intention soit "bonne" (aider le bot à comprendre la page) ne change rien au diagnostic.

Un usage légitime de <noscript> existe : afficher un message du type "Ce site nécessite JavaScript" ou fournir un lien alternatif. Injecter le contenu complet de la page — avec H1, prix, description structurée — dans un <noscript> alors que ce contenu n'est visible qu'après exécution JS pour les vrais utilisateurs, c'est exactement ce que Google considère comme du cloaking.

Ce problème est distinct mais connexe d'un autre pattern toxique : les balises meta manipulées par des conditions CSS ou JS. Un cas similaire a été documenté lors d'un A/B test qui servait un noindex à 50 % du trafic, où la divergence entre les versions provoquait une désindexation progressive.

Le fix : supprimer, rendre en SSR, et demander la réexamen

Le correctif s'est déroulé en trois phases, étalées sur 48 heures de travail intense.

Phase 1 — Suppression immédiate du noscript (T+0)

Priorité absolue : retirer le composant <SeoFallback> et tous les blocs <noscript> contenant du contenu produit. Le revert Git est propre — un seul commit à annuler.

git revert 3f7a2e1 --no-edit
git push origin main

Le déploiement est poussé en production en 8 minutes via le pipeline Vercel. Le CDN Cloudflare est purgé manuellement pour les 1 247 URLs concernées :

# Purge Cloudflare via API — batch de 30 URLs max par appel
cat affected-urls.txt | xargs -n 30 -I {} \
  curl -s -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/purge_cache" \
  -H "Authorization: Bearer $CF_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{"files": [{}]}'

Phase 2 — Mise en place d'un pre-rendering SSR minimal (T+12h)

Supprimer le <noscript> ne suffit pas. Le problème initial reste : le HTML brut servi par le SPA React ne contient que le splash screen. Googlebot WRS finira par rendre la page, mais le HTML initial doit être propre.

L'équipe opte pour un pré-rendu statique via react-snap, configuré pour générer le HTML complet de chaque page produit au build :

// package.json — extrait
{
  "scripts": {
    "postbuild": "react-snap"
  },
  "reactSnap": {
    "source": "build",
    "puppeteerArgs": ["--no-sandbox"],
    "include": ["/produit/"],
    "minifyHtml": {
      "collapseWhitespace": true,
      "removeComments": true
    },
    "inlineCss": true,
    "skipThirdPartyRequests": true
  }
}

Avec cette configuration, chaque URL /produit/* est pré-rendue avec le contenu complet directement dans le <div id="root">. Le <title> et la meta description sont corrects dès le premier octet. Plus de splash screen pour Googlebot. Plus de divergence.

L'alternative aurait été de migrer vers Next.js ou Remix pour du vrai SSR. L'équipe n'avait pas le budget de temps. react-snap a résolu le problème en une demi-journée. Ce n'est pas élégant. C'est efficace.

Ce choix d'architecture rejoint les problématiques rencontrées lors de migrations vers le SSR Angular où le provideServerRendering mal configuré provoque des hydration mismatches invisibles.

Phase 3 — Demande de réexamen (T+24h)

L'action manuelle nécessite une demande de réexamen dans Search Console. L'équipe rédige un message précis :

  • Description du problème identifié (blocs <noscript> contenant du contenu dupliqué)
  • Date du commit fautif et date du revert
  • Preuve que le HTML brut actuel sert le contenu identique à ce que voit un utilisateur
  • URL d'exemple avant/après

La demande est soumise le mercredi. La réponse de Google arrive le lundi suivant : action manuelle levée. Délai total : 5 jours ouvrés.

Récupération du trafic

La réindexation des 1 247 URLs prend 9 jours après la levée de l'action manuelle. Le trafic organique retrouve 85 % de son niveau d'avant-incident au bout de 14 jours. Les 15 % restants reviennent progressivement sur les 3 semaines suivantes, à mesure que Google recrawle et re-rank les pages.

Impact total estimé : 21 jours de perturbation, environ 42 000 clics organiques perdus, soit un manque à gagner de 95 000 € pour ce e-commerce.

Les garde-fous mis en place

L'équipe ajoute trois contrôles au pipeline CI/CD :

  1. Lint HTML statique : un script Node qui parse le HTML généré et échoue si un <noscript> contient plus de 50 caractères de texte (seuil pour un simple message "Activez JavaScript").

  2. Diff SSR/CSR automatisé : à chaque PR, un job compare le HTML brut (curl) avec le DOM rendu par Puppeteer. Si la divergence textuelle dépasse 20 %, le build échoue.

  3. Alerte Search Console : notification Slack immédiate sur les actions manuelles et les chutes d'indexation supérieures à 5 % sur 48h.

Ce type de vérification SSR/CSR rejoint la logique de surveillance décrite dans l'incident de migration Next.js Pages Router vers App Router, où les metadata étaient ignorées sur les pages client faute de monitoring du HTML initial.

Ce qu'on en retient

Le <noscript> n'est pas un outil SEO. C'est un mécanisme de fallback pour les navigateurs sans JavaScript. Y injecter du contenu riche quand le reste de la page est un SPA vide, c'est créer une divergence que Google qualifie de cloaking — même sans intention malveillante.

Trois règles pour les SPA :

  • Le HTML initial doit contenir le même contenu que le DOM rendu. Pas un spinner. Pas un placeholder.
  • Si le SSR complet n'est pas envisageable, le pré-rendu statique est un compromis viable.
  • La divergence entre HTML brut et DOM final doit être testée automatiquement, à chaque déploiement.

Un outil de monitoring continu comme Seogard détecte ce type de divergence SSR/CSR en quelques minutes après le déploiement — pas onze jours plus tard, quand Search Console envoie un email d'action manuelle.

Articles connexes

Rendering4 juin 2026

Lazy-load du hero Vue : H1 invisible pour Google

Un hero section en v-if masque le H1 au SSR. Récit d'une régression silencieuse sur 320 pages, diagnostic technique et fix Nuxt complet.

Rendering5 avril 2026

SSR vs CSR : impact réel sur le SEO technique

Comparaison technique SSR et CSR avec exemples de crawl, code et scénarios concrets. Ce que Googlebot voit vraiment selon votre mode de rendering.

Rendering5 avril 2026

Google voit une page blanche sur votre SPA : diagnostic et solutions

Diagnostic technique complet des problèmes de rendering JavaScript sur les SPA. Solutions SSR, prerendering et monitoring pour Googlebot.

Rendering5 avril 2026

Hydration mismatch : le bug invisible qui tue votre SEO

Détectez et corrigez les erreurs d'hydratation SSR qui dégradent silencieusement votre indexation. Méthodes, outils et code pour debug avancé.

Rendering5 avril 2026

ISR, SSR, SSG : quel rendering choisir pour le SEO

Guide technique pour choisir entre ISR, SSR et SSG selon votre type de site. Comparatif, code Next.js/Nuxt, et scénarios réels e-commerce, média, SaaS.

Rendering5 avril 2026

Prerendering SEO : quand et comment l'implémenter

Guide technique du prerendering pour le SEO : cas d'usage concrets, implémentation avec Next.js, Nuxt, Astro, et pièges à éviter sur les SPA.