Sanity preview mode en prod : drafts indexés par Google

Sanity preview mode actif en production : quand Googlebot indexe vos drafts

Mardi 11h. L'équipe éditoriale d'une marketplace mode française — 4 200 fiches produit, 1,8 million de visites organiques par mois — prépare un drop de 47 nouvelles références. Les fiches sont en draft dans Sanity Studio. Aucune n'a été publiée. Pourtant, jeudi matin, une cliente envoie un screenshot au service client. Elle a trouvé sur Google une fiche produit avec un prix barré à "XXX €", un titre "DRAFT — Veste matelassée printemps", et une photo placeholder grise. La fiche est indexée. Et elle n'est pas la seule.

Jeudi 9h12 — Le signal d'alarme

Le responsable e-commerce forward le screenshot au lead SEO. Premier réflexe : vérifier manuellement. Une recherche site: ciblée sur le nom du produit remonte la page. Le title dans les SERPs affiche le préfixe "DRAFT —". Mauvais signe.

Le lead SEO ouvre Search Console. Onglet Pages. Filtre sur les URL contenant /produit/. Le nombre de pages indexées a grimpé : 4 547 au lieu des 4 200 attendues. 347 pages supplémentaires. Toutes apparues dans l'index entre mardi et mercredi — exactement la fenêtre du dernier déploiement.

Premier diagnostic (faux) : un problème de publication accidentelle côté Sanity. L'équipe éditoriale vérifie. Les 47 fiches sont bien en statut draft dans Sanity Studio. Aucune n'a été publiée. Le mystère s'épaissit.

Le lead SEO lance un crawl Screaming Frog en mode Googlebot sur le domaine de production. Résultat : les 347 pages "fantômes" répondent en 200. Elles servent du contenu. Et pas n'importe lequel : du contenu draft, avec des champs incomplets, des prix placeholder, des descriptions qui commencent par "TODO :".

L'hypothèse bascule vers le frontend. Le site tourne sur Next.js 14 (App Router) avec next-sanity pour la couche data. Le dev fullstack senior regarde les variables d'environnement du déploiement Vercel de production. Et là, la ligne qui fait mal :

SANITY_API_READ_TOKEN=skRw7g...XXXX
NEXT_PUBLIC_SANITY_PREVIEW_ENABLED=true

Le token de preview — celui qui donne accès aux drafts via l'API Sanity — est actif en production. Et la variable NEXT_PUBLIC_SANITY_PREVIEW_ENABLED est à true. Sur le build de prod.

L'équipe comprend que ce n'est pas un bug mineur. Googlebot crawle le site de production, récupère les drafts comme du contenu légitime, et les indexe. Depuis au moins 48 heures. Potentiellement plus : un audit rapide des logs Vercel montre que la variable est présente depuis le déploiement du 2 juin. Soit 12 jours.

Le bug : quand le client Sanity de production fetche les drafts

Pour comprendre la mécanique, il faut regarder comment next-sanity configure le client de requête.

La configuration du client Sanity

Le fichier sanity/lib/client.ts du projet :

// sanity/lib/client.ts
import { createClient } from 'next-sanity'

export const client = createClient({
  projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
  dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
  apiVersion: '2024-06-01',
  useCdn: !process.env.NEXT_PUBLIC_SANITY_PREVIEW_ENABLED,
  token: process.env.SANITY_API_READ_TOKEN,
  perspective: process.env.NEXT_PUBLIC_SANITY_PREVIEW_ENABLED
    ? 'previewDrafts'
    : 'published',
})

Le problème est dans les deux dernières propriétés. Quand NEXT_PUBLIC_SANITY_PREVIEW_ENABLED vaut "true" (et en JavaScript, la string "true" est truthy) :

  1. useCdn passe à false — le client bypasse le CDN Sanity et tape l'API directement.
  2. perspective passe à 'previewDrafts' — le client retourne les documents en statut draft, en plus des documents publiés. Les drafts écrasent même les versions publiées quand elles existent.

