WordPress vers Strapi headless : 4000 redirections .htaccess oubliées en migration JAMstack
Jeudi 6 mars, 22h. L'équipe ops d'un média B2B français (12 000 articles, 1,4 million de sessions organiques par mois) coupe le DNS vers l'ancien WordPress. Le nouveau front Astro + Strapi v5 prend le relais. Le lighthouse score passe de 38 à 96. Le TTFB chute de 2,1 s à 180 ms. Tout le monde trinque sur Slack. Neuf jours plus tard, le trafic organique a fondu de 60 %. Search Console affiche 4 217 URL en erreur 404. Personne n'a porté le .htaccess.
Lundi 15 mars, 8h12 — Le dashboard vire au rouge
C'est la directrice éditoriale qui sonne l'alarme. Le rapport Looker Studio quotidien affiche 14 200 sessions organiques le dimanche, contre 38 000 le dimanche précédent. Elle tag le lead SEO sur Slack : "Bug GA4 ou vrai problème ?"
Le lead SEO ouvre GA4. Le trafic referral est stable. Le trafic direct est stable. Le trafic organique seul a décroché — courbe en falaise, pas en pente douce. Il ouvre Search Console.
Pages avec erreurs : 4 217 URL en 404 soft ou 404 hard.
Premier réflexe : vérifier si le sitemap est indexé. Il l'est. Les nouvelles URL /articles/mon-slug remontent correctement. Le problème ne vient pas des nouvelles pages. Il vient des anciennes.
Le lead SEO tape dans la barre d'adresse une URL qu'il connaît par cœur — un article de 2019 qui générait 800 clics par semaine. 404. Il essaie la version avec /blog/categorie/sous-categorie/slug — l'ancien format WordPress. 404. Il essaie /blog/slug — le format intermédiaire après la refonte de 2021. 404.
L'hypothèse initiale : "Les slugs ont changé lors de la migration Strapi, il faut régénérer le sitemap avec les anciens slugs." Le dev front vérifie. Les slugs n'ont pas changé. Le contenu est bien là, sous /articles/slug. Le problème est ailleurs.
Le lead SEO lance un crawl Screaming Frog sur les 500 URL les plus performantes de l'ancien site (exportées depuis Search Console > Performances > Pages, triées par clics sur 16 mois). Résultat :
- 312 URL retournent un 404
- 188 URL retournent un 200 (celles dont le slug correspond au nouveau format)
- 0 URL retournent un 301
Zéro redirect. C'est là que le lead SEO comprend l'ampleur. Il demande au dev ops : "Où est passé le .htaccess ?"
Silence. Puis : "On n'a plus Apache. On est sur Nginx derrière Cloudflare."
Sept ans de redirections accumulées — refontes de catégories, fusions d'articles, corrections de typos dans les slugs, migrations de structures d'URL de 2018, 2021 et 2023 — tout ça vivait dans un fichier .htaccess de 4 137 lignes. Ce fichier n'a jamais quitté le serveur Apache. Personne ne l'a exporté. Personne ne l'a converti. Personne n'a pensé à le porter parce que, dans la checklist de migration, la ligne "redirections" n'existait pas.
Le trafic perd 8 % supplémentaire chaque jour. Google continue de crawler les anciennes URL, reçoit des 404, et commence à les désindexer.
Le bug : 4 137 lignes de mémoire institutionnelle laissées sur un serveur éteint
Pour comprendre la gravité, il faut voir ce que contenait ce .htaccess. L'équipe récupère le fichier depuis un backup OVH daté de la veille du switch DNS.
Le fichier .htaccess original (extrait)
# Refonte structure URL 2021
RewriteRule ^blog/marketing-digital/(.*)$ /blog/$1 [R=301,L]
RewriteRule ^blog/strategie-contenu/(.*)$ /blog/$1 [R=301,L]
RewriteRule ^blog/seo-technique/(.*)$ /blog/$1 [R=301,L]
# Fusions d'articles 2022
Redirect 301 /blog/guide-seo-debutant /blog/guide-seo-complet-2022
Redirect 301 /blog/guide-seo-2020 /blog/guide-seo-complet-2022
Redirect 301 /blog/checklist-seo-technique /blog/guide-seo-complet-2022
# Corrections slug typos (lot de 200+)
Redirect 301 /blog/statregie-contenu-b2b /blog/strategie-contenu-b2b
Redirect 301 /blog/anaylse-concurrentielle /blog/analyse-concurrentielle
# Migration domaine 2018 (sous-domaine blog)
RewriteCond %{HTTP_HOST} ^blog\.example\.com$ [NC]
RewriteRule ^(.*)$ https://www.example.com/blog/$1 [R=301,L]
# Pages supprimées → redirections vers catégorie
Redirect 301 /blog/outil-xyz-test /blog/categorie/outils
# ... 3800+ lignes supplémentaires
Ce fichier est un palimpseste. Quatre migrations successives s'y empilent. Certaines redirections pointent vers des URL qui sont elles-mêmes redirigées plus bas dans le fichier — des chaînes de 2 ou 3 sauts. Certaines RewriteRule utilisent des regex complexes pour gérer des patterns de catégories dynamiques.
Ce que voit le développeur vs ce que voit Googlebot
Le développeur, dans son navigateur, tape example.com/articles/guide-seo-complet-2022. La page s'affiche, rapide, propre, score Lighthouse parfait. Il ne tape jamais example.com/blog/guide-seo-debutant parce que cette URL n'existe plus dans son univers mental.
Googlebot, lui, a dans son index 4 200+ URL au format /blog/.... Ses signaux de ranking — les backlinks, l'historique de crawl, les données de clic — sont attachés à ces URL. Quand il les recrawle, il reçoit un 404. Pas un 301 vers la nouvelle URL. Un 404 brut.
Pour vérifier exactement ce que Googlebot reçoit, le lead SEO utilise l'outil d'inspection d'URL de Search Console sur une des anciennes URL critiques :
URL inspectée : https://www.example.com/blog/guide-seo-complet-2022
Couverture : URL non trouvée (404)
Dernière exploration : 12 mars 2026
Type d'exploration : Googlebot Smartphone
Puis il vérifie côté serveur avec curl, en simulant Googlebot :
curl -I -A "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.6422.175 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" \
"https://www.example.com/blog/guide-seo-complet-2022"
Réponse :
HTTP/2 404
content-type: text/html; charset=utf-8
x-powered-by: Astro
404 propre. Pas de page d'erreur customisée avec un status 200 (soft 404). Un vrai 404. C'est presque pire : Google n'hésite pas, il désindexe.
Pourquoi personne n'a vu le problème avant le launch
Trois raisons techniques convergent.
1. L'environnement de staging n'avait pas de données historiques. Le staging Strapi contenait 200 articles de test importés manuellement. Aucun ne portait d'ancienne URL. Les tests E2E (Playwright) validaient la navigation interne du nouveau site — pas la rétrocompatibilité avec l'ancien.
2. Le .htaccess était invisible dans le repo. WordPress était hébergé sur un mutualisé OVH. Le .htaccess était édité en production via le gestionnaire de fichiers cPanel — jamais versionné dans Git. Quand l'équipe a migré le contenu via l'API REST WordPress → Strapi, le fichier .htaccess n'a pas été dans le périmètre.
3. Le changement de serveur web a rendu le format incompatible. Même si quelqu'un avait pensé à copier le fichier, .htaccess est un fichier Apache. Le nouveau front tourne sur un serveur Node (Astro SSR) derrière Nginx et Cloudflare. Il aurait fallu convertir chaque RewriteRule et Redirect en configuration Nginx ou en logique applicative — un travail d'extraction et de transformation que personne n'avait estimé.
L'ampleur réelle mesurée
Le lead SEO exporte les données Search Console des 28 jours pré-migration et les compare aux 9 jours post-migration :
| Métrique | Pré-migration (28j avg/jour) | Post-migration (J+9) | Delta |
|---|---|---|---|
| Clics organiques / jour | 16 400 | 6 100 | −63 % |
| Impressions / jour | 285 000 | 142 000 | −50 % |
| Pages indexées (Coverage) | 11 847 | 8 230 | −3 617 |
| URL en erreur 404 | 12 | 4 217 | +4 205 |
Les 4 217 URL en 404 ne sont pas toutes des pages de contenu actif. L'analyse Screaming Frog révèle :
- 1 840 URL sont des anciennes pages redirigées vers d'autres pages (chaînes de redirects historiques)
- 1 620 URL sont d'anciens slugs correspondant à du contenu qui existe toujours sous un nouveau chemin
- 480 URL sont des pages de catégories/tags supprimées qui redirigaient vers des pages parentes
- 277 URL sont des pages réellement supprimées qui redirigaient vers la homepage ou une landing pertinente
Chacune de ces URL portait du jus de lien. Certaines accumulaient des backlinks depuis 5 ans. Les couper revient à couper l'alimentation électrique d'un immeuble étage par étage.
Le fix : convertir, déployer, purger, attendre
L'équipe met en place le correctif en trois phases sur 48 heures.
Phase 1 — Extraction et nettoyage du .htaccess (4 heures)
Le fichier brut contient des doublons, des redirections obsolètes (pointant vers des URL elles-mêmes en 404 depuis des années), et des chaînes de redirects. Le lead SEO écrit un script Node pour parser, dédupliquer et aplatir les chaînes :
// parse-htaccess.mjs
import { readFileSync, writeFileSync } from 'fs';
const raw = readFileSync('.htaccess', 'utf-8');
const redirectMap = new Map();
for (const line of raw.split('\n')) {
// Match "Redirect 301 /old /new"
const simpleMatch = line.match(/^Redirect\s+301\s+(\S+)\s+(\S+)/);
if (simpleMatch) {
redirectMap.set(simpleMatch[1], simpleMatch[2]);
continue;
}
// Match "RewriteRule ^old$ /new [R=301,L]"
const rewriteMatch = line.match(/^RewriteRule\s+\^([^$]+)\$?\s+(\S+)\s+\[R=301/);
if (rewriteMatch) {
// Skip regex patterns — flag for manual review
if (/[(.+*?\\]/.test(rewriteMatch[1])) {
console.warn(`MANUAL REVIEW: ${line.trim()}`);
continue;
}
redirectMap.set('/' + rewriteMatch[1], rewriteMatch[2]);
}
}
// Flatten chains: /a → /b → /c becomes /a → /c
for (const [source, target] of redirectMap) {
let final = target;
let depth = 0;
while (redirectMap.has(final) && depth < 5) {
final = redirectMap.get(final);
depth++;
}
if (final !== target) {
redirectMap.set(source, final);
console.log(`CHAIN FLATTENED: ${source} → ${target} → ${final}`);
}
}
// Remove self-redirects
for (const [source, target] of redirectMap) {
if (source === target) redirectMap.delete(source);
}
// Export as JSON
const output = Object.fromEntries(redirectMap);
writeFileSync('redirects.json', JSON.stringify(output, null, 2));
console.log(`Exported ${redirectMap.size} redirects`);
Résultat : 3 841 redirections propres, aplaties. 47 patterns regex flaggés pour revue manuelle (gérés en RewriteRule avec des captures groups).
Phase 2 — Implémentation dans Astro + Nginx (6 heures)
Deux couches de redirections. La première, côté Nginx, gère le gros volume de redirections statiques. La seconde, côté Astro middleware, gère les patterns regex restants.
Configuration Nginx (/etc/nginx/conf.d/redirects.conf) :
# Généré depuis redirects.json — 3841 entrées
# Utiliser map pour éviter une cascade de if
map $request_uri $redirect_target {
default "";
/blog/guide-seo-debutant /articles/guide-seo-complet;
/blog/guide-seo-2020 /articles/guide-seo-complet;
/blog/statregie-contenu-b2b /articles/strategie-contenu-b2b;
/blog/marketing-digital/audit-seo /articles/audit-seo;
# ... 3837 entrées supplémentaires (fichier généré)
}
server {
# ... config existante ...
if ($redirect_target != "") {
return 301 $redirect_target;
}
}
Middleware Astro pour les patterns regex (47 règles) :
// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';
const regexRedirects: Array<{ pattern: RegExp; replacement: string }> = [
{
pattern: /^\/blog\/marketing-digital\/(.+)$/,
replacement: '/articles/$1',
},
{
pattern: /^\/blog\/strategie-contenu\/(.+)$/,
replacement: '/articles/$1',
},
{
pattern: /^\/blog\/seo-technique\/(.+)$/,
replacement: '/articles/$1',
},
{
pattern: /^\/blog\/categorie\/([^/]+)$/,
replacement: '/rubriques/$1',
},
// ... 43 patterns supplémentaires
];
export const onRequest = defineMiddleware(({ request, redirect }, next) => {
const url = new URL(request.url);
const path = url.pathname;
for (const { pattern, replacement } of regexRedirects) {
const match = path.match(pattern);
if (match) {
const target = path.replace(pattern, replacement);
return redirect(target, 301);
}
}
return next();
});
Phase 3 — Validation et purge (2 heures)
Avant de déployer en production, l'équipe valide en staging avec un script curl parallèle :
# Tester les 3841 redirections en parallèle (20 threads)
cat redirects.json | jq -r 'to_entries[] | "\(.key) \(.value)"' | \
xargs -P 20 -L 1 bash -c '
status=$(curl -s -o /dev/null -w "%{http_code}" "https://staging.example.com$0")
location=$(curl -s -o /dev/null -w "%{redirect_url}" "https://staging.example.com$0")
if [ "$status" != "301" ]; then
echo "FAIL: $0 → status=$status (expected 301 → $1)"
fi
'
Zéro échec. Déploiement en production à 19h. Purge du cache Cloudflare via API. Soumission d'un recrawl des 500 URL prioritaires via l'API Search Console Indexing (la soumission en lot, pas l'inspection individuelle, pour les sites éligibles).
Récupération observée
Le trafic ne remonte pas instantanément. Google doit recrawler chaque ancienne URL, recevoir le 301, puis transférer les signaux vers la nouvelle URL. Chronologie observée :
- J+2 après le fix : les 404 dans Search Console commencent à diminuer (de 4 217 à 3 100)
- J+7 : 1 400 URL encore en 404 (Google n'a pas encore tout recrawlé)
- J+14 : 380 URL en 404 — les résidus sont des pages très peu crawlées
- J+21 : le trafic organique remonte à 78 % du niveau pré-migration
- J+35 : le trafic atteint 94 % — les 6 % restants correspondent à des URL dont les backlinks externes pointent vers des chaînes de 3+ redirections, que certains navigateurs et bots résolvent mal
Au total, l'incident aura coûté environ 340 000 clics organiques sur 5 semaines (9 jours sans redirections + 26 jours de récupération progressive). Pour un média monétisé au CPM, l'estimation de revenu perdu dépasse 15 000 euros.
Les mesures de prévention mises en place
L'équipe ajoute trois garde-fous dans son pipeline CI/CD :
1. Le fichier de redirections est versionné. Plus jamais édité en production via cPanel. Le redirects.json vit dans le repo Git, avec un test unitaire qui vérifie que chaque source est unique et que chaque cible retourne un 200 en staging.
2. Un crawl de régression pré-deploy. Screaming Frog est lancé en mode CLI sur les 1 000 URL les plus performantes (export Search Console actualisé chaque semaine). Tout 404 ou 5xx bloque le deploy.
3. Une alerte Search Console quotidienne. Un script Apps Script requête l'API Search Console chaque matin à 7h et envoie un message Slack si le nombre de pages en erreur augmente de plus de 50 en 24 heures.
Ce qu'on en retient
Une migration technique peut être impeccable côté performance, design et architecture — et catastrophique côté SEO si la mémoire de l'ancien site n'est pas portée. Le .htaccess n'est pas un fichier de config serveur. C'est un registre de 7 ans de décisions SEO. Chaque ligne est un lien entre une URL morte et le trafic qu'elle a transmis à sa remplaçante.
La checklist de migration doit inclure, avant tout changement de serveur web : l'export exhaustif des règles de redirection existantes, leur conversion vers le nouveau format, et leur validation automatisée. Pas comme une tâche optionnelle. Comme un pré-requis au switch DNS.
C'est précisément le type de régression silencieuse — des milliers de 301 qui disparaissent d'un coup — qu'un monitoring continu type Seogard détecte dans les heures qui suivent un déploiement, pas trois semaines plus tard via un dashboard GA4.
D'autres récits de migrations qui tournent mal : Vue 2 vers Vue 3 : 47 pages produit en panne SEO, Next.js Pages Router vers App Router : metadata ignorées, Gatsby vers Astro : RSS feed orphelin pendant 6 semaines, Nuxt 2 vers Nuxt 3 : 200 pages en fallback layout. Et pour ceux qui veulent stress-tester leur staging avant de vivre la même chose : comment stress-tester un environnement de staging.