Storyblok : 1 200 redirections custom disparaissent lors d'un upgrade de plan
Mardi 14h. L'équipe ops d'un site e-commerce français — 8 400 pages produit, 2,1 millions de sessions mensuelles — finalise la migration de son espace Storyblok du plan Startup vers le plan Business. Le backoffice affiche le nouveau plan. Les composants sont là. Les stories aussi. Personne ne vérifie la table de redirections stockée dans un content type custom. 72 heures plus tard, Search Console remonte 1 247 erreurs 404 sur des URLs qui généraient encore 38 000 clics par mois.
Mercredi 9h12 — Les premières alertes
L'alerte vient de Slack, pas de Search Console. Un développeur front remarque un pic d'erreurs 404 dans les logs Vercel du middleware de redirection. Il ouvre le dashboard : 847 requêtes 404 en une heure sur des anciennes URLs produit — des slugs de la V2 du site, migrés il y a onze mois.
Premier réflexe : vérifier le middleware Next.js. Le code n'a pas bougé depuis trois semaines. Le fichier middleware.ts fetch bien l'API Storyblok au démarrage pour charger la map de redirections. Le problème n'est pas dans le code.
Deuxième réflexe : ouvrir Storyblok. Le content type redirect-rule existe toujours dans le schéma. Mais quand l'équipe filtre les stories de type redirect-rule, le résultat est vide. Zéro entrée. 1 247 stories de redirection ont disparu.
L'hypothèse initiale est un bug d'affichage. Quelqu'un rafraîchit. Quelqu'un vide le cache du navigateur. Quelqu'un essaie l'API directement :
curl -s "https://api.storyblok.com/v2/cdn/stories?starts_with=redirects/&token=$STORYBLOK_TOKEN&per_page=25" | jq '.stories | length'
Réponse : 0.
La panique s'installe à 9h34. Le lead SEO ouvre Search Console. La couverture d'indexation montre déjà 312 URLs passées de "Valide" à "Non trouvée (404)". Google a crawlé une partie des anciennes URLs pendant la nuit. Le reste va suivre.
L'équipe contacte le support Storyblok. En parallèle, quelqu'un vérifie le changelog de l'espace : la migration de plan date de mardi 14h07. Aucune suppression manuelle de story n'apparaît dans les logs d'activité après cette date. Les stories n'ont pas été supprimées — elles n'ont pas été portées.
Le lead dev ouvre l'ancien espace (encore accessible en lecture pendant 48h après downgrade) et confirme : les 1 247 stories redirect-rule sont bien là. Dans le nouvel espace Business, elles n'existent pas.
À 10h15, le constat est posé : l'export-import de plan n'a pas inclus les stories dont le content type était marqué comme "nestable" plutôt que "content type". Et redirect-rule avait été créé comme nestable component il y a onze mois, avant d'être promu en content type — sans que la conversion soit complète côté Storyblok.
Le bug : un content type fantôme entre deux mondes
Pour comprendre ce qui s'est passé, il faut revenir à l'architecture de redirections mise en place un an plus tôt.
L'architecture initiale
L'équipe avait créé un composant Storyblok nommé redirect-rule avec trois champs :
{
"name": "redirect-rule",
"schema": {
"source_path": {
"type": "text",
"required": true,
"description": "Ancien chemin (ex: /produit/ancien-slug)"
},
"target_path": {
"type": "text",
"required": true,
"description": "Nouveau chemin (ex: /p/nouveau-slug)"
},
"status_code": {
"type": "option",
"options": [
{ "name": "301", "value": "301" },
{ "name": "302", "value": "302" }
],
"default_value": "301"
}
},
"is_nestable": false,
"is_root": true
}
Ce composant avait été créé initialement comme nestable (intégrable dans d'autres blocs), puis converti en root (utilisable comme content type autonome) via l'interface Storyblok. L'équipe avait ensuite créé un dossier redirects/ dans l'arborescence et y avait stocké 1 247 stories, une par règle de redirection.
Côté Next.js, le middleware chargeait ces stories au cold start :
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
interface RedirectRule {
source_path: string
target_path: string
status_code: '301' | '302'
}
let redirectMap: Map<string, RedirectRule> | null = null
async function loadRedirects(): Promise<Map<string, RedirectRule>> {
const map = new Map<string, RedirectRule>()
let page = 1
let total = 0
do {
const res = await fetch(
`https://api.storyblok.com/v2/cdn/stories?starts_with=redirects/&token=${process.env.STORYBLOK_TOKEN}&per_page=100&page=${page}&version=published`
)
const data = await res.json()
total = data.total
for (const story of data.stories) {
const content = story.content as RedirectRule
map.set(content.source_path, {
source_path: content.source_path,
target_path: content.target_path,
status_code: content.status_code || '301',
})
}
page++
} while ((page - 1) * 100 < total)
return map
}
export async function middleware(request: NextRequest) {
if (!redirectMap) {
redirectMap = await loadRedirects()
}
const rule = redirectMap.get(request.nextUrl.pathname)
if (rule) {
const statusCode = rule.status_code === '302' ? 302 : 301
return NextResponse.redirect(
new URL(rule.target_path, request.url),
statusCode
)
}
return NextResponse.next()
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}
Ce système fonctionnait depuis onze mois sans incident.
Ce qui se passe lors du changement de plan
Storyblok propose un mécanisme d'export/import d'espace lors d'un changement de plan. Le processus exporte les composants (schémas), les stories (contenus), les assets et les datasources. Mais l'export repose sur la classification interne des composants.
Le problème : redirect-rule avait été créé comme nestable, puis sa propriété is_root avait été passée à true via l'interface. Dans la base Storyblok, le composant conservait un flag interne component_group_id hérité de sa création en tant que nestable. Lors de l'export d'espace, le processus de sérialisation traitait les stories associées à des composants ayant ce flag comme des blocs imbriqués — pas comme des stories autonomes.
Résultat : les schémas de composants étaient bien exportés (y compris redirect-rule), mais les 1 247 stories du dossier redirects/ n'étaient pas incluses dans le fichier d'export. Elles appartenaient à un composant que le système considérait encore partiellement comme nestable.
Ce que voit le développeur vs ce que voit le middleware
Dans le navigateur, l'interface Storyblok du nouvel espace Business affiche le content type redirect-rule dans la liste des composants. Un éditeur peut même créer une nouvelle story de ce type. Tout semble normal.
Mais le dossier redirects/ est vide. Et le middleware Next.js, au prochain cold start, charge une map vide. Chaque requête vers une ancienne URL traverse le middleware sans match et atteint le router Next.js — qui renvoie un 404.
Pour confirmer le diagnostic, l'équipe exécute un diff entre l'export JSON de l'ancien espace et celui du nouveau :
# Extraction des slugs de stories dans l'ancien export
cat old-space-export.json | jq '[.stories[] | select(.full_slug | startswith("redirects/")) | .full_slug] | length'
# Résultat : 1247
# Même extraction dans le nouveau
cat new-space-export.json | jq '[.stories[] | select(.full_slug | startswith("redirects/")) | .full_slug] | length'
# Résultat : 0
1 247 à 0. Pas de suppression. Pas d'erreur loguée. Juste un export qui ne les a jamais incluses.
Pourquoi personne ne l'a vu
Trois raisons.
Premièrement, l'équipe QA testait les pages du site — pas les redirections. Le plan de test post-migration couvrait les composants visuels, les stories produit, les pages catégorie. Personne n'avait de test automatisé vérifiant que l'API retournait des stories dans redirects/.
Deuxièmement, le middleware Next.js ne logue pas d'erreur quand la map est vide. Il initialise silencieusement une Map de taille 0 et traite chaque requête normalement. Pas de warning, pas d'alerte.
Troisièmement, les 404 ne sont pas immédiatement visibles côté utilisateur. Les anciennes URLs ne sont pas dans la navigation. Elles sont atteintes via des backlinks externes, des résultats de recherche, des bookmarks. Le trafic arrive — et repart avec un 404. Silencieusement. Pendant 72 heures, ce problème est invisible de l'intérieur. Seuls les logs serveur et Googlebot le voient. Un scénario comparable à ce qu'on observe quand un CMS headless sert du contenu vide à Googlebot sans que l'équipe s'en aperçoive.
Le fix : réimport, fallback et alerting
Étape 1 — Réimport d'urgence des stories de redirection
L'ancien espace étant encore accessible, l'équipe exporte les stories du dossier redirects/ via l'API Management :
#!/bin/bash
# Export des stories de redirection depuis l'ancien espace
OLD_TOKEN="old-space-management-token"
SPACE_ID="old-space-id"
PAGE=1
ALL_STORIES="[]"
while true; do
RESPONSE=$(curl -s -H "Authorization: $OLD_TOKEN" \
"https://mapi.storyblok.com/v1/spaces/$SPACE_ID/stories?starts_with=redirects/&per_page=100&page=$PAGE")
STORIES=$(echo "$RESPONSE" | jq '.stories')
COUNT=$(echo "$STORIES" | jq 'length')
if [ "$COUNT" -eq 0 ]; then
break
fi
ALL_STORIES=$(echo "$ALL_STORIES $STORIES" | jq -s 'add')
PAGE=$((PAGE + 1))
done
echo "$ALL_STORIES" > redirect-stories-backup.json
echo "Exported $(echo "$ALL_STORIES" | jq 'length') stories"
Puis réimport dans le nouvel espace, story par story, via l'API Management du nouveau space :
#!/bin/bash
NEW_TOKEN="new-space-management-token"
NEW_SPACE_ID="new-space-id"
# Créer le dossier redirects/ d'abord
curl -s -X POST -H "Authorization: $NEW_TOKEN" \
-H "Content-Type: application/json" \
-d '{"story":{"name":"redirects","slug":"redirects","is_folder":true}}' \
"https://mapi.storyblok.com/v1/spaces/$NEW_SPACE_ID/stories"
# Importer chaque story
cat redirect-stories-backup.json | jq -c '.[]' | while read -r story; do
NAME=$(echo "$story" | jq -r '.name')
SLUG=$(echo "$story" | jq -r '.slug')
CONTENT=$(echo "$story" | jq '.content')
curl -s -X POST -H "Authorization: $NEW_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"story\":{\"name\":\"$NAME\",\"slug\":\"$SLUG\",\"content\":$CONTENT,\"parent_id\":null},\"publish\":1}" \
"https://mapi.storyblok.com/v1/spaces/$NEW_SPACE_ID/stories" > /dev/null
echo "Imported: $SLUG"
done
L'import prend 23 minutes pour les 1 247 stories. Chaque story est publiée immédiatement.
Étape 2 — Invalidation du cache middleware
Le middleware Next.js cache la map de redirections en mémoire au cold start. Deux actions nécessaires :
- Redéploiement sur Vercel pour forcer un nouveau cold start.
- Ajout d'une variable d'environnement
REDIRECT_CACHE_TTL=3600pour forcer un rechargement horaire au lieu de garder la map indéfiniment.
// middleware.ts — version corrigée avec TTL
let redirectMap: Map<string, RedirectRule> | null = null
let lastLoadTime = 0
const CACHE_TTL = parseInt(process.env.REDIRECT_CACHE_TTL || '3600', 10) * 1000
export async function middleware(request: NextRequest) {
const now = Date.now()
if (!redirectMap || now - lastLoadTime > CACHE_TTL) {
redirectMap = await loadRedirects()
lastLoadTime = now
// Alerte si la map est vide ou anormalement petite
if (redirectMap.size < 100) {
console.error(
`[REDIRECT WARNING] Only ${redirectMap.size} redirect rules loaded. Expected 1200+.`
)
}
}
const rule = redirectMap.get(request.nextUrl.pathname)
if (rule) {
const statusCode = rule.status_code === '302' ? 302 : 301
return NextResponse.redirect(
new URL(rule.target_path, request.url),
statusCode
)
}
return NextResponse.next()
}
Le console.error avec seuil est rudimentaire mais aurait suffi à déclencher une alerte Vercel dès le premier cold start post-migration.
Étape 3 — Vérification Screaming Frog
L'équipe lance un crawl Screaming Frog en mode liste, en injectant un échantillon de 200 anciennes URLs. Résultat attendu : 200 réponses 301. Résultat obtenu après réimport : 198 réponses 301, 2 réponses 404. Les deux 404 correspondent à des stories dont le slug contenait des caractères spéciaux mal encodés lors de l'export. Fix manuel en 5 minutes.
Étape 4 — Demande de re-crawl
Pour les 312 URLs déjà passées en 404 dans l'index Google, l'équipe utilise l'outil d'inspection d'URL dans Search Console pour demander une réindexation sur les 50 pages les plus critiques. Pour le reste, le crawl naturel de Googlebot — environ 800 pages/jour sur ce site — doit suffire.
Temps de récupération
- J+1 après le fix : les logs Vercel montrent 0 erreur 404 sur les URLs redirigées.
- J+3 : Search Console commence à reclasser les URLs de "Non trouvée" à "Valide".
- J+9 : 95 % des URLs sont de retour dans l'index.
- J+14 : le trafic organique sur les pages cibles revient à son niveau pré-incident. Perte estimée sur la période : 12 400 clics, soit environ 6 800 € de revenu attribuable au SEO pour ce site.
L'impact aurait pu être bien pire. Les redirections servaient principalement des backlinks anciens et du trafic longue traîne. Si le content type perdu avait été les métadonnées SEO elles-mêmes, la récupération aurait pris des semaines, pas des jours.
Ce qu'on en retient
Les CMS headless ne sont pas des bases de données relationnelles. Leur couche d'export/import a des angles morts, surtout quand un composant a changé de nature au fil du temps. Storyblok n'est pas le seul concerné — tout CMS qui distingue "nestable" et "root" expose ce risque.
Trois garde-fous auraient évité cet incident. Un test automatisé post-migration qui compte les stories par content type et compare à l'espace source. Un seuil d'alerte dans le middleware quand la map de redirections passe sous un minimum attendu. Et un crawl de validation sur un échantillon d'anciennes URLs avant de couper l'ancien espace.
Un monitoring continu type Seogard détecte ce genre de régression — 1 200 URLs passant de 301 à 404 en une nuit — en quelques minutes. Pas en 72 heures.
Les redirections ne sont pas un détail d'ops. Ce sont des contenus critiques au même titre que les métadonnées, et elles méritent le même niveau de test, de backup et de surveillance.