En développement local, c'est le comportement voulu. Le rédacteur voit ses modifications en temps réel avant publication. En production, c'est une catastrophe.

Ce que voit le développeur vs ce que voit Googlebot

Un développeur qui ouvre la page produit dans son navigateur ne remarque rien d'anormal sur les fiches déjà publiées. Le draft et la version publiée sont identiques pour les anciens produits (le draft est juste une copie de travail). La page s'affiche correctement.

Mais pour les 47 nouvelles fiches — celles qui n'existent qu'en draft — le comportement diverge. Côté Sanity, la requête GROQ standard du projet :

*[_type == "product" && slug.current == $slug][0]{
  title,
  price,
  description,
  "imageUrl": mainImage.asset->url,
  seoTitle,
  seoDescription
}

Avec la perspective previewDrafts, cette requête retourne les documents dont l'_id commence par drafts.. Ces documents n'ont pas de version publiée. Ils existent uniquement dans l'espace draft de Sanity.

Le HTML servi par Next.js pour une de ces fiches fantômes :

<!DOCTYPE html>
<html lang="fr">
<head>
  <title>DRAFT — Veste matelassée printemps 2026 | BrandName</title>
  <meta name="description" content="TODO : rédiger la description SEO pour ce produit" />
  <meta name="robots" content="index, follow" />
  <link rel="canonical" href="https://www.example.com/produit/veste-matelassee-printemps-2026" />
</head>
<body>
  <h1>DRAFT — Veste matelassée printemps 2026</h1>
  <span class="price">XXX €</span>
  <p class="description">Lorem ipsum dolor sit amet, description produit à compléter.</p>
  <img src="https://cdn.sanity.io/images/.../placeholder-gray.png" alt="" />
</body>
</html>

Pas de noindex. Un canonical propre. Un title indexable. Pour Googlebot, c'est une page légitime. Il la crawle, la rend, l'indexe. Les 340 fiches en draft (les 47 nouvelles plus ~293 anciennes fiches en cours de modification éditoriale) sont traitées comme du contenu de production.

Pourquoi les tests n'ont rien détecté

Trois raisons convergent.

1. Le pipeline CI ne teste pas les variables d'environnement de production. Les tests e2e tournent avec un jeu de variables dédié (.env.test). La variable NEXT_PUBLIC_SANITY_PREVIEW_ENABLED n'y est pas définie — donc les tests passent en mode published. Le bug n'existe que dans le contexte de déploiement Vercel.

2. La preview Vercel et la production partagent le même projet. L'équipe avait configuré les variables d'environnement dans le dashboard Vercel en cochant "Production", "Preview" et "Development" pour le token et le flag preview. Un raccourci pris lors du setup initial, jamais corrigé.

3. Aucun monitoring du contenu servi côté SEO. L'équipe surveille les métriques de performance (Core Web Vitals, TTFB), les erreurs 5xx, le taux de conversion. Personne ne vérifie automatiquement que le contenu servi à un crawler correspond au contenu publié dans le CMS. C'est exactement le type de divergence silencieuse qu'on retrouve dans d'autres stacks headless — comme quand un champ SEO title Contentful n'est pas synchronisé vers Next.js.

L'ampleur réelle du dégât

Le lead SEO extrait les données Search Console sur 14 jours. Sur les 340 pages indexées par erreur :

  • 127 affichent des titles avec le préfixe "DRAFT —".
  • 89 ont des meta descriptions contenant "TODO" ou "Lorem ipsum".
  • 34 ont des prix affichés comme "XXX €" ou "0 €".
  • Le tout génère 2 300 impressions parasites et 180 clics vers des pages au contenu incohérent.

Plus grave : les versions draft des fiches existantes (les 293) écrasent les versions publiées. Certaines fiches à fort trafic servent maintenant un title de brouillon au lieu du title optimisé. Le CTR moyen de ces pages chute de 4,2 % à 2,8 % sur la période.

