Quitter WordPress pour Astro : 204 URL, des 301, un éditeur de bureau
Peut‑on quitter WordPress sans perdre une seule URL, ni le référencement, ni l’outil d’édition de l’équipe ? Deux migrations vers Astro passées au crible, avec le code, les chiffres réels et ce qui a cassé en route.

Fin septembre, le projet en était là : une maquette HTML complète du nouveau site d’une société de services numériques, neuf gabarits, quelques centaines de fichiers, et une note en tête du document de conception qui disait que tout cela préparait « une future intégration WordPress ». Une semaine plus tard, le site tournait sous Astro, l’équipe éditait ses pages avec une application de bureau, et WordPress n’était plus dans le tableau.
La question de départ était simple : peut‑on quitter WordPress sans perdre une seule URL, sans perdre le référencement accumulé, et sans priver l’équipe de la possibilité de modifier ses textes elle-même ? Voici comment la migration s’est déroulée, avec le code, les chiffres réels et ce qui a cassé en route. S’y ajoute un second chantier, plus court, mené ensuite avec la même méthode : un autre site, bilingue français / malgache.
1. Pourquoi ne pas simplement refaire un thème WordPress
Le plan initial, c’était bien un thème. La maquette avait été construite pour ça : des types de contenus, des champs, des compositions de blocs pensées pour être reproduites dans WordPress. Et puis, au relevé de l’existant qui devait préparer la migration, les constats se sont empilés. Ils sont tous consignés dans le dépôt :
- Le périmètre réel était flou. Le sitemap généré par l’extension SEO déclarait 161 adresses. La collecte complète, traductions, archives et anciennes adresses comprises, en a trouvé 204. Une quarantaine d’URL vivaient donc hors du sitemap.
- Des redirections en chaîne. 11 adresses étaient déjà en 301, dont certaines pointaient vers une page elle-même redirigée.
- Un HTML approximatif. Sur les articles anglais, le H1 de la page était « Blog », pas le titre de l’article. Les FAQ étaient des accordéons du constructeur de pages, sans données structurées.
- Du poids à retirer. Les polices pesaient 2,4 Mo, et dans la maquette les cartes du blog étaient générées en JavaScript, donc invisibles pour un robot qui n’exécute pas le script.
Refaire un thème, c’était reconstruire la maquette une deuxième fois dans un autre système, puis continuer à vivre avec des extensions pour le SEO, les formulaires, les redirections et la mise en page. Générer le site directement à partir de contenus Markdown, c’était garder la maquette telle quelle et ne réécrire que la mécanique. L’historique git montre le basculement : maquette HTML du 23 au 25 septembre, premier commit « site astro » le 30.
2. Extraire et convertir le contenu
La migration s’est appuyée sur deux sources, et c’est le choix qui a le plus servi par la suite.
La première source, c’est la maquette elle-même. Elle reprenait déjà les corps éditoriaux complets du site en ligne : 89 pages, 39 articles, 8 cas clients, 5 engagements, 7 catégories. Le contrôle fait à ce moment-là : sur les 1 715 paragraphes des 39 articles français, aucun paragraphe de plus de 45 caractères ne manquait après normalisation des espaces.
La seconde source, c’est le site en ligne, interrogé page par page. Pour chacune des 204 adresses, un script a relevé le statut HTTP, la destination éventuelle, le titre et la description SEO, la directive robots, la canonique et les dates. Résultat : 193 pages en 200, 11 en 301, 17 en noindex. Ce relevé est versionné dans le dépôt, il sert de vérité pour tout le reste.
La conversion lit chaque page de la maquette avec cheerio, découpe le <main> en blocs typés (texte, lignes, liens, cartes, étapes, FAQ…), passe la prose en Markdown avec Turndown et écrit un fichier par page, avec son en-tête YAML. Le SEO ne vient pas de la maquette mais du relevé en ligne :
function seoFor(url, $) {
const live = liveSeo[ORIGIN + url];
const seo = {};
if (live?.status === 200) {
seo.title = live.title;
if (live.description) seo.description = live.description;
if (live.robots?.includes('noindex')) seo.noindex = true;
} else {
seo.title = clean($('title').text());
// pages nouvelles : titre et description de la maquette
}
return { seo, live };
}
Une page qui existait garde donc exactement son titre, sa description, son statut d’indexation et ses dates de publication et de modification. Les pages nouvelles prennent ce que la maquette proposait.
Turndown a demandé quelques règles maison (deux listes consécutives qui fusionnent, du gras collé au mot suivant), et ce qui ne rentre dans aucun bloc connu part dans un bloc HTML de repli, compté dans un rapport.
Pour vérifier la fidélité, un second script compare le texte du <main> de chaque page générée avec celui de la maquette (similarité de Dice sur les paires de mots) et la structure (nombre d’éléments par classe CSS). Au moment de la migration, la similarité moyenne était de 99 %. Relancé pour cet article, le même script donne 54,8 %. Ce n’est pas une régression, c’est que les contenus ont été largement réécrits depuis. Ce type de contrôle a une durée de vie : il est précieux le jour de la bascule, puis il ne mesure plus que l’écart avec un passé qu’on a choisi de quitter.
3. Le plan de redirections, et comment on vérifie qu’aucune URL n’est perdue
La règle de départ : toutes les URL publiques sont conservées à l’identique, même chemin, même barre oblique finale. On ne redirige que ce qui doit l’être. Le reste du travail consiste à trouver tout ce qui doit l’être.
D’abord, les 11 redirections existantes, avec leurs chaînes aplaties : si A pointe vers B qui pointe vers C, A pointe directement vers C.
for (const k of Object.keys(liveRedirects)) {
let t = liveRedirects[k];
const seen = new Set([k]);
while (liveRedirects[t] && !seen.has(t)) { seen.add(t); t = liveRedirects[t]; }
liveRedirects[k] = t;
}
Ensuite, les adresses techniques de WordPress que personne ne liste mais que Google connaît : /feed/, les pages d’archives paginées, les étiquettes, les pages auteur, et les anciens sitemaps de l’extension SEO déclarés dans la Search Console. Puis les ebooks PDF hébergés dans /wp-content/uploads/, dont les liens avaient circulé par e-mail. Puis les images : 295 anciennes adresses d’images redirigées une par une vers leur nouveau fichier, pour garder le référencement dans Google Images.
Tout est déclaré dans deux fichiers YAML, l’un écrit à la main, l’autre généré par la migration :
- from: /guide-offshore-it/
to: /guide-projet-informatique-offshore/
- from: /feed/
to: /feed.xml
- from: /blog/tag/*
to: /blog/
Une intégration Astro lit ces fichiers et, au build, écrit les mêmes 301 dans quatre formats : _redirects pour Netlify ou Cloudflare Pages, .htaccess pour Apache, un fichier à inclure pour nginx et un vercel.json. L’hébergeur n’était pas figé au moment du développement, et il ne fallait pas que ce choix décide du référencement. Le cœur est une conversion de motif en expression régulière :
// "/blog/tag/*" -> "^/blog/tag/.*$" ; "/page/" -> "^/page/?$"
const toRegex = (u) => '^' + (isPattern(u)
? reEsc(u).replace(/\*/g, '.*')
: reEsc(noSlash(u)) + (isPage(u) ? '/?' : '')) + '$';
Aujourd’hui, le build annonce « 345 redirections 301 écrites » : 50 écrites à la main, 295 générées pour les images.
Et la vérification ? Trois niveaux. Le relevé des 204 adresses est contrôlé contre le sitemap en ligne : les 161 adresses déclarées sont toutes couvertes. Un document de migration liste chaque ancienne adresse avec son sort, « conservée » ou « 301 vers… ». Enfin, un audit parcourt le site généré et refuse tout lien interne qui ne mène ni à une page existante ni à une redirection déclarée.
Le dépôt montre aussi ce que ces trois niveaux n’avaient pas vu. Le 12 octobre, deux commits ajoutent des 301 oubliés : les anciens flux RSS par catégorie et par article (/…/feed/), et les pages parentes que WordPress servait implicitement, comme /cas-client/ ou /blog/category/, qui n’avaient jamais été des pages à part entière. Aucun inventaire ne les listait. C’est exactement pour ça que la consigne de lancement dit de surveiller le rapport des pages en 404 de la Search Console pendant plusieurs semaines et d’ajouter une redirection pour chaque adresse signalée.
Un inventaire d’URL n’est jamais complet. Le plan de redirections doit être un fichier qu’on enrichit, pas un livrable qu’on signe.
4. Images et performances
Les images ont reçu un traitement en trois couches.
Un script d’optimisation passe sur tous les fichiers : largeur maximale de 1 600 px, JPEG en mozjpeg qualité 78, PNG en palette, et un fichier n’est remplacé que s’il gagne au moins 10 %. Le nom et le format ne changent pas, donc aucune URL ne bouge.
Ensuite, chaque <img> reçoit ses dimensions réelles, lues directement dans l’en-tête binaire du fichier (PNG, GIF, WebP, JPEG), sans bibliothèque. Le navigateur réserve la place avant le chargement : pas de décalage de mise en page.
Enfin, en production uniquement, les images passent par le CDN d’images de l’hébergeur : plusieurs largeurs, et le format (AVIF ou WebP) choisi selon le navigateur.
const CDN = process.env.NETLIFY === 'true';
const WIDTHS = [400, 640, 960, 1280, 1600, 2000];
export function imageAttrs(src, sizes = '100vw') {
const size = imageSize(src);
if (!CDN || !size.width) return size;
const url = (w) => `/.netlify/images?url=${encodeURIComponent(src)}&w=${w}&q=78`;
const widths = [...WIDTHS.filter((w) => w < size.width), size.width];
return { ...size, srcset: widths.map((w) => `${url(w)} ${w}w`).join(', '), sizes };
}
En local, on sert l’image d’origine, et personne n’a eu à réencoder les images existantes en plusieurs tailles.
Côté pages : polices en WOFF2 réduites aux caractères utilisés (environ 390 Ko au lieu de 2,4 Mo), dix feuilles de style regroupées au build en un seul fichier (une requête bloquante au lieu de dix), CSS et JS mis en cache un an parce que leur adresse change à chaque build, images sept jours. L’index de recherche du blog (environ 650 Ko) n’est chargé qu’à l’ouverture de la recherche. Un audit relu plus tard relève un HTML d’environ 100 Ko par page.
Le dépôt ne contient pas de mesure Core Web Vitals avant et après, donc pas de score à donner.
5. L’éditeur de bureau : garder la main sans CMS en ligne
C’est la partie du projet qui a le plus tâtonné. Quitter WordPress, c’est aussi retirer à l’équipe son outil d’édition. Il fallait un remplaçant que des non-développeurs acceptent d’utiliser.
Premier essai, le 1er octobre : une administration en ligne de type Git-based CMS, branchée sur le dépôt. Le premier enregistrement réel a été un échec instructif. Une modification de quelques mots sur une page d’offre a produit un commit de 235 lignes ajoutées et 76 supprimées. L’outil avait réécrit tout le fichier : guillemets changés, et surtout des champs vides ajoutés partout.
-title: "Software Design and Development: From Business Need to Production"
+title: 'Software Design and Development: From Business Need to Production'
+ image: ''
+ noindex: false
+ canonical: ''
+excerpt: ''
+date: ''
Un date: ’’ ne passe pas la validation du schéma : le build a cassé. Sept minutes plus tard, le commit était annulé. Le schéma a d’abord été rendu tolérant aux champs vides, avec une publication par lot en plus, puis, le lendemain, l’administration en ligne a été retirée pour de bon.
À la place, une application de bureau Electron, pour Windows et Mac. Elle clone le dépôt, installe les dépendances et lance l’aperçu Astro sur le poste. Rien à installer à côté : l’exécutable d’Electron sert de Node, et Git est remplacé par isomorphic-git. La connexion se fait avec un compte GitHub (device flow), qui doit avoir le droit d’écriture sur le dépôt.
Trois onglets :
- Page : le site s’affiche, chaque texte se modifie d’un clic, directement dans la page. L’application retrouve dans le fichier Markdown la valeur correspondant au texte affiché, et ne modifie que celle-là : le reste du fichier reste identique, octet pour octet. C’est la réponse directe à l’échec de la veille.
- Formulaire : tous les champs, français et anglais côte à côte (Decap CMS en mode local).
- Publier : la liste des pages modifiées, avec « Voir » et « Annuler » pour chacune, et un bouton qui met le lot en ligne.
Chaque enregistrement crée un commit local, ce qui donne un historique et des retours en arrière gratuits. À la publication, tous ces commits sont fusionnés en un seul, posé sur la version en ligne :
// Commit unique : arbre final, parent = version en ligne
const oid = await git.commit({
fs, dir: this.dir, message: summary, author: this.author(),
tree: commit.tree, parent: [remote], ref: LOCAL,
});
Avant l’envoi, l’application lance une validation des contenus. Une page en erreur bloque la publication : le site en ligne n’est jamais cassé par une saisie. Et pour qu’une saisie incomplète ne bloque pas tout, le schéma répare au lieu de refuser : une traduction sans titre devient un brouillon non publié, une question de FAQ sans réponse n’est pas affichée, et chaque correction est signalée dans l’aperçu par un encadré « À compléter ».
Neuf versions de l’application en deux jours, de 0.2.0 à 0.4.4. Depuis, le dépôt montre des commits « Mise à jour du site : 1 élément », publiés depuis l’application par un membre de l’équipe, sans intervention d’un développeur. C’était l’objectif.
6. Le contrôle automatique avant mise en ligne
Sans extension SEO pour surveiller les pages, il faut un contrôle qui tourne à chaque build. Sur les deux sites, c’est la même commande :
npm run verifier # build + audit
L’audit charge chaque page générée avec cheerio et vérifie :
- les liens internes, images et feuilles de style qui ne mènent nulle part (une redirection déclarée compte comme valide) ;
- les liens du contenu qui partent vers l’autre langue par erreur ;
- les pages indexables sans version dans l’autre langue (absence du
hreflangcorrespondant) ; - les titres SEO de moins de 25 ou plus de 65 caractères, les descriptions trop courtes ou trop longues, les pages sans H1 unique, les images sans
alt, les titres et descriptions dupliqués ; - sur le site bilingue, en plus, chaque bloc JSON-LD doit être du JSON valide, et chaque page indexable doit avoir au moins deux intertitres H2.
Un lien cassé ou un JSON-LD invalide fait échouer la commande, le reste remonte en avertissement. Le contrôle JSON-LD est modeste : il vérifie que le bloc est lisible, pas qu’il respecte le schéma de Google.
Le site de la société de services ajoute une comparaison des deux langues : chaque page anglaise doit avoir le même gabarit, les mêmes sections et les mêmes blocs que sa version française. Résultats du jour, relancés pour cet article :
| Contrôle | Services (FR / EN) | Bilingue (FR / MG) |
|---|---|---|
| Pages générées | 428, dont 416 indexables | 68, dont 66 indexables |
| Liens cassés | 0 | 0 |
| Pages sans version dans l’autre langue | 0 | 0 |
| JSON-LD invalides | non contrôlé | 0 |
| Avertissements SEO | 2 titres trop longs | 0 |
| Redirections 301 | 345 | 14 |
Les commits du site bilingue tiennent tous dans la journée du 12 octobre : il reprend tout ce socle : contenus typés, gabarits, redirections multi-hébergeurs, audit. Côté redirections, il est plus simple : toutes ses URL publiques sont conservées, on ne redirige que les adresses techniques de WordPress (anciens sitemaps, /wp-admin/, /wp-login.php, l’ancien type de contenu des offres d’emploi) et quatre images déjà indexées. Il ajoute des données structurées JobPosting pour les offres d’emploi.
7. llms.txt
Les deux sites publient un /llms.txt, le format proposé pour présenter un site aux assistants IA. Il n’est pas écrit à la main : il est généré au build à partir des contenus, comme le sitemap. Une présentation de la société, les informations clés, puis les pages rangées par famille, chacune avec son lien et sa description SEO tronquée à 180 caractères :
const line = (p) => {
const c = cardOf(p);
const desc = truncate(plain(p.data.seo.description ?? c.text ?? ''), 180);
return `- [${plain(c.title)}](${origin}${p.url})${desc ? `: ${desc}` : ''}`;
};
Les pages en noindex et les landings de campagne en sont exclues, les 25 articles les plus récents y figurent, et chaque langue a son fichier (environ 36 Ko chacun). Le robots.txt, lui aussi généré, ouvre explicitement le site aux robots des assistants IA, sauf les routes serveur. Après chaque mise en ligne, une extension de build signale à IndexNow les seules pages dont le HTML a changé.
Aucune donnée du projet ne prouve que llms.txt change quoi que ce soit au trafic. Mais le coût est nul une fois le générateur écrit, et le fichier reste à jour sans que personne y pense. Sur WordPress, il aurait fallu une extension de plus, ou un fichier statique condamné à vieillir.
8. Ce qu’on perd par rapport à WordPress
Ce serait malhonnête de présenter la migration comme un gain sur toute la ligne.
- L’édition depuis n’importe quel navigateur. Il faut installer l’application sur un poste, avoir un compte GitHub avec les droits d’écriture, et attendre quelques minutes à la première ouverture. Les installateurs ne sont pas signés : Windows affiche son avertissement de protection, et sur Mac il faut passer par clic droit, « Ouvrir ».
- L’immédiateté. Une publication passe par un commit puis une reconstruction du site : deux à trois minutes avant d’être en ligne.
- Les extensions. Formulaire de contact, assistant, envoi des ebooks : tout ce qu’une extension faisait en trois clics est devenu du code, une fonction serveur et des variables d’environnement à maintenir. Les formulaires du site bilingue reposent sur la détection de formulaires de l’hébergeur : changer d’hébergeur, c’est les réécrire.
- L’autonomie sur la structure. L’équipe modifie les textes, les images, le SEO, ajoute des pages dans des gabarits existants. Un nouveau type de bloc, en revanche, demande un développeur : le déclarer dans le schéma, créer son composant, l’enregistrer, régénérer la configuration de l’éditeur.
En échange : plus de mises à jour d’extensions, un historique de chaque mot modifié, et un site statique sans base de données.
Les leçons
- Relevez le site en ligne avant de toucher à quoi que ce soit. Statut, titre, description, robots, dates, page par page. Ce fichier devient la référence de toute la migration.
- Le sitemap ne dit pas tout. 161 adresses déclarées, 204 réelles, et des pages parentes, des flux RSS et des PDF que personne n’avait listés.
- Les redirections sont des données, pas de la configuration serveur. Un fichier YAML, quatre formats générés : le choix de l’hébergeur ne touche plus au référencement.
- Un contrôle de fidélité sert le jour J. Ensuite, il mesure l’écart avec un passé qu’on a choisi de quitter.
- L’éditeur ne doit jamais réécrire un fichier entier. Modifier la seule valeur touchée, c’est ce qui rend les commits lisibles et les retours en arrière sûrs.
- Le contrôle avant mise en ligne remplace l’extension SEO. Mais seulement s’il tourne à chaque build et qu’il fait échouer la publication sur l’essentiel.