Un seed Strapi efface le rôle Public : 2 400 pages blanches indexées pendant 19 jours
Jeudi 16h42. L'équipe backend d'une marketplace française de mobilier — 2 400 fiches produit, 180 000 visites organiques mensuelles — merge un script de seed censé initialiser les données de staging. Le déploiement passe. Les tests end-to-end passent. Le navigateur affiche les pages normalement. Personne ne remarque que le SSR Next.js, lui, sert désormais du vide. Googlebot non plus ne dit rien. Pas tout de suite.
Lundi 9h12 — "Le trafic a planté ce week-end"
L'alerte vient du channel Slack #seo-monitoring. Le lead SEO poste une capture d'écran de Google Search Console : les clics du week-end sont en chute libre. Samedi : −38 %. Dimanche : −51 %. Le lundi matin en temps réel, la tendance continue.
Première hypothèse : un problème de serveur. L'équipe devops vérifie les uptime monitors. Tout est vert. TTFB stable à 290 ms. Aucun 5xx dans les logs Nginx.
Deuxième hypothèse : une mise à jour d'algorithme Google. Le lead SEO vérifie les trackers habituels — Semrush Sensor, MozCast. Rien de significatif sur la période.
Troisième hypothèse : un problème d'indexation. Le lead ouvre l'outil d'inspection d'URL dans Search Console sur une fiche produit populaire. Le rendu HTML renvoyé par Google montre… une page quasi vide. Le shell de l'application est là — header, footer, layout — mais le contenu produit est absent. Pas de titre produit. Pas de description. Pas de prix. Pas de structured data.
À 9h47, le lead reproduit avec curl depuis son terminal :
curl -s https://www.example.com/produit/chaise-scandinave-noyer | grep '<h1'
Résultat : <h1 class="product-title"></h1>. Le H1 est vide.
Il ouvre la même URL dans Chrome. Le H1 affiche "Chaise scandinave — Noyer massif". Le contenu est là, rendu côté client par React après hydratation. Mais le HTML servi par le serveur — celui que Googlebot consomme en priorité — est vide.
Le lead sait ce que ça signifie. Le fetch SSR de Next.js vers l'API Strapi échoue silencieusement. Il ouvre le channel #backend : "Qui a touché à Strapi jeudi ?"
Le silence dure quatre minutes. Puis le dev backend répond : "J'ai mergé le seed script pour staging. Mais ça ne devrait pas affecter la prod."
À 10h03, l'équipe commence à comprendre que le "ça ne devrait pas" est le problème.
Le bug : un seed qui réinitialise les permissions du rôle Public
Ce qui s'est passé dans le seed
L'équipe utilise Strapi v4 (4.15.x) avec le plugin Users & Permissions. Le script de seed, prévu pour staging, réinitialise les rôles pour garantir un état propre avant les tests. Le problème : le script tourne aussi en production.
Voici le fichier src/index.ts de Strapi, dans la fonction bootstrap :
// src/index.ts — fonction bootstrap de Strapi
export default {
async bootstrap({ strapi }) {
// Seed initial "pour staging" — mais exécuté à chaque démarrage
const publicRole = await strapi
.query('plugin::users-permissions.role')
.findOne({ where: { type: 'public' } });
if (publicRole) {
// Réinitialise TOUTES les permissions du rôle Public
await strapi
.query('plugin::users-permissions.permission')
.deleteMany({ where: { role: publicRole.id } });
// Recrée uniquement les permissions auth
const authActions = [
'plugin::users-permissions.auth.callback',
'plugin::users-permissions.auth.connect',
'plugin::users-permissions.auth.register',
];
for (const action of authActions) {
await strapi.query('plugin::users-permissions.permission').create({
data: { action, role: publicRole.id },
});
}
}
strapi.log.info('Bootstrap seed completed');
},
};
Le script supprime toutes les permissions du rôle Public, puis ne recrée que les permissions d'authentification. Les permissions find et findOne des content-types — api::product.product, api::category.category, api::page.page — disparaissent.
Résultat : chaque appel API public vers /api/products, /api/categories, /api/pages renvoie un 403 Forbidden.
Ce que voit le développeur vs ce que voit Googlebot
Le développeur ouvre https://www.example.com/produit/chaise-scandinave-noyer dans Chrome. Voici ce qui se passe :
- Le serveur Next.js exécute
getServerSideProps. getServerSidePropsfetchhttps://strapi.internal/api/products?filters[slug]=chaise-scandinave-noyer.- Strapi renvoie 403.
- Le code Next.js catch l'erreur et retourne un objet props vide au lieu de faire un throw :
// pages/produit/[slug].tsx
export const getServerSideProps: GetServerSideProps = async ({ params }) => {
try {
const res = await fetch(
`${process.env.STRAPI_URL}/api/products?filters[slug][$eq]=${params.slug}&populate=*`,
{ headers: { Authorization: `Bearer ${process.env.STRAPI_PUBLIC_TOKEN}` } }
);
const json = await res.json();
return { props: { product: json.data?.[0] ?? null } };
} catch (error) {
// L'erreur est avalée. Pas de log. Pas de throw. Pas de notFound.
return { props: { product: null } };
}
};
Problème supplémentaire : le code utilise un STRAPI_PUBLIC_TOKEN — un token API Strapi — mais le rôle associé à ce token est aussi "Public" dans la configuration Strapi de cette équipe. Le 403 frappe donc aussi les requêtes authentifiées par token.
- Le serveur renvoie un HTML 200 avec le layout mais sans données produit.
- Le composant React côté client tente un second fetch, cette fois depuis le navigateur du développeur. Ce fetch passe par un BFF (Backend for Frontend) interne qui, lui, utilise un token avec le rôle "Authenticated". Ce token a encore ses permissions
find. - Le contenu s'affiche côté client après hydratation.
Le développeur voit le contenu. Googlebot, lui, reçoit le HTML SSR vide et n'exécute pas le fetch client de la même manière. Même si Googlebot exécute du JavaScript, le BFF interne nécessite un cookie de session que le bot n'a pas.
Voici le HTML que Googlebot reçoit :
<!DOCTYPE html>
<html lang="fr">
<head>
<title>Produit | MaisonDeco</title>
<meta name="description" content="" />
<meta name="robots" content="index, follow" />
</head>
<body>
<header><!-- navigation --></header>
<main>
<article class="product-page">
<h1 class="product-title"></h1>
<div class="product-description"></div>
<span class="product-price"></span>
</article>
</main>
<footer><!-- footer --></footer>
</body>
</html>
Un H1 vide. Une meta description vide. Aucun structured data Product. Le title est le fallback générique du layout. Sur 2 400 fiches produit, c'est la même chose. Et les 87 pages catégories sont dans le même état — elles aussi dépendent de l'API Strapi pour leur contenu.
Pourquoi les tests n'ont rien détecté
Trois raisons.
1. Le pipeline CI/CD teste en mode "Authenticated". Les tests Cypress s'exécutent après un login utilisateur. Le rôle Authenticated a toujours ses permissions. Les pages rendent correctement dans le contexte de test.
2. Le seed n'a pas de garde d'environnement. Le script bootstrap s'exécute à chaque démarrage Strapi, tous environnements confondus. L'équipe n'a jamais ajouté de condition if (process.env.NODE_ENV === 'staging').
3. Le code SSR avale les erreurs. Le try/catch dans getServerSideProps transforme un 403 en props: { product: null }. Le serveur renvoie un 200. Les monitors de santé ne voient rien. Aucune alerte. Le problème est similaire à ce qu'on observe quand un fallback meta vide se substitue au contenu réel — sauf qu'ici c'est l'intégralité du contenu qui disparaît.
Vérification : confirmer le 403
L'équipe confirme le diagnostic en interrogeant l'API Strapi directement, sans token :
# Requête publique sans authentification
curl -s -o /dev/null -w "%{http_code}" \
https://strapi.example.com/api/products?filters[slug][$eq]=chaise-scandinave-noyer
# Résultat : 403
Puis avec le token Public :
curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer $STRAPI_PUBLIC_TOKEN" \
https://strapi.example.com/api/products?filters[slug][$eq]=chaise-scandinave-noyer
# Résultat : 403
Et en vérifiant les permissions via l'admin Strapi :
# Lister les permissions du rôle Public via l'API admin
curl -s -H "Authorization: Bearer $STRAPI_ADMIN_TOKEN" \
https://strapi.example.com/api/users-permissions/roles | \
jq '.roles[] | select(.type == "public") | .permissions'
# Résultat : seules les 3 permissions auth sont présentes.
# Aucune permission api::product, api::category, api::page.
Le diagnostic est confirmé. Le seed a nettoyé les permissions find et findOne de tous les content-types publics.
Le fix : restaurer, sécuriser, alerter
Étape 1 — Restaurer les permissions immédiatement
Pas question de passer par l'admin UI pour cocher manuellement les cases sur 12 content-types. L'équipe écrit un script de restauration ciblé :
// scripts/restore-public-permissions.ts
import Strapi from '@strapi/strapi';
async function restorePublicPermissions() {
const appContext = await Strapi().load();
const strapi = appContext;
const publicRole = await strapi
.query('plugin::users-permissions.role')
.findOne({ where: { type: 'public' } });
if (!publicRole) {
console.error('Rôle Public introuvable');
process.exit(1);
}
// Content-types qui doivent être lisibles publiquement
const publicContentTypes = [
'api::product.product',
'api::category.category',
'api::page.page',
'api::brand.brand',
'api::collection.collection',
];
const actions = ['find', 'findOne'];
for (const contentType of publicContentTypes) {
for (const action of actions) {
const fullAction = `${contentType}.${action}`;
const existing = await strapi
.query('plugin::users-permissions.permission')
.findOne({ where: { action: fullAction, role: publicRole.id } });
if (!existing) {
await strapi
.query('plugin::users-permissions.permission')
.create({ data: { action: fullAction, role: publicRole.id } });
console.log(`✓ Créé : ${fullAction}`);
} else {
console.log(`— Existe déjà : ${fullAction}`);
}
}
}
console.log('Restauration terminée');
process.exit(0);
}
restorePublicPermissions();
Le script est exécuté à 10h31, lundi. Les permissions sont restaurées en 4 secondes.
Étape 2 — Supprimer le seed destructeur
Le code bootstrap est nettoyé. Le deleteMany sur les permissions est supprimé. L'équipe ajoute une garde d'environnement stricte pour tout futur seed :
// src/index.ts — corrigé
export default {
async bootstrap({ strapi }) {
if (process.env.NODE_ENV !== 'development') {
strapi.log.info('Bootstrap seed skipped (non-dev environment)');
return;
}
// Seed uniquement en développement local
// ...
},
};
Étape 3 — Rendre le SSR résistant aux 403
Le getServerSideProps est refactorisé pour ne plus avaler les erreurs :
// pages/produit/[slug].tsx — corrigé
export const getServerSideProps: GetServerSideProps = async ({ params, res }) => {
const apiRes = await fetch(
`${process.env.STRAPI_URL}/api/products?filters[slug][$eq]=${params.slug}&populate=*`,
{ headers: { Authorization: `Bearer ${process.env.STRAPI_PUBLIC_TOKEN}` } }
);
if (!apiRes.ok) {
// Log explicite pour le monitoring
console.error(`Strapi API error: ${apiRes.status} for slug ${params.slug}`);
if (apiRes.status === 403) {
// Renvoyer un 503 pour que Googlebot revienne plus tard
res.statusCode = 503;
res.setHeader('Retry-After', '3600');
return { props: { product: null, error: 'PERMISSION_DENIED' } };
}
return { notFound: true };
}
const json = await apiRes.json();
const product = json.data?.[0] ?? null;
if (!product) {
return { notFound: true };
}
return { props: { product } };
};
Le 503 avec Retry-After est un choix délibéré. Comme le documente Google Search Central, un 503 signale à Googlebot que l'indisponibilité est temporaire. Le bot reviendra. Un 200 avec du contenu vide, lui, entraîne une désindexation progressive.
Ce pattern d'erreur silencieuse SSR est un classique des architectures headless. L'incident rappelle le cas d'un preview mode Sanity resté actif en production — un autre scénario où le CMS envoie les mauvaises données au SSR sans que personne ne s'en aperçoive.
Étape 4 — Ajouter un health check API permissions
L'équipe ajoute un test automatisé dans le pipeline de déploiement :
#!/bin/bash
# ci/check-strapi-public-permissions.sh
STRAPI_URL="${STRAPI_URL:-https://strapi.example.com}"
ENDPOINTS=(
"/api/products?pagination[limit]=1"
"/api/categories?pagination[limit]=1"
"/api/pages?pagination[limit]=1"
)
for endpoint in "${ENDPOINTS[@]}"; do
status=$(curl -s -o /dev/null -w "%{http_code}" "${STRAPI_URL}${endpoint}")
if [ "$status" -ne 200 ]; then
echo "ERREUR: ${endpoint} retourne ${status} (attendu 200)"
exit 1
fi
echo "OK: ${endpoint} → ${status}"
done
echo "Toutes les permissions publiques sont fonctionnelles"
Ce script est ajouté comme étape post-déploiement dans le pipeline GitHub Actions. Si un endpoint public renvoie autre chose qu'un 200, le déploiement est marqué en échec et une alerte Slack est envoyée.
Étape 5 — Invalider le cache et demander la réindexation
Le site utilise un CDN Cloudflare. L'équipe purge le cache de toutes les pages produit et catégorie. Puis elle soumet le sitemap dans Search Console pour accélérer le recrawl.
L'équipe lance aussi Screaming Frog sur l'intégralité du site en mode "JavaScript rendering" pour vérifier que toutes les pages servent désormais le contenu SSR correct.
Résultat du crawl Screaming Frog post-fix :
- 2 400 fiches produit : H1 présent, meta description renseignée, structured data Product détecté.
- 87 pages catégorie : H1 et contenu restaurés.
- 0 page avec H1 vide.
La récupération
La timeline de récupération observée dans Search Console :
- J+0 (lundi, 10h31) : fix déployé.
- J+1 à J+3 : Googlebot recrawle environ 400 pages/jour. Les résultats mis en cache avec le contenu vide persistent dans l'index.
- J+5 : les premières fiches produit retrouvent leur snippet enrichi dans les SERPs.
- J+10 : le trafic organique remonte à 72 % du niveau pré-incident.
- J+14 : 91 % du trafic récupéré.
- J+19 : retour complet au niveau antérieur. Le nombre de pages indexées dans Search Console correspond à nouveau au sitemap.
Impact total estimé : −112 000 clics organiques sur 19 jours. Pour une marketplace dont le trafic organique génère environ 35 % du chiffre d'affaires, le coût est significatif.
Ce qu'on en retient
Un script de seed sans garde d'environnement est une bombe à retardement. Un try/catch qui avale un 403 est un complice silencieux. Les deux combinés transforment un CMS headless en distributeur de pages blanches — sans qu'aucun monitor classique ne bronche.
Trois règles à graver :
- Tout seed destructeur doit être conditionné à l'environnement.
process.env.NODE_ENVou une variable dédiée. Sans exception. - Le SSR ne doit jamais servir un 200 quand la source de données échoue. Un 503 avec
Retry-Afterprotège l'index. Un 200 vide le détruit. Le pattern rappelle d'autres cas où le contenu est invisible au fetch HTTP brut malgré une apparence normale dans le navigateur. - Les permissions CMS doivent être testées automatiquement après chaque déploiement. Pas manuellement. Pas "quand on y pense".
Un monitoring continu type Seogard détecte ce type de divergence entre rendu navigateur et rendu SSR/bot en quelques minutes — pas trois semaines après dans un graphique Search Console.