Côté maillage, 12 fiches draft contiennent des liens internes vers des catégories pas encore créées — des 404. Googlebot les suit, les enregistre. Le rapport de couverture Search Console commence à remonter des erreurs soft 404.

Le fix : trois étapes, zéro compromis

Étape 1 — Isoler les variables d'environnement

Dans le dashboard Vercel, les variables sont reconfigurées immédiatement :

  • SANITY_API_READ_TOKEN : décoché de "Production". Reste uniquement sur "Preview" et "Development".
  • NEXT_PUBLIC_SANITY_PREVIEW_ENABLED : supprimé de "Production". Reste sur "Preview" uniquement.

Mais ça ne suffit pas. Le code du client Sanity repose sur une logique conditionnelle fragile. Si une variable est undefined, le comportement par défaut doit être sûr. Le client est refactorisé :

// sanity/lib/client.ts — version corrigée
import { createClient } from 'next-sanity'

const isPreview =
  process.env.NEXT_PUBLIC_SANITY_PREVIEW_ENABLED === 'true' &&
  process.env.NODE_ENV !== 'production'

export const client = createClient({
  projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
  dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
  apiVersion: '2024-06-01',
  useCdn: !isPreview,
  token: isPreview ? process.env.SANITY_API_READ_TOKEN : undefined,
  perspective: isPreview ? 'previewDrafts' : 'published',
})

La double condition — flag explicite ET environnement non-production — empêche toute fuite accidentelle du mode preview en production. Si le token est undefined, l'API Sanity refuse les requêtes authentifiées et ne retourne que le contenu publié. C'est un fail-safe.

Étape 2 — Purger les pages indexées

Le redéploiement est déclenché. Les 340 pages qui n'existent qu'en draft retournent maintenant un 404 (le client en perspective published ne les trouve plus). Pour les 293 fiches existantes dont le contenu draft écrasait le contenu publié, la version publiée est à nouveau servie.

Pour accélérer la désindexation des 47 pages fantômes, l'équipe soumet un batch de demandes de suppression via l'API Search Console :

# Soumettre une demande de suppression d'URL via l'API Indexing
# (les 47 URLs sont listées dans urls-to-remove.txt)
while IFS= read -r url; do
  curl -s -X POST \
    "https://searchconsole.googleapis.com/webmasters/v3/sites/https%3A%2F%2Fwww.example.com/urlRemovals" \
    -H "Authorization: Bearer $(gcloud auth print-access-token)" \
    -H "Content-Type: application/json" \
    -d "{\"siteUrl\": \"https://www.example.com\", \"inspectionUrl\": \"$url\"}" \
    | jq '.inspectionResult.indexStatusResult.coverageState'
done < urls-to-remove.txt

En parallèle, l'outil de suppression temporaire de Search Console est utilisé pour les 12 URLs les plus visibles (celles qui apparaissent dans les SERPs avec "DRAFT" dans le title).

Étape 3 — Mettre en place un garde-fou durable

L'équipe ajoute un middleware Next.js qui bloque toute réponse contenant des marqueurs de draft :

// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  const response = NextResponse.next()

  // En production, vérifier que le mode preview n'est pas actif
  if (
    process.env.NODE_ENV === 'production' &&
    process.env.NEXT_PUBLIC_SANITY_PREVIEW_ENABLED === 'true'
  ) {
    // Logger l'alerte et servir quand même (ne pas casser le site)
    // mais envoyer une alerte Slack
    console.error(
      '[SEO-GUARD] CRITICAL: Sanity preview mode is active in production'
    )
    // Webhook Slack / PagerDuty
    fetch(process.env.ALERT_WEBHOOK_URL!, {
      method: 'POST',
      body: JSON.stringify({
        text: '🚨 Sanity preview mode is active on PRODUCTION. Drafts may be served to Googlebot.',
      }),
    }).catch(() => {})
  }

  return response
}

export const config = {
  matcher: ['/produit/:path*', '/categorie/:path*'],
}

