Migrer un blog WordPress vers Strapi 5 ne se résume pas à exporter le XML et l’importer dans le nouveau CMS — Strapi ne propose pas d’importateur WordPress natif. Il faut écrire un petit script Node.js qui parcourt l’API REST de WordPress, transforme chaque article au format attendu par Strapi, et envoie via l’API REST de Strapi. Ce travail prend en général deux à quatre jours pour un blog de 500 à 2000 articles, en incluant les phases de test et de relecture.
Ce tutoriel parcourt les étapes concrètes : préparer la cible Strapi, écrire le script de migration, gérer les médias, préserver les URLs et les redirections, valider la migration. À la fin, vous aurez un blog Strapi peuplé avec vos articles WordPress, vos images en place, et un fichier de redirections 301 prêt à déployer côté reverse-proxy. L’exemple cible un Strapi déjà déployé en production (cf. tutoriel « déployer Strapi sur Hetzner »), mais s’adapte sans difficulté à un Strapi local.
Prérequis
- Un site WordPress source avec accès admin et API REST publique activée (cas par défaut depuis WP 5.0)
- Un Strapi 5 cible avec un compte administrateur
- Node.js 20 LTS ou plus récent installé localement
- Un éditeur de code et un terminal
- Une copie de sauvegarde fraîche du WordPress avant de commencer (au cas où)
L’API REST WordPress est accessible sous https://votreblog.com/wp-json/wp/v2/. Vérifiez qu’elle répond avec curl https://votreblog.com/wp-json/wp/v2/posts?per_page=1 : vous devez obtenir un tableau JSON avec un article. Si vous obtenez une erreur 401 ou 404, un plugin de sécurité (souvent iThemes Security ou Wordfence) bloque l’API ; il faudra l’autoriser temporairement le temps de la migration, ou utiliser un Application Password pour l’authentification.
Étape 1 — Cartographier le contenu WordPress
Avant d’écrire la moindre ligne de code, il faut comprendre ce qu’on doit migrer. WordPress stocke ses contenus dans plusieurs endpoints REST distincts qu’il faut tous explorer : /wp-json/wp/v2/posts pour les articles, /categories pour les catégories, /tags pour les étiquettes, /users pour les auteurs, /media pour les images de la médiathèque, et éventuellement /pages pour les pages statiques.
Comptez d’abord les volumes pour calibrer le script. La requête curl -I https://votreblog.com/wp-json/wp/v2/posts?per_page=1 renvoie un en-tête X-WP-Total qui contient le nombre total d’articles. Faites de même pour les catégories, les étiquettes et les médias. Sur un blog typique de cinq ans, on a en général 500 à 1500 articles, 30 à 80 catégories, 200 à 400 étiquettes, et 1000 à 5000 médias.
Posez-vous les bonnes questions pendant cette phase. Quels champs personnalisés ACF (Advanced Custom Fields) faut-il préserver ? Y a-t-il des shortcodes WordPress utilisés massivement qu’il faudra convertir en blocs Strapi ou en HTML statique ? Les images sont-elles servies par WordPress lui-même ou par un CDN tiers (Cloudinary, Bunny) ? Les réponses conditionnent la complexité du script. Pour un blog standard sans ACF ni shortcodes exotiques, comptez deux jours ; avec des champs custom complexes, prévoyez le double.
Étape 2 — Modéliser les Content-Types côté Strapi
Connectez-vous au panneau d’administration de votre Strapi cible et ouvrez le Content-Type Builder. On va créer trois Collection Types qui répliquent la structure WordPress : Article, Catégorie et Étiquette. La collection Auteur peut être confondue avec la collection Users-Permissions par défaut de Strapi.
Pour la collection Article, créez les champs suivants : titre (Text, court, requis), slug (UID basé sur titre, requis et unique), extrait (Text long), corps (Rich Text — Blocks ou Markdown selon préférence), publishedAt (Date), idWordpress (Number, integer) qui servira de clé de correspondance pendant la migration, auteur (Relation many-to-one vers Users), imagePrincipale (Media — Single Image), categories (Relation many-to-many vers Catégorie), etiquettes (Relation many-to-many vers Étiquette).
Pour la collection Catégorie, prévoyez nom, slug, description, et idWordpress. Idem pour Étiquette. Sauvegardez les schémas : Strapi applique les migrations sur PostgreSQL et crée les tables. Le champ idWordpress est central : il permet au script de retrouver les correspondances pour rattacher un article aux bonnes catégories sans dupliquer.
Une fois les schémas créés, allez dans « Settings → Roles → Authenticated » et donnez les permissions « create », « find », « update » sur les trois collections. Créez ensuite un API Token dans « Settings → API Tokens → Create new API Token » avec le type « Full access » et une durée illimitée. Copiez ce token immédiatement (il n’est plus affiché ensuite) — il servira au script.
Étape 3 — Créer le projet de migration
On va écrire le script dans un dossier dédié, hors du dépôt Strapi pour ne pas polluer le code applicatif. Créez le dossier, initialisez un projet Node.js avec ESM activé, et installez les dépendances minimales.
mkdir migration-wp-strapi
cd migration-wp-strapi
npm init -y
npm pkg set type=module
npm install undici turndown
Trois choix techniques. Premièrement, ESM (type: module) plutôt que CommonJS, car la syntaxe import est plus lisible et c’est le standard moderne. Deuxièmement, undici comme client HTTP : c’est le moteur sous-jacent de fetch dans Node.js, plus performant qu’axios pour de gros volumes de requêtes. Troisièmement, turndown pour convertir le HTML WordPress en Markdown propre que Strapi avalera plus facilement que du HTML brut. Si vous préférez garder du HTML, retirez turndown.
Créez un fichier .env à la racine pour les paramètres :
# .env
WP_URL=https://votreblog.com
STRAPI_URL=https://cms.votredomaine.com
STRAPI_TOKEN=votre-api-token-strapi-copie-a-l-etape-2
Le script lira ces variables au démarrage. Charger les secrets depuis .env évite de les commiter par accident dans Git, ce qui est particulièrement important pour le token Strapi qui donne un accès complet à votre CMS.
Étape 4 — Écrire le script de migration des catégories
On commence par les catégories parce qu’elles sont les plus simples (peu de champs, pas de relations entrantes), et parce que les articles dépendent d’elles. Créez un fichier migrate-categories.js :
import 'node:process'
import { readFileSync } from 'node:fs'
const env = Object.fromEntries(
readFileSync('.env', 'utf8')
.split('\n').filter(l => l && !l.startsWith('#'))
.map(l => l.split('='))
)
async function fetchAllCategories() {
let page = 1, all = []
while (true) {
const res = await fetch(`${env.WP_URL}/wp-json/wp/v2/categories?per_page=100&page=${page}`)
if (!res.ok) break
const data = await res.json()
if (data.length === 0) break
all.push(...data)
page++
}
return all
}
async function createInStrapi(category) {
const res = await fetch(`${env.STRAPI_URL}/api/categories`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${env.STRAPI_TOKEN}`,
},
body: JSON.stringify({
data: {
nom: category.name,
slug: category.slug,
description: category.description,
idWordpress: category.id,
},
}),
})
if (!res.ok) throw new Error(`Strapi: ${res.status} ${await res.text()}`)
return res.json()
}
const categories = await fetchAllCategories()
console.log(`${categories.length} catégories trouvées sur WordPress`)
for (const cat of categories) {
try {
await createInStrapi(cat)
console.log(`✓ ${cat.name}`)
} catch (e) {
console.error(`✗ ${cat.name}: ${e.message}`)
}
}
Le script suit une logique simple en deux temps : récupérer toutes les catégories WordPress en paginant l’API REST (100 par page), puis pour chaque catégorie, créer l’équivalent dans Strapi via POST. La gestion d’erreurs est minimale mais fonctionnelle : une création qui échoue est loggée avec un croix, sans interrompre la suite. Lancez avec node migrate-categories.js. La sortie affiche une ligne par catégorie ; à la fin, ouvrez le panneau Strapi sous « Catégorie » pour vérifier visuellement que tout est arrivé.
Étape 5 — Migrer les médias
Les images sont le sujet le plus délicat. Une image WordPress vit sous /wp-content/uploads/AAAA/MM/nom-fichier.jpg et a un identifiant numérique qu’on retrouve dans l’API. Strapi attend qu’on uploade le fichier brut via son endpoint multipart, qui retourne un objet média avec son propre identifiant. Le script de migration des médias doit donc télécharger chaque fichier WordPress puis le réuploader vers Strapi, en gardant la correspondance des IDs.
// migrate-media.js (extrait — version complète sur le dépôt associé)
import { writeFileSync, readFileSync, existsSync } from 'node:fs'
const mapping = existsSync('media-map.json')
? JSON.parse(readFileSync('media-map.json', 'utf8'))
: {}
async function migrateMedia(media) {
if (mapping[media.id]) return mapping[media.id]
const fileRes = await fetch(media.source_url)
const blob = await fileRes.blob()
const form = new FormData()
form.append('files', blob, media.slug + '.' + media.mime_type.split('/')[1])
const upload = await fetch(`${env.STRAPI_URL}/api/upload`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${env.STRAPI_TOKEN}` },
body: form,
})
const result = await upload.json()
mapping[media.id] = result[0].id
writeFileSync('media-map.json', JSON.stringify(mapping, null, 2))
return result[0].id
}
Trois éléments méritent l’attention. La correspondance idWordPress → idStrapi est sauvegardée dans media-map.json à chaque succès, ce qui permet de relancer le script sans re-uploader ce qui est déjà migré (idempotence). La fonction utilise FormData et blob(), supportés nativement par Node.js 20+. Le format de fichier est reconstruit à partir du mime_type WordPress, ce qui couvre 95 % des cas mais peut nécessiter un ajustement pour les SVG ou les WebP. Comptez vingt à soixante minutes d’exécution pour 1000 à 5000 médias selon la bande passante.
Étape 6 — Migrer les articles
L’étape culminante. Pour chaque article WordPress, on doit récupérer le contenu HTML du content.rendered, le convertir en Markdown propre via Turndown, retrouver l’ID Strapi de l’image principale via la table de correspondance, retrouver les IDs Strapi des catégories et étiquettes, puis créer l’article via POST.
// migrate-posts.js (extrait clé)
import TurndownService from 'turndown'
const turndown = new TurndownService({ headingStyle: 'atx' })
async function migratePost(post, mediaMap, catMap, tagMap) {
const corps = turndown.turndown(post.content.rendered)
const imagePrincipaleId = post.featured_media
? mediaMap[post.featured_media]
: null
const body = {
data: {
titre: post.title.rendered,
slug: post.slug,
extrait: post.excerpt.rendered.replace(/<[^>]*>/g, '').trim().slice(0, 280),
corps,
publishedAt: post.date,
idWordpress: post.id,
imagePrincipale: imagePrincipaleId,
categories: post.categories.map(id => catMap[id]).filter(Boolean),
etiquettes: post.tags.map(id => tagMap[id]).filter(Boolean),
},
}
const res = await fetch(`${env.STRAPI_URL}/api/articles`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${env.STRAPI_TOKEN}`,
},
body: JSON.stringify(body),
})
if (!res.ok) throw new Error(`${post.slug}: ${res.status} ${await res.text()}`)
return res.json()
}
Plusieurs détails sont importants. L’extrait WordPress contient parfois du HTML résiduel — le replace(/<[^>]*>/g, '') nettoie ces balises. La conversion Turndown gère bien les paragraphes, titres, listes, citations et liens, mais peut buter sur des shortcodes WordPress restants ; faites un test sur cinq articles d’abord avant de lancer le batch complet. Les relations many-to-many (catégories, étiquettes) attendent un tableau d’IDs Strapi : on les récupère via les tables de correspondance construites aux étapes 4 et 5. Le filter(Boolean) protège contre les ID manquants si un article a une catégorie qu’on n’aurait pas migrée.
Lancez d’abord sur cinq articles seulement (limite per_page=5 dans la requête WordPress) pour valider que la conversion donne un résultat propre dans le panneau Strapi. Une fois satisfait, retirez la limite et laissez tourner. Pour 1000 articles, comptez quinze à trente minutes. La sortie console doit afficher une ligne ✓ par article migré.
Étape 7 — Préserver le SEO
Une migration qui change les URLs publiques sans précautions perd 30 à 60 % du trafic organique pendant les trois mois suivants — le temps que Google recrawle. Trois mesures évitent ce désastre. La première : conserver les slugs WordPress tels quels dans Strapi (c’est ce que fait le script ci-dessus). La deuxième : générer un fichier de redirections 301 pour les patterns d’URL qui changent. La troisième : maintenir le sitemap.xml accessible pendant la période de transition.
Le pattern le plus courant à rediriger est le préfixe de catégorie. WordPress sert /category/dev-web/article-slug/, le nouveau front-end Strapi servira souvent /blog/article-slug ou /articles/article-slug. Côté Caddy (cf. tutoriel « déployer Strapi sur Hetzner »), on ajoute un bloc :
www.votreblog.com {
redir https://votreblog.com{uri} permanent
}
votreblog.com {
redir /category/* /blog/{path.1} permanent
redir /tag/* /etiquettes/{path.1} permanent
redir /author/* /blog permanent
reverse_proxy frontend:3000
}
Caddy applique ces redirections en HTTP 301, ce qui transmet l’autorité SEO de l’ancienne URL vers la nouvelle. Pour des cas d’URL plus exotiques, utilisez un mapping explicite généré depuis votre script : lors de la migration, écrivez chaque correspondance ancienneURL → nouvelleURL dans un fichier redirects.csv, puis transformez-le en directives Caddy ou en règles Nginx selon votre proxy. Vérifiez le résultat avec curl -I https://votreblog.com/category/dev-web/article-slug/ qui doit renvoyer HTTP/2 301 et un en-tête location: correct.
Étape 8 — Validation et bascule DNS
Avant de basculer le DNS du domaine principal, faites tourner le nouveau site sur un sous-domaine de test (par exemple preprod.votreblog.com) et passez une journée à le parcourir. Vérifiez les cinq points suivants en priorité : les pages d’accueil et de catégorie chargent vite (Lighthouse > 85), les images s’affichent correctement, la pagination fonctionne, le moteur de recherche renvoie des résultats pertinents, les liens internes au sein des articles ne renvoient pas vers l’ancien WordPress.
Une fois la pré-prod validée, baissez le TTL DNS de l’enregistrement A à 300 secondes 24 heures avant la bascule pour limiter le délai de propagation. Le jour J, mettez WordPress en mode lecture seule (plugin « WP Maintenance Mode » ou chmod -R a-w wp-content), faites un dernier passage du script de migration pour rattraper les éventuels articles publiés depuis la veille, puis modifiez l’enregistrement A pour pointer vers votre nouveau serveur. La propagation prend de cinq à trente minutes ; surveillez Google Search Console les jours suivants pour repérer toute hausse anormale d’erreurs 404.
Gardez l’ancien WordPress allumé pendant trois mois sur un sous-domaine privé : si une fonctionnalité oubliée se révèle plus tard nécessaire, vous pourrez y accéder. Au-delà de trois mois, exportez une dernière copie complète et éteignez le serveur.
Erreurs fréquentes et solutions
| Erreur | Cause | Solution |
|---|---|---|
| API REST WordPress renvoie 401 | Plugin de sécurité bloque l’accès | Whitelister votre IP ou utiliser un Application Password |
| Strapi renvoie 400 sur création d’article | Slug en doublon ou champ requis manquant | Logger le body envoyé ; vérifier les contraintes du schéma |
| Conversion Markdown casse les blocs Gutenberg | Shortcodes propriétaires non reconnus | Pré-traiter le HTML avec une regex avant Turndown |
| Médias en double dans Strapi | Script relancé sans media-map.json | Restaurer le fichier de mapping ; supprimer doublons via API |
| Trafic organique chute après migration | Redirections 301 incomplètes | Auditer les 50 URLs les plus visitées dans Search Console |
Optimisations possibles
Le script présenté est volontairement simple pour rester lisible. Trois améliorations méritent d’être considérées sur des projets plus gros. La première : paralléliser les requêtes HTTP avec Promise.all ou un pool de promesses (par exemple p-limit) pour passer de quinze minutes à trois minutes sur 1000 articles. Attention à respecter les limites de débit de WordPress et de Strapi : une concurrence de 5 à 10 reste sûre, au-delà on risque le rate limiting.
La deuxième : capturer les erreurs dans un fichier errors.log structuré (JSON Lines) plutôt que sur la sortie standard, pour pouvoir analyser après coup les articles qui ont échoué et les rejouer sélectivement. La troisième : ajouter une phase de validation post-migration qui compare les compteurs WordPress et Strapi pour s’assurer qu’aucun contenu n’a été silencieusement perdu : X-WP-Total côté source doit correspondre au nombre d’articles dans Strapi.
Tutoriels associés
- Article principal : Strapi, Directus, Payload — choisir et déployer en 2026
- Installer Payload CMS 3 dans Next.js
- Déployer Strapi 5 sur Hetzner avec PostgreSQL
Ressources officielles
- API REST WordPress : developer.wordpress.org/rest-api
- Application Passwords WordPress : make.wordpress.org/core/2020/11/05/application-passwords-integration-guide
- API REST Strapi 5 : docs.strapi.io/cms/api/rest
- Endpoint upload Strapi : docs.strapi.io/cms/plugins/upload
- Turndown (HTML vers Markdown) : github.com/mixmark-io/turndown
- Undici (client HTTP Node.js) : github.com/nodejs/undici
- Caddy redirections : caddyserver.com/docs/caddyfile/directives/redir