Ce middleware ne bloque pas le rendu — casser le site serait pire. Mais il déclenche une alerte immédiate si la variable fuit à nouveau en production. L'équipe ajoute aussi un test dans le pipeline CI :

# .github/workflows/deploy.yml — extrait
- name: Verify production env safety
  if: github.ref == 'refs/heads/main'
  run: |
    if grep -q 'NEXT_PUBLIC_SANITY_PREVIEW_ENABLED=true' .env.production 2>/dev/null; then
      echo "ERROR: Preview mode enabled in production env file"
      exit 1
    fi
    echo "✅ Production env is clean — no preview mode"

La récupération

Les 47 pages fantômes sont désindexées en 4 à 6 jours. Les demandes de suppression accélèrent le processus pour les plus visibles (24-48 heures).

Pour les 293 fiches existantes qui servaient du contenu draft, la récupération est plus lente. Google doit re-crawler les pages, constater que le contenu a changé, et réévaluer le ranking. Le CTR des pages affectées remonte progressivement :

  • J+3 : 3,1 % (vs 2,8 % au creux).
  • J+10 : 3,8 %.
  • J+21 : retour à 4,1 %, proche du niveau pré-incident.

Le trafic organique global du site accuse une baisse de 6 % sur la période (environ −108K clics sur 21 jours), en partie due à la dilution de crawl budget causée par les 340 pages parasites. Ce phénomène de dilution est similaire à ce qu'on observe quand un A/B test sert un noindex à 50 % du trafic — le crawler gaspille des ressources sur des pages qui ne devraient pas exister.

Ce qu'on en retient

Les architectures headless créent un espace entre le CMS et le rendu. Cet espace est fertile pour les régressions SEO silencieuses. Un flag de preview, une variable d'environnement mal scopée, un token qui fuit — et Googlebot voit un site que personne dans l'équipe ne voit.

La règle : en production, le client CMS ne doit jamais avoir accès aux drafts. Pas par convention. Par contrainte technique. Un token absent. Un NODE_ENV check. Un test CI qui casse le build.

Et si le bug passe quand même ? Un monitoring continu type Seogard détecte la divergence entre contenu CMS publié et contenu servi au crawler en quelques minutes — pas en douze jours.

La preview, c'est pour les humains. Pas pour les bots. Quand la frontière saute, l'index se remplit de brouillons. Et les brouillons, Google les traite comme des pages. Avec tout ce que ça implique.

Articles connexes

Headless15 juin 2026

Contentful + Next.js : title manquant, fallback H1 sur 1 200 pages

Un champ SEO title Contentful non mappé dans Next.js génère un fallback H1 identique sur 1 200 variantes produit. Récit, diagnostic, fix.

Actualités SEO15 juin 2026

AI Overview Click Data : ce que les clics révèlent vraiment

Les utilisateurs quotidiens d'AI Overview cliquent 3.5x plus sur les sources. Analyse technique des données et stratégies d'optimisation concrètes.

Actualités SEO14 juin 2026

Siri + Gemini : impact concret sur la visibilité SEO

Apple intègre Gemini dans Siri. Analyse technique des conséquences pour le crawl, le rendering, le structured data et la visibilité organique de vos pages.

CMS14 juin 2026

Rank Math sitemap : mise à jour qui force une réindexation

Un update Rank Math change le format des sitemaps. Google traite chaque URL comme nouvelle. Récit du pic de crawl, de la chute, et du fix.

Framework13 juin 2026

TanStack Router SSR : le title vient du layout, pas de la page

Un e-commerce perd 40 % de clics organiques : TanStack Router applique le title du layout parent au lieu de la leaf route. Récit, diagnostic, fix.

CMS13 juin 2026

Yoast SEO désactivé par un update : meta vides sur 80% du blog

Un update WordPress désactive Yoast SEO sans alerte. 1 200 articles perdent leurs meta en silence. Récit, diagnostic technique et fix complet.