Migrer un site de Webflow à Astro sans perdre son référencement

Faut-il partir, quelle architecture choisir, comment migrer en 14 étapes sans perdre une seule adresse : le guide complet, avec le code testé et une checklist de 78 points.

Auteur
Térence Mbu
Publié le
Lecture
81 min
Format
Guide
Sommaire

Le guide complet, étape par étape. Édition du 21 octobre 2026 (Astro 7.4).

Par Térence Mbu, développeur web. Je construis des sites dans les deux mondes : sur Webflow, et en custom code. Ce guide ne cherche pas à vous faire partir. Il cherche à ce que, si vous partez, vous ne perdiez rien.

Avant de commencer

Pour qui est ce guide

Il se lit à deux niveaux.

  • Les parties marquées « Décider » s'adressent à la personne qui possède le site : fondateur, responsable marketing, chef de projet. Aucune ligne de code.
  • Les parties marquées « Faire » s'adressent au développeur qui fera la migration. Les commandes et les fichiers sont donnés en entier, et chaque étape dit ce qui a été testé.

La règle d'or, en trois phrases

  1. Chaque adresse du site garde exactement son chemin. La perte la plus fréquente vient d'une adresse qui existait et n'existe plus. Les autres viennent de ce qui change sous une adresse identique : un noindex oublié, une canonical fausse, un titre, un texte ou des liens internes perdus. C'est le rôle des étapes 9 et 12.
  2. On reproduit d'abord, on améliore ensuite. Changer de plateforme et refaire le design le même jour empêche de comprendre ce qui a fait bouger le trafic.
  3. On ne résilie rien chez Webflow avant 30 jours de surveillance. Tant que l'ancien site existe, revenir en arrière prend quelques minutes : on repointe le domaine vers lui.

Ce que ce guide a vérifié, et ce qu'il n'a pas vérifié

  • Les versions, les prix et les règles des outils ont été lus les 20 et 21 octobre 2026, sur les pages officielles d'Astro, de Webflow, de Google, de Cloudflare, de Netlify et de Vercel. Ils changent : relisez-les avant de décider.
  • Le code a été testé le 20 octobre sur Astro 7.4.1 et Node 24, d'abord contre une copie simulée de l'API Webflow (101 articles, pagination, limite de requêtes, image refusée).
  • Puis sur un vrai site, le 21 octobre : j'ai construit un site de démonstration sur Webflow, « Atelier Nord » (un studio d'architecture fictif), et je l'ai migré vers Astro en suivant ce guide, jusqu'à la mise en ligne sur Cloudflare, d'abord dans sa variante minimale (100 % statique, contenu en Markdown). Sa page de contact est ensuite passée à la stack recommandée (l'adaptateur et le formulaire Astro Action + Resend), testée sur le serveur local de Cloudflare puis en ligne, sauf l'envoi réel d'un e-mail. Sanity n'a pas été testé dans ce guide : l'étape 6 dit ce qui vient de ma stack et ce qui reste à éprouver. Les captures d'écran et les chiffres viennent de ce site. Le récit est en partie 5.
  • Ce test ne remplace pas un essai sur votre site : chaque site Webflow a ses particularités, et l'étape 12 sert à les trouver.

Partie 1 · Décider

1.1 Faut-il partir ?

La moitié des migrations ratées le sont avant la première ligne de code : elles n'auraient pas dû avoir lieu. La bonne question n'est pas « Webflow ou Astro ». C'est : qui modifie le site, quoi, à quelle fréquence, et qui s'en occupera dans deux ans ?

La grille que j'applique pour y répondre, situation par situation, est dans un article à part : Webflow ou custom code, la grille.

Restez sur Webflow si :

  • votre équipe marketing recompose elle-même ses pages chaque semaine, s'y retrouve très bien, et rien ne bloque ce que le site doit faire ;
  • votre site est une boutique Webflow : sortir un e-commerce est un projet à part entière. Les produits et l'historique des commandes s'exportent (CSV, API), mais le panier, le paiement, les taxes et les e-mails de commande se reconstruisent sur un autre outil ;
  • votre site est simple, il fonctionne, et une ou deux personnes le mettent à jour ;
  • une refonte, un changement de marque ou une grosse campagne est en cours : attendez qu'elle soit finie ;
  • personne ne s'occupera de la maintenance. Un commentaire d'un fil Reddit sur la hausse des prix le résume : « Passer au code, c'est souvent s'embaucher soi-même comme développeur permanent. » C'est un excellent plan si c'en est un, un piège sinon.

Partez vers Astro si :

  • quelqu'un de technique fait partie de l'équipe, ou un prestataire assure la maintenance. Finn Dollimore, développeur Webflow qui a fait ce choix en 2026, le dit simplement : migrer a du sens quand l'équipe compte des gens techniques ;
  • votre équipe produit code déjà en JavaScript : sur Webflow, seul le développeur Webflow peut toucher au site ; en custom code, toute l'équipe peut y contribuer, et vous recrutez dans un vivier bien plus large ;
  • vous avez besoin de fonctionnalités que Webflow ne porte pas, ou réserve à ses offres Team et Enterprise ;
  • vous construisez avec des agents IA (Claude Code, Cursor) et Webflow vous ralentit : un composant fait à la main, élément par élément, contre quelques secondes en custom code ;
  • le nombre de personnes qui modifient le site augmente, et la facture des sièges Webflow avec lui ;
  • vous voulez maîtriser chaque détail technique du référencement, de la performance ou du multilingue.

Ce qui ne suffit pas comme raison :

  • « C'est moins cher. » ZenML a migré 2 224 pages et économise environ 800 $ par an. Son auteur l'écrit lui-même : « les économies seules n'auraient pas justifié le déménagement ». Les vraies raisons sont ailleurs.
  • « Tout le monde part. » Sur Reddit, une agence qui vend à la fois Webflow et Astro rapportait en septembre 2026 que la demande de migration vers Webflow avait fortement augmenté. Le flux va dans les deux sens.
  • « Webflow est plus simple pour une équipe non technique. » Ce n'est pas un critère en soi : un outil d'édition bien réglé (partie 3, étape 11) se prend en main aussi vite. Le vrai critère est ce que le site doit faire.

Le piège à connaître avant de décider.

Une agence spécialisée l'écrit dans son propre guide de vente : une équipe de designers sans développeur se retrouve, après la migration, plus enfermée qu'avant. Un code que personne ne sait modifier est un piège plus dur qu'un compte où l'on peut se connecter.

1.2 Ce que ça coûte de rester, ce que ça coûte de partir

(Prix relevés le 20/10/2026 sur les pages officielles, en dollars, facturation annuelle sauf mention.)

Rester sur Webflow :

PostePrix
Plan de site Premium (avec CMS, 20 000 éléments)25 $/mois (39 $ en mensuel)
Plan de site Basic (sans CMS)15 $/mois
Siège « Full » par personne qui construit39 $/mois (45 $ en mensuel)
Siège « Limited » par personne qui modifie le contenu15 $/mois (19 $ en mensuel)
Plan de Workspace nécessaire pour exporter le codeCore (19 $/mois) ou Freelancer (16 $/mois) ; un plan de site seul ne l'inclut pas

⚠️ Une date à noter : depuis le 13 mai 2026, Webflow a fusionné ses plans CMS et Business en un plan Premium. Les sites existants basculent à leur premier renouvellement après le 29 juin 2026. Les sites hébergés dans un Workspace Freelancer ou Agency basculent à leur premier renouvellement après le 16 novembre 2026. Si vous êtes dans ce cas, c'est le bon moment pour refaire vos calculs, dans un sens comme dans l'autre.

Pour refaire ce calcul avec les réponses de votre site, avant et après le 16 novembre, j'ai fait un outil gratuit : Combien vous coûte votre site Webflow chaque mois ?

Calez la bascule sur vos dates de renouvellement. Webflow ne rembourse pas : une résiliation laisse courir la période déjà payée, et le plan de site et le plan de Workspace se résilient séparément. Notez les deux dates avant de fixer le jour J : un plan annuel qui se renouvelle pendant les 30 jours de surveillance coûte une année de plus. Si vous pensez partir dans l'année, ne passez pas en facturation annuelle pour figer l'ancien prix. Côté « rester », le plan Premium inclut 50 Go de bande passante par mois (100 Go sur l'ancien plan Business) ; au-delà, comptez 20 $/mois par tranche de 50 Go en annuel (30 $ en mensuel).

Partir vers Astro :

PostePrix
Hébergement Cloudflare0 $ : les fichiers statiques sont gratuits et illimités ; les pages à la demande (ici /contact) entrent dans les 100 000 requêtes par jour du plan gratuit. Offre payante à partir de 5 $/mois
Envoi des e-mails du formulaire (Resend)Gratuit jusqu'à 3 000 e-mails par mois (100 par jour), largement assez pour un formulaire de contact
Hébergement NetlifyGratuit avec 300 crédits par mois, sans recharge : crédits épuisés, tous les sites du compte sont mis en pause. Plans payants à 9 $ ou 20 $/mois (ce que valent les crédits : partie 2.2)
Hébergement VercelPlan Pro : 20 $/mois par siège développeur (le plan gratuit exclut tout usage commercial : partie 2.2)
Outil d'édition (CMS)De 0 $ à plusieurs centaines par mois : voir la partie 2
La maintenanceLe poste que les posts « quittez Webflow » oublient (voir ci-dessous)

Le coût que personne ne chiffre : la maintenance. Sur Webflow, l'hébergement, la sécurité et les mises à jour sont compris. Sur Astro, quelqu'un doit mettre à jour le framework et ses dépendances. Pour vous donner une idée du rythme : Astro 5 est sorti le 3 décembre 2024, Astro 6 le 10 mars 2026, Astro 7 le 22 juin 2026. Deux versions majeures en 2026, et seule la version majeure précédente reçoit encore des correctifs, de sécurité uniquement. La question à poser avant de partir : qui s'en occupe, et combien de temps par trimestre ?

1.3 Pourquoi Astro, et quand ce n'est pas Astro

Astro est un framework pensé pour les sites de contenu : sites vitrines, blogs, documentations, sites marketing. C'est l'une des destinations les plus citées par les équipes qui ont raconté leur sortie de Webflow en 2026 : la plupart gardent leur contenu en Markdown, d'autres passent par un CMS comme Sanity.

  • Il produit du HTML statique par défaut, sans JavaScript inutile. Les pages sont rapides et lisibles par les moteurs de recherche et les IA sans effort particulier.
  • Ses « content collections » ressemblent aux collections du CMS Webflow : un type de contenu, ses champs, ses éléments.
  • Il accepte n'importe quel outil : HTML et CSS simples, ou des composants React, Vue ou Svelte là où il en faut.
  • Il est solide : l'équipe d'Astro a rejoint Cloudflare le 16 janvier 2026. Le projet reste open source (licence MIT) et se déploie toujours chez Netlify, Vercel ou sur un serveur classique. Webflow lui-même a donné 150 000 $ au projet en septembre 2025, et son service d'hébergement d'applications (Webflow Cloud) fait tourner des applications Astro.

Quand ce n'est pas Astro : si votre site est surtout une application (espace client connecté, tableau de bord, logique métier lourde), un framework d'application comme Next.js sera plus naturel. Astro sait faire du rendu serveur, mais ce n'est pas là qu'il brille.

1.4 Les alternatives à une sortie complète

Partir n'est pas binaire. Trois chemins intermédiaires existent.

  1. La migration en deux temps. On déplace d'abord le site (le design et les pages) vers Astro, en gardant le CMS Webflow comme source de contenu, lu par son API. L'équipe continue d'écrire dans Webflow. On change d'outil d'édition plus tard, une fois le nouveau site stable. C'est la recommandation de Google appliquée à la lettre : « Planifiez vos changements l'un après l'autre, pas tous en même temps. » Détail à l'étape 6.
  2. Webflow Cloud. Webflow héberge des applications Astro ou Next.js à côté d'un site Webflow (sous une adresse comme /app). Utile pour ajouter une partie applicative sans quitter Webflow. Moins adapté à un site marketing entier : par défaut, chaque page y est rendue à la demande, et l'en-tête de cache est forcé à private, no-cache.
  3. Attendre Webflow Source. Présenté à la Webflow Conf (1er au 3 septembre 2026), encore en avant-première limitée, il doit faire du code la source de vérité dans Webflow. Rien n'est disponible publiquement, et la question « que peut-on exporter et héberger ailleurs ? » reste ouverte. Un analyste du secteur le résume : si votre site bloque votre marketing aujourd'hui, attendre une architecture future sans date n'est pas une stratégie.

Vous hésitez encore entre rester et partir ?

Prenez 30 minutes avec moi, je regarde votre cas : réserver un appel.

Partie 2 · Choisir l'architectureDéciderFaire

Deux décisions structurent tout le reste : où vit le contenu, et où vit le site.

2.1 Où vivra le contenu ?

OptionComment on modifie le sitePour quiCoût (20/10/2026)
A. Fichiers Markdown dans le dépôtUn développeur, ou un agent IA, modifie les fichiers ; chaque changement passe par une relecture avant publicationÉquipes sans éditeur non technique. C'est le choix de ZenML, VGV, StackOne0 $
B. CMS basé sur Git (Keystatic, Decap, TinaCMS, CloudCannon)Une interface d'édition, mais le contenu reste des fichiers dans le dépôtUne ou deux personnes qui publient des articlesKeystatic et Decap : gratuits. TinaCMS : gratuit pour 2 utilisateurs, puis 24 $/projet/mois. CloudCannon : à partir de 49 $/mois en annuel (55 $ en mensuel)
C. CMS « headless » (Sanity, Storyblok, Contentful)Une vraie application d'édition, avec aperçu, rôles et composition de pagesUne équipe marketing qui publie et compose des pagesSanity : gratuit jusqu'à 20 utilisateurs et 10 000 documents, puis 15 $/utilisateur/mois. Storyblok : gratuit pour 1 utilisateur, puis 99 $/mois (90,75 $ en annuel). Contentful : gratuit jusqu'à 10 000 entrées, puis 300 $/mois
D. Garder le CMS Webflow, lu par l'APIL'équipe continue dans Webflow ; le site Astro se reconstruit à chaque publicationLa migration en deux temps (1.4)Le plan de site Webflow continue (25 $/mois en Premium)

Comment trancher :

  • Personne ne publie sans développeur → A.
  • Quelques personnes publient des articles, rarement des pages → B, ou Sanity si le contenu est riche.
  • Une équipe marketing qui compose ses pages seule → C, avec des blocs prédéfinis (étape 11).
  • Vous voulez le risque le plus bas possible le jour de la bascule → D, puis A, B ou C plus tard.

⚠️ Sanity et son édition visuelle (cliquer sur un texte de la page pour le modifier) : avec Astro, elle ne fonctionne que sur des pages rendues à la demande. Il faut donc un adaptateur et une route d'aperçu, même si le site public reste statique.

2.2 Où sera hébergé le site ?

HébergeurPour un site Astro statiqueÀ savoir
Cloudflare (Workers)✅ Le choix par défaut : fichiers statiques gratuits et illimités, et l'adaptateur Astro officiel pour les quelques pages à la demande (Astro a rejoint Cloudflare en janvier 2026)⚠️ Un domaine personnalisé sur Workers exige que Cloudflare gère la zone DNS du domaine (ses serveurs de noms) ; sur le plan gratuit, il n'y a pas d'autre option. Le déplacement se fait sans toucher au site (étape 13). Si votre DNS ne peut pas quitter son fournisseur actuel, prenez Netlify ou Vercel. Cloudflare recommande Workers plutôt que Pages pour tout nouveau projet, et l'adaptateur Astro ne gère plus Pages depuis sa version 13. Le fichier _redirects envoie un code 302 par défaut : écrivez toujours 301
Netlify✅ Très simple, formulaires intégrés gratuitsLe plan gratuit compte en crédits : 15 crédits par mise en production, 20 crédits par Go de trafic, 300 crédits par mois, sans recharge possible. ⚠️ Crédits épuisés, Netlify met en pause tous les sites du compte jusqu'au mois suivant (une page « Site not available » remplace chaque page). Pour un site d'entreprise, prenez au moins le plan Personal (9 $/mois, 1 000 crédits, recharge possible) et surveillez la consommation le premier mois. Un CMS qui republie à chaque modification consomme 15 crédits à chaque fois
Vercel✅ Techniquement parfait⚠️ Plan gratuit réservé à un usage personnel et non commercial, y compris pour un site construit pour un client. Un site d'entreprise passe en Pro
Webflow Cloud🟠 Possible, pensé pour des applicationsRendu à la demande et cache désactivé par défaut (voir 1.4)

2.3 Statique ou à la demande ?

  • Statique (par défaut) : toutes les pages sont fabriquées une fois, au moment du « build ». C'est le plus rapide et le moins cher, et c'est ce qu'il faut à 90 % des sites vitrines.
  • À la demande : la page est fabriquée à chaque visite, sur un serveur. Nécessaire pour les formulaires traités par Astro (Actions), l'aperçu de Sanity, les contenus personnalisés.

Un site peut mélanger les deux : tout statique, sauf une ou deux routes. Pour ça, on installe l'adaptateur de l'hébergeur (@astrojs/cloudflare, @astrojs/netlify, @astrojs/vercel) et on marque les routes dynamiques avec export const prerender = false.

2.4 Ce que Webflow faisait sans qu'on le voie

C'est la liste qui fait échouer les migrations rapides. Un guide d'agence la résume : « Mettre en place cette couche, c'est l'essentiel du travail de migration. »

Webflow le faisait seulSur Astro, il faut le prévoir
Hébergement, CDN, certificat HTTPSL'hébergeur (Cloudflare, Netlify, Vercel). Sur Cloudflare, la redirection de http:// vers https:// est un réglage à part, à activer : SSL/TLS > Edge Certificates > Always Use HTTPS (étape 13)
Historique des versions, retour en arrièreGit : chaque modification est un commit, revenir en arrière en est un autre
Domaine de préproduction (.webflow.io)Une adresse d'aperçu par branche, fournie par l'hébergeur
Le sous-domaine technique non indexé (réglage Staging indexing)"workers_dev": false dans le wrangler.jsonc de production, sinon le site reste servi, indexable, sur mon-site.<compte>.workers.dev. Ajoutez "preview_urls": true pour garder les adresses d'aperçu (étape 13)
Bouton « Publier »Un build automatique à chaque modification validée
Traitement des formulaires et anti-spamUne Astro Action et un service d'e-mail, ou un service de formulaires (étape 8)
Recherche interneastro:assets (étape 6). Les adresses des images changent forcément : elles quittent le domaine de Webflow (étape 2)
Optimisation des imagesastro:assets (étape 6)
Sitemap automatique@astrojs/sitemap (étape 9)
Gestionnaire de redirections 301La table redirects d'Astro avec l'adaptateur, ou le fichier _redirects de l'hébergeur (étape 10)
Mises à jour de sécuritéLe build échoue sur un contenu invalide (le schéma des collections, étape 6). Les liens morts et les liens qui passent par une redirection se trouvent au crawl de la préproduction (étape 12)
Rien ne se publie s'il est cassé (ou presque)Un contrôle automatique : le build échoue sur un lien mort ou un contenu invalide
En-têtes de sécurité (dont HSTS, selon son centre d'aide)Le réglage HSTS de la zone Cloudflare, et les en-têtes posés par le site (étape 9)
La balise de vérification Search ConsoleUne propriété « Domaine », vérifiée par le DNS (étape 1)
Le panneau d'audit (textes alternatifs, accessibilité)Lighthouse et un contrôle d'accessibilité à la recette (étape 12)

2.5 La stack que je recommande

C'est celle de mon gabarit de départ pour tous mes sites Astro, éprouvée sur le vrai réseau de Cloudflare en octobre 2026. Elle répond aux choix ci-dessus pour le cas le plus courant : un site vitrine qu'une équipe met à jour.

BriqueChoixPourquoi
FrameworkAstro 7.4, version figée (pas de ^)Une mise à jour d'Astro ou de l'adaptateur est une décision, pas un accident
HébergementCloudflare Workers, plan gratuit, sur le compte du clientLe client possède son site ; les fichiers statiques ne coûtent rien. Condition : la zone DNS du domaine doit pouvoir passer chez Cloudflare (partie 2.2)
RenduTout statique, sauf /contact (output: 'static' + l'adaptateur @astrojs/cloudflare)La vitesse et le coût du statique ; une seule page à la demande, pour traiter le formulaire
ContenuSanity, son Studio intégré au site sur /studioUne vraie interface d'édition avec rôles, et une republication automatique à chaque publication (étape 11). Son édition visuelle (cliquer sur la page pour modifier) demande en plus une route d'aperçu à la demande, que cette stack n'inclut pas
FormulaireAstro Action + Resend pour l'envoi d'e-mail, sans JavaScript côté visiteur, avec un champ piège et un délai minimal contre les robots (étape 8)Aucun service de formulaires tiers ; repli sur un lien mailto: tant que la clé n'est pas posée
StyleTailwind 4, les variables du site Webflow en tokensLa charte reste verrouillée dans un seul fichier
PolicesL'API de polices d'Astro, fichiers servis par le siteAucune adresse IP envoyée à Google
Imagesastro:assets, optimisées au build (imageService: 'compile'), et le CDN de Sanity pour les images du CMSRien n'est facturé à la transformation
AnimationsGSAP + Lenis, uniquement par des attributs data-anim ; le HTML reste complet sans JavaScriptUne animation qui casse ne casse pas la page
RedirectionsLa table redirects d'Astro, une ligne par ancienne adresse ; l'adaptateur en fait de vraies 301 (étape 10)Une seule source de vérité
Mesure d'audienceCloudflare Web Analytics, pages vues seulement ; pendant la migration, en complément des outils existants, pas à leur placeElle ne dépose ni cookie ni stockage local

La variante minimale : un site que seul un développeur modifie, sans formulaire traité par le site, peut rester 100 % statique, sans adaptateur, avec son contenu en fichiers Markdown. C'est la voie qu'a suivie le site de démonstration de ce guide (partie 5). Sans adaptateur, la table redirects d'Astro ne produit pas de vraies 301 : les redirections passent par le fichier _redirects (étape 10). La variante minimale est décrite et testée de bout en bout ; pour la stack recommandée, chaque étape dit ce qui a été testé.

Partie 3 · La méthode en 14 étapesFaire

C'est la méthode que j'applique à chaque migration de site, quelle que soit la direction. Les captures d'écran viennent du site de démonstration de ce guide, migré étape par étape le 21 octobre 2026.

Le site de démonstration Atelier Nord ouvert dans le Designer de Webflow
Le site de départ : « Atelier Nord », un studio d'architecture fictif, dans le Designer de Webflow.

Les étapes 1 et 2 sont les plus longues, plus longues que le code. Simon Olivier, qui a sorti le site de son agence Bienfait de Webflow en septembre 2026, l'a écrit mot pour mot : « L'inventaire d'abord. […] Le code a pris moins de temps que cette étape. »

Étape 1 · L'inventaire, avant de toucher à quoi que ce soit

Une migration perd le plus souvent du référencement de la même façon : une adresse qui existait n'existe plus, et personne ne l'avait notée. L'inventaire est votre contrat : à la fin, chaque ligne doit répondre.

Toutes les adresses :

  • le sitemap.xml du site ;
  • un crawl complet (Screaming Frog, ou un script) : le sitemap ment. Sur une migration de 143 adresses, une 144e vivait hors du sitemap. Et un site encore sur son adresse .webflow.io peut ne pas avoir de sitemap du tout (c'était le cas du site de démonstration) ;
  • les adresses lues dans le CMS, une par élément publié. Sur le site de démonstration, le crawl a manqué les trois articles du blog : les cartes de la liste pointaient vers une adresse cassée. Seule la liste tirée du CMS les a rattrapés. Le crawl trouve ce qui est relié, le CMS trouve ce qui existe ;
  • les pages qui reçoivent des clics ou des impressions dans Search Console, sur 16 mois. ⚠️ L'export de l'interface s'arrête à 1 000 lignes : au-delà, passez par l'API de Search Console (jusqu'à 25 000 lignes par requête) ou par l'export groupé vers BigQuery. Faites un export par type de recherche (Web, Image, Vidéo), que le rapport compte à part. Gardez le chiffre Image : c'est le trafic que la migration expose le plus, puisque toutes les adresses d'images changent (étape 2) ;
  • les pages d'atterrissage de vos autres canaux, que Search Console ne voit pas : le rapport des pages de destination de votre outil de mesure sur 12 mois, tous canaux confondus, les adresses de destination des campagnes publicitaires, les liens des e-mails et des QR codes imprimés ;
  • les pages qui reçoivent des liens d'autres sites (rapport Liens de Search Console). Une page peut porter des liens entrants sans aucun clic : elle n'apparaît pas dans le rapport Performances ;
  • les fichiers (PDF, images) liés depuis d'autres sites : ils vivent sur le CDN de Webflow, pas sur votre domaine (voir l'étape 2) ;
  • les fichiers techniques, que ni le sitemap ni le crawl ne listent comme des pages : /robots.txt (Webflow permet d'y écrire ses propres règles), /llms.txt (Site settings > SEO > LLMs.txt), et le flux RSS de chaque collection (/blog/rss.xml, voir l'étape 9). Téléchargez-les tels quels avec curl, et ajoutez leurs adresses à l'inventaire.

Pour chaque adresse, le crawl doit relever : le code de réponse, le titre, la description, la balise canonical, les balises de partage (Open Graph), les balises de langue (hreflang), les données structurées, le H1. ZenML a appelé son crawl son « contrat avec la réalité » : le nouveau site doit le reproduire à l'identique.

Le contenu :

  • chaque collection du CMS : ses champs, leurs types, ses références vers d'autres collections, son nombre d'éléments ;
  • le code personnalisé caché dans les réglages : celui du site (Site settings > Custom code), celui de chaque page (Page settings), celui des gabarits de collection. Il n'apparaît nulle part dans le design ;
  • les intégrations natives, qui n'apparaissent dans aucun champ de code : l'identifiant Google Analytics et le pixel Meta saisis dans Site settings > Apps & integrations, l'app Google Site Tools. Notez chaque identifiant : sur Astro, il faudra poser la balise vous-même, avec le même identifiant et la même gestion du consentement ;
  • les réglages de référencement des gabarits de collection : par exemple « titre = nom de l'article | Blog ». Il faudra les reproduire.

Tout ce qui vit hors du design :

  • les formulaires : leur nom, leurs champs, leur destination, ce qui se passe après l'envoi ;
  • les scripts : Google Tag Manager, analytics, chat, bandeau cookies, prise de rendez-vous ;
  • les outils branchés : attributs Finsweet, Memberstack, Jetboost, Weglot ;
  • les pages de remerciement : ce sont souvent des cibles de conversion dans Google Tag Manager. Les supprimer casse le suivi des ventes sans aucune erreur visible ;
  • la recherche interne, les pages protégées par mot de passe, les langues, l'e-commerce ;
  • les espaces membres : Webflow a fermé sa fonction « User Accounts » le 29 janvier 2026. Si le site en a, ils passent déjà par un outil tiers (Memberstack, Outseta…) : notez lequel, il se rebranche ;
  • la vérification Search Console : Webflow pose la balise de vérification (SEO > Google site verification), et elle disparaît avec le site. Le plus sûr : créer avant la migration une propriété « Domaine », vérifiée par le DNS ;
  • les modules Webflow : Analyze (son historique ne vous suivra pas, exportez-le), Optimize (tests A/B en cours), les apps de la Marketplace.

Les redirections déjà en place : les exporter en CSV (Site settings > Publishing > 301 redirects > Export).

Les mesures de départ (datées, avant toute intervention) : Lighthouse sur chaque type de page, Core Web Vitals réels, trafic et positions des pages qui comptent. Sans elles, impossible de prouver après coup que rien n'a été perdu.

Le livrable de l'étape : un tableau avec une ligne par adresse et une décision par ligne :

DécisionQuand
Garder (même adresse)Le cas normal. 95 % des lignes
Rediriger (301)L'adresse doit changer, ou la page fusionne avec une autre
Supprimer (404)Seulement si la page n'a ni trafic, ni lien entrant, ni utilité. Les trois conditions, pas une seule
DésindexerPages utiles aux visiteurs mais pas aux moteurs (remerciements, pages de test)

Les colonnes que j'utilise pour migration/inventaire.csv, à copier dans un tableur :

Texte
chemin,code,titre,description,canonical,og_titre,og_image,robots,hreflang,h1,jsonld,clics_16_mois,liens_entrants,decision,destination,note

Étape 2 · Tout sortir de Webflow

⛔ Ne rien supprimer et ne rien résilier avant l'étape 14.

L'export du code (menu principal, le logo W en haut à gauche du Designer > Code export, ou Maj+E) :

  • il demande un plan de Workspace payant (Core, Growth, Freelancer ou Agency) ; un plan de site seul ne suffit pas ;
  • il contient les pages HTML, trois feuilles de style (normalize.css, webflow.css, et celle du site), le JavaScript de Webflow et un dossier d'images ;
  • il ne contient pas : le contenu du CMS (les listes de collection affichent leur état vide, les gabarits n'ont plus de contenu), les langues secondaires, le traitement des formulaires, le reCAPTCHA, la recherche, la protection par mot de passe, l'e-commerce, les composants de code ;
  • il sert de référence, pas de base de travail. Gardez-le dans un dossier à part (webflow-export/), hors du code du site, pour qu'il ne devienne jamais une dépendance.
La fenêtre Export Code de Webflow, avec l'avertissement sur le CMS et les formulaires
Webflow le dit lui-même, en bas de la fenêtre d'export : « Exports don't include CMS or native form functionality ».

⚠️ Relisez aussi le CSS exporté avant de vous en servir comme référence. Sur le site de démonstration, la feuille publiée contenait des transitions invalides (border-color undefined undefined) et des classes en double (_w-button, _w-input).

Le contenu du CMS : par l'API plutôt que par le CSV.

  • Le bouton Export de chaque collection produit un CSV : le texte riche sort en HTML, les références multiples en liste de texte, les images en liens.
  • L'API Data v2 donne des données typées : chaque champ avec son type, les références par identifiant, les images avec leur texte alternatif. C'est elle qu'utilise le script de l'étape 6.
La collection Blog dans le CMS de Webflow, avec ses champs Auteur et Catégorie et le bouton Export
La collection Blog du site de démonstration : un champ « Auteur » qui renvoie vers une autre collection, une « Catégorie » à choix, et le bouton Export en haut à droite.
  • ⚠️ Les images sortent sous forme de liens vers les fichiers hébergés par le projet Webflow. Webflow l'écrit lui-même : si le site est supprimé, ces liens cassent, y compris les images à l'intérieur des textes riches. On télécharge tout.
  • ⚠️ Certains fichiers du CDN de Webflow répondent par une erreur 403. ZenML et VGV l'ont rencontré. Le dossier d'images de l'export du code sert alors de roue de secours, pour les images du design seulement : il ne contient pas celles du CMS, qui se récupèrent sur la page publiée.

Les adresses des images et des fichiers changent toutes, sans redirection possible.

Sur Webflow, une image ou un PDF n'est jamais servi par votre domaine, mais par le CDN de Webflow (cdn.prod.website-files.com/…, ou uploads-ssl.webflow.com/… sur un site plus ancien). Après la migration, votre site les sert lui-même (/_astro/… pour les images, /files/… pour les fichiers). Les anciennes adresses ne sont pas sur votre domaine : aucune redirection ne peut les rattraper. Trois conséquences :

  • Google Images retrouve les nouvelles images en recrawlant vos pages. Mesurez ce trafic avant (étape 1, Type de recherche : Image), et suivez-le à part pendant les 30 jours. S'il compte (portfolio, architecture, produits), attendez-vous à une baisse temporaire, et gardez des noms de fichiers descriptifs et les mêmes textes alternatifs.
  • Un PDF lié depuis un autre site garde l'adresse du CDN de Webflow : il ne répond que tant que le projet Webflow existe. Pour les fichiers qui reçoivent des liens importants, demandez aux sites concernés de mettre à jour le lien.
  • Un fichier qui avait déjà une adresse sur votre domaine (par exemple une redirection Webflow de /brochure.pdf vers le CDN) figure dans le CSV des redirections : posez le fichier à cette adresse (public/brochure.pdf), ou faites pointer la redirection vers son nouvel emplacement.

D'où la règle : ne supprimez jamais le projet Webflow. Webflow le recommande lui-même à qui quitte sa plateforme : ses anciennes images répondent le temps que Google voie les nouvelles, et ses PDF restent joignables. À l'étape 14, on résilie le plan de site, pas le projet. Webflow ne dit pas combien de temps il garde les fichiers d'un projet sans plan : ne comptez pas dessus pour un lien important.

Les réponses aux formulaires : Site settings > Forms > View submissions > Export all, formulaire par formulaire. Les fichiers envoyés par les visiteurs sortent sous forme de liens : téléchargez-les aussi. Les réponses appartiennent au client, il les garde.

La liste des réponses du formulaire Contact dans Webflow, avec le bouton Export all
Les réponses d'un formulaire et le bouton « Export all ». Elles contiennent l'adresse IP de chaque visiteur (floutée ici) : ce sont des données personnelles, à archiver comme telles.

Les redirections : le CSV de l'étape 1. (L'API des redirections est réservée aux comptes Enterprise.) Un site sans offre de site payante n'en a souvent pas : Webflow n'y accepte de redirections 301 qu'avec un Workspace Growth ou Agency.

La section 301 redirects de Webflow avec le message indiquant qu'elles ne sont pas disponibles sur un site non hébergé
Sur un site sans offre payante, ici dans un Workspace Freelancer, le réglage existe mais reste fermé. Sur Cloudflare, les mêmes redirections tiennent en trois lignes (étape 10).

Étape 3 · Créer le projet Astro

Prérequis : Node.js 22.22 ou plus récent (les versions impaires comme la 23 ne sont pas prises en charge).

Terminal
npm create astro@latest mon-site -- --template minimal
cd mon-site
npx astro add cloudflare sitemap tailwind   # l'adaptateur (pour /contact), le plan du site, Tailwind 4
npm install @astrojs/rss gsap lenis turndown

Figez ensuite les versions d'Astro et de l'adaptateur dans package.json (retirez le ^ devant astro et @astrojs/cloudflare). La variante minimale saute cloudflare et tailwind, et retire de la configuration ci-dessous les lignes output, session, adapter et redirects : ses redirections passent par public/_redirects (étape 10). Si le contenu va dans Sanity, ses paquets s'ajoutent à l'étape 6.

L'organisation du projet :

Texte
mon-site/
├── astro.config.mjs
├── wrangler.jsonc              ← si hébergement Cloudflare
├── sanity.config.ts            ← si le contenu vit dans Sanity (étape 6)
├── migration/                  ← le dossier de pilotage (hors du site)
│   ├── plan.md                 ← les phases et leur définition de fini (partie 4)
│   ├── inventaire.csv          ← l'étape 1 : une ligne par adresse
│   ├── redirections.csv
│   ├── lecons.md               ← ce qui a coûté du temps, relu à chaque session
│   └── a-trancher.md           ← les décisions qui ne sont pas techniques
├── webflow-export/             ← l'export du code : référence, jamais importé
├── scripts/
│   └── export-webflow.mjs      ← l'étape 6
├── public/                     ← servi tel quel, à adresse fixe
│   ├── _redirects              ← variante minimale seulement
│   ├── robots.txt
│   ├── favicon.svg
│   └── og-default.jpg
└── src/
    ├── actions/index.ts        ← le traitement du formulaire (étape 8)
    ├── assets/                 ← les images téléchargées (optimisées par Astro)
    ├── components/
    │   ├── seo/BaseHead.astro
    │   ├── sections/           ← la liste fermée des sections (étape 5)
    │   └── ui/                 ← boutons, conteneurs
    ├── content/                ← le contenu exporté du CMS
    ├── content.config.ts
    ├── layouts/Layout.astro
    ├── pages/
    │   ├── index.astro
    │   ├── blog/index.astro
    │   ├── blog/[slug].astro
    │   ├── contact.astro       ← la seule page rendue à la demande (étape 8)
    │   └── 404.astro
    └── styles/
        ├── tokens.css
        └── global.css

La configuration (astro.config.mjs). Les trois premières lignes sont celles qui protègent vos adresses :

JavaScript
// @ts-check
import { defineConfig, fontProviders } from 'astro/config';
import sitemap from '@astrojs/sitemap';
import cloudflare from '@astrojs/cloudflare';

export default defineConfig({
  site: 'https://www.exemple.com', // le domaine définitif, avec ou sans www, exactement comme aujourd'hui
  trailingSlash: 'never',          // Webflow sert /a-propos, jamais /a-propos/
  build: { format: 'file' },       // génère a-propos.html au lieu de a-propos/index.html
  output: 'static',                // tout est fabriqué au build, sauf les pages marquées prerender = false
  session: false,                  // un site vitrine n'a pas de session (sinon l'adaptateur crée un stockage KV)
  adapter: cloudflare({ imageService: 'compile' }), // images optimisées au build, rien de facturé
  redirects: {},                   // les anciennes adresses, une ligne chacune (étape 10)
  integrations: [
    sitemap({ filter: (page) => !page.endsWith('/merci') }), // pages de remerciement hors du sitemap
  ],
  image: { layout: 'constrained', responsiveStyles: true },
  fonts: [
    { provider: fontProviders.google(), name: 'Inter', cssVariable: '--font-inter' },
  ],
});
  • Tailwind : astro add tailwind a ajouté lui-même son plugin dans la configuration (vite: { plugins: [tailwindcss()] }) ; il n'est pas répété ici.
  • site : obligatoire pour le sitemap et les balises canonical. Reprenez exactement le domaine actuel. Si le site vit sur www.exemple.com, n'écrivez pas exemple.com.
  • trailingSlash et build.format : Webflow sert ses pages sans barre finale et redirige /page/ vers /page. Ces deux réglages produisent les mêmes adresses. Une nuance : Webflow répond à /page/ par une 301, Cloudflare par une 307 (redirection temporaire) vers /page. Sans conséquence tant que la canonical et les liens internes n'ont pas de barre finale. ⚠️ Pour les pages statiques, c'est l'hébergeur qui a le dernier mot : vérifiez avec curl sur la préproduction (étape 12).
  • fonts : l'API de polices d'Astro (stable depuis Astro 6) télécharge les polices et les sert depuis votre site. Bonus : Webflow charge les Google Fonts depuis les serveurs de Google, ce qui envoie l'adresse IP des visiteurs à Google ; Webflow signale lui-même que ce n'est peut-être pas conforme au RGPD.

L'hébergement Cloudflare (wrangler.jsonc, à la racine) :

JSON
// wrangler.jsonc : un site Astro statique avec une page à la demande (/contact), sur Cloudflare Workers
{
  "name": "mon-site",
  "main": "@astrojs/cloudflare/entrypoints/server",
  "compatibility_date": "2026-10-15",
  "compatibility_flags": ["nodejs_compat"],
  "assets": { "binding": "ASSETS", "directory": "./dist" }
  // ⛔ pas de "not_found_handling" ici (voir l'encadré ci-dessous) : le Worker sert lui-même la 404
}

Dans la page du formulaire, une ligne la rend dynamique : export const prerender = false; (src/pages/contact.astro). Toutes les autres restent statiques.

Mise en ligne : npx astro build && npx wrangler deploy, ou un déploiement automatique à chaque push depuis GitHub.

Variante minimale (100 % statique, sans adaptateur). La configuration Astro est la même, sans les lignes output, session, adapter et redirects. L'hébergement :

JSON
// wrangler.jsonc : un site Astro 100 % statique sur Cloudflare Workers, sans adaptateur
{
  "name": "mon-site",
  "compatibility_date": "2026-10-01",
  "assets": {
    "directory": "./dist",
    "html_handling": "auto-trailing-slash", // /a-propos sert a-propos.html
    "not_found_handling": "404-page"         // sert 404.html avec un vrai code 404
  }
}

Cette variante ne vaut que sans adaptateur.

Dès que vous ajoutez l'adaptateur @astrojs/cloudflare et des pages rendues à la demande (un formulaire traité par Astro, un Studio Sanity intégré), retirez not_found_handling : sinon, quand un visiteur ouvre une de ces pages dans son navigateur, Cloudflare lui sert la page 404 statique sans jamais appeler le serveur. Le piège ne se voit pas avec curl, qui n'envoie pas l'en-tête du navigateur (Sec-Fetch-Mode: navigate) : testez ces pages dans un vrai navigateur, ou avec curl -H 'Sec-Fetch-Mode: navigate'.

Astro 7 est plus strict avec le HTML collé.

Son compilateur refuse désormais les balises non fermées : du HTML copié depuis l'export Webflow ou un bloc « Embed » peut faire échouer le build. Le HTML mal imbriqué (une <div> dans un <p>) passe, mais Astro ne le corrige plus : c'est le navigateur qui le réarrange, parfois autrement que prévu. Autre détail : Astro 7 compacte le HTML de vos composants plus agressivement, et deux éléments en ligne écrits sur deux lignes (<span>a</span> puis <em>b</em>) peuvent se coller. Relisez les textes mis en forme.

Étape 4 · Le design system d'abord, les pages ensuite

C'est l'étape que les migrations rapides sautent, et celle qui les fait échouer. Trois récits de 2026 le disent chacun à leur manière :

  • mega.dev (924 pages) : copier page par page a fait dériver les styles. Il a fallu une seconde passe pour extraire les couleurs, les tailles et les composants partagés, puis rebrancher toutes les pages dessus.
  • Felix Dorner, designer : demander à une IA de déduire le design system à partir du site en ligne a échoué, parce que le HTML de Webflow aplatit la structure. Ce qui a marché : prendre le HTML d'un composant à la fois dans le navigateur, et le reconstruire.
  • Le cas de retour en arrière (Reddit, juillet 2026) : un site converti en Astro en une journée, puis des nouvelles pages « légèrement hors charte » au fil des semaines, et un retour sur Webflow.

L'ordre qui marche :

1. Les variables (« tokens ») : couleurs, typographies, tailles de texte, espacements, rayons, ombres. Si le site Webflow utilise le panneau Variables, ou la convention Client-First de Finsweet, tout est déjà nommé : reprenez les mêmes noms.

CSS
/* src/styles/tokens.css : les valeurs du site Webflow, nommées une fois pour toutes */
:root {
  --color-ink: #0b0b0b;
  --color-paper: #fbfbf9;
  --color-accent: #2f4bff;
  --font-body: var(--font-inter), system-ui, sans-serif;
  --text-h1: clamp(2.5rem, 5vw, 4.5rem);
  --space-section: 7rem;
  --container-max: 80rem;
  --radius: 0.5rem;
}

Avec Tailwind 4 (la stack recommandée), les mêmes variables se déclarent dans un bloc @theme de la feuille principale : @theme { --color-ink: #0b0b0b; } crée à la fois la variable CSS et les classes text-ink, bg-ink. La charte reste dans un seul fichier.

2. La mise en page : le gabarit commun (en-tête, navigation, pied de page) dans src/layouts/Layout.astro.

3. Les composants : boutons, cartes, puis les sections.

4. Les pages, assemblées uniquement à partir de ces composants.

Deux façons de reprendre le style. Choisissez consciemment :

A. Garder le CSS de Webflow comme « coque »B. Reconstruire proprement
CommentOn garde normalize.css, webflow.css et la feuille du site comme styles globaux, et on réécrit le HTML en composants Astro qui gardent les mêmes classesOn réécrit le style à partir des variables, en CSS simple ou en Tailwind
AvantageIdentique au pixel près, vite. Les classes Client-First restent lisiblesCode propre, léger, facile à maintenir
InconvénientOn embarque tout le framework CSS de Webflow, et des classes générées comme div-block-47Plus long. Chaque écart se corrige à la main
Pour quiLa priorité est « identique, vite, sans risque », puis un nettoyage progressifLe site va vivre longtemps et évoluer

Pour la voie A, Finsweet (l'auteur de Client-First) publie depuis septembre 2026 @finsweet/webflow-astro : des composants Astro qui reproduisent les éléments natifs de Webflow (navbar, menu déroulant, onglets, slider, lightbox, formulaires, listes de collection), réécrits en TypeScript, sans jQuery. Le paquet n'apporte aucun CSS : on garde celui de Webflow. ⚠️ Il est très jeune (version 0.2, cinq semaines au 20/10) : à tester avant de s'y fier.

Le consensus des sources : l'export sert de référence et de réserve d'images, jamais de base pour le HTML (son CSS peut servir de coque : c'est la voie A). On ne « migre pas l'export ». On migre le design et le contenu.

Étape 5 · Les gabarits, les composants et les pages

Le gabarit commun (src/layouts/Layout.astro) :

Astro
---
// src/layouts/Layout.astro
import type { ComponentProps } from 'astro/types';
import BaseHead from '../components/seo/BaseHead.astro';
import '../styles/global.css';

// Les propriétés du composant d'en-tête (étape 9), plus la langue de la page et ses données structurées
type Props = ComponentProps<typeof BaseHead> & { lang?: string; jsonLd?: object };
const { lang = 'fr', jsonLd, ...seo } = Astro.props; // lang suit la langue de la page, jamais écrite en dur (étape 9)
---
<html lang={lang}>
  <head>
    <BaseHead {...seo} />
    {jsonLd && <script type="application/ld+json" set:html={JSON.stringify(jsonLd)} />}
  </head>
  <body>
    <slot />
  </body>
</html>

Une liste fermée de sections. Chaque section du site Webflow devient un composant de src/components/sections/ : Hero.astro, Features.astro, Testimonials.astro, Faq.astro, Cta.astro. Une page ne s'assemble qu'avec ces composants. Un besoin nouveau crée un composant nouveau, validé une fois, au lieu d'une page bricolée. C'est le garde-fou qui manquait au cas du retour en arrière.

Les pages statiques (accueil, à propos, contact) : une page .astro par page Webflow, au même chemin. src/pages/a-propos.astro donne /a-propos.

Les pages de collection : le dossier porte le même nom que le préfixe Webflow. Si les articles vivent sur /blog/mon-article, la route est src/pages/blog/[slug].astro. Si Webflow utilise /blog-posts/, gardez /blog-posts/. On ne corrige pas les adresses pendant une migration.

Étape 6 · Le contenu : du CMS Webflow aux content collections

Le principe. Un script lit l'API de Webflow, écrit un fichier Markdown par élément publié, télécharge chaque image, et convertit le texte riche sans perdre les légendes ni les vidéos. On peut le relancer autant de fois que nécessaire jusqu'à la bascule.

Ce que l'API renvoie, et les pièges de chaque type de champ :

Champ WebflowCe que renvoie l'APICe qu'il faut faire
Texte richeDu HTML (des figures w-richtext-figure-type-image ou -video, des blocs w-embed)Convertir en Markdown avec des règles pour les figures, les vidéos et les blocs. Ne jamais « réparer » le Markdown avec des expressions régulières après coup
Image{ fileId, url, alt }Télécharger dans src/assets/, garder le texte alternatif
GalerieUne liste d'imagesIdem, en liste
OptionL'identifiant de l'option, pas son libelléRetrouver le libellé dans le schéma de la collection
RéférenceL'identifiant de l'élément viséLe remplacer par son slug (deux passages : on lit tout, puis on écrit)
Références multiplesUne liste d'identifiantsIdem
Champ videAbsent, ou null : les deux se rencontrentTous les champs optionnels doivent l'être dans le schéma

Autres règles de l'API : 100 éléments au maximum par requête (on pagine), 60 requêtes par minute sur les petits plans et 120 sur les plans CMS (au-delà, réponse 429 et un délai à respecter), un slug unique par collection seulement. Le jeton se crée dans Site settings > Apps & integrations > API access, avec les droits de lecture du CMS. Seul un administrateur du site peut le créer. Gardez-le hors du code (une variable d'environnement), et supprimez-le une fois la migration finie.

La section API access des réglages Webflow avec le bouton Generate API token
Les réglages du site, onglet Apps & Integrations : la clé se génère ici, en lecture seule suffit.

⚠️ Pas de MDX pour du contenu importé. ZenML l'a mesuré : MDX interprète les accolades et le HTML brut de Webflow comme du code, et aurait cassé 1 904 fichiers. Du Markdown simple (.md) suffit.

Le script complet (scripts/export-webflow.mjs) :

JavaScript
// scripts/export-webflow.mjs
// Exporte toutes les collections publiées du CMS Webflow en fichiers Markdown pour Astro,
// et télécharge chaque image et chaque fichier sur le disque.
//
// Usage :  WEBFLOW_TOKEN=xxx WEBFLOW_SITE_ID=xxx node scripts/export-webflow.mjs
// Prérequis : Node 22 ou plus, et `npm install turndown`
// Le jeton se crée dans Webflow : Site settings > Apps & integrations > API access (droits CMS en lecture).

import fs from 'node:fs/promises';
import path from 'node:path';
import { createHash } from 'node:crypto';
import TurndownService from 'turndown';

const API = 'https://api.webflow.com/v2';
const { WEBFLOW_TOKEN, WEBFLOW_SITE_ID } = process.env;
if (!WEBFLOW_TOKEN || !WEBFLOW_SITE_ID) throw new Error('WEBFLOW_TOKEN et WEBFLOW_SITE_ID sont obligatoires');
const headers = { Authorization: `Bearer ${WEBFLOW_TOKEN}`, accept: 'application/json' };

// ── 1. Appels à l'API : pause sur 429 (trop de requêtes), nouvel essai sur une erreur serveur ou réseau ──
const sleep = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000));
async function get(url) {
  for (let attempt = 1; ; attempt++) {
    const res = await fetch(url, { headers }).catch(() => null); // null = erreur réseau
    if (res?.status === 429) {
      const retry = Number(res.headers.get('retry-after')); // en secondes ; une date HTTP donnerait NaN
      const seconds = Number.isFinite(retry) && retry > 0 ? retry : 60;
      console.log(`429 : pause de ${seconds} s`);
      await sleep(seconds);
      continue;
    }
    if (res?.ok) return res.json();
    if (attempt < 5 && (!res || res.status >= 500)) { await sleep(2 ** attempt); continue; }
    throw new Error(`${res?.status ?? 'erreur réseau'} sur ${url}`);
  }
}

// Webflow renvoie les éléments par paquets de 100 au maximum.
async function getAllItems(collectionId) {
  const items = [];
  for (let offset = 0; ; offset += 100) {
    const page = await get(`${API}/collections/${collectionId}/items/live?limit=100&offset=${offset}`);
    items.push(...page.items);
    if (page.items.length === 0 || items.length >= page.pagination.total) return items;
  }
}

// ── 2. Téléchargement des fichiers : les liens Webflow cassent si le site est supprimé ──
const downloaded = new Map(); // adresse d'origine -> fichier local, pour ne rien télécharger deux fois
const usedFiles = new Set();
async function download(url, dir) {
  if (downloaded.has(url)) return downloaded.get(url);
  const res = await fetch(url).catch(() => null);
  if (!res?.ok) {
    console.warn(`⚠️  fichier ${res?.status ?? 'erreur réseau'} : ${url}`); // à reprendre à la main (voir l'étape 2)
    return null;
  }
  // Un nom sans espace ni parenthèse (sinon le lien Markdown casse), et jamais deux fois le même.
  let name = new URL(url).pathname.split('/').pop().replace(/[^\w.-]+/g, '-');
  if (usedFiles.has(path.join(dir, name))) name = `${createHash('sha1').update(url).digest('hex').slice(0, 8)}-${name}`;
  usedFiles.add(path.join(dir, name));
  await fs.mkdir(dir, { recursive: true });
  const file = path.join(dir, name);
  await fs.writeFile(file, Buffer.from(await res.arrayBuffer()));
  downloaded.set(url, file);
  return file;
}

const relative = (fromDir, file) => path.relative(fromDir, file).split(path.sep).join('/');
const camel = (slug) => slug.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());

// ── 3. Texte riche : HTML Webflow -> Markdown, sans perdre les légendes ni les vidéos ──
const turndown = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced', bulletListMarker: '-' });
turndown.keep(['iframe']);
// Un « < » écrit dans un article (ex. « la balise <head> ») doit rester du texte, jamais devenir une balise.
const escapeMarkdown = turndown.escape.bind(turndown);
turndown.escape = (text) => escapeMarkdown(text).replace(/</g, '\\<');
turndown.addRule('webflow-figure', {
  filter: 'figure', // images et vidéos insérées dans le texte riche (w-richtext-figure-type-*)
  replacement: (_content, node) => {
    const iframe = node.querySelector('iframe');
    if (iframe) { // la vidéo garde ses proportions (Webflow les portait par un conteneur, perdu ici)
      iframe.setAttribute('width', '100%');
      iframe.setAttribute('height', '100%');
      return `\n\n<div style="aspect-ratio: 16 / 9">${iframe.outerHTML}</div>\n\n`;
    }
    const img = node.querySelector('img');
    if (!img) return '';
    const alt = (img.getAttribute('alt') ?? '').replace(/[[\]]/g, '\\$&');
    const caption = node.querySelector('figcaption')?.textContent.trim();
    return `\n\n![${alt}](${img.getAttribute('src')})${caption ? `\n\n*${turndown.escape(caption)}*` : ''}\n\n`;
  },
});
turndown.addRule('webflow-embed', {
  filter: (node) => node.nodeName === 'DIV' && (node.classList.contains('w-embed') || node.hasAttribute('data-rt-embed-type')), // blocs « Embed »
  replacement: (_content, node) => `\n\n${node.innerHTML.trim()}\n\n`,
});

async function richTextToMarkdown(html, assetsDir, contentDir) {
  for (const [, src] of html.matchAll(/<img[^>]+src="([^"]+)"/g)) {
    const file = await download(src.replaceAll('&amp;', '&'), assetsDir);
    if (file) html = html.replaceAll(src, () => relative(contentDir, file)); // une fonction : « $& » resterait sinon interprété
  }
  // Les fichiers liés dans le texte riche (PDF…), sur tous les domaines de fichiers de Webflow : dans public/, à adresse fixe.
  const filesDir = path.join('public/files', path.basename(assetsDir));
  for (const [, href] of html.matchAll(/<a[^>]+href="(https?:\/\/(?:uploads-ssl\.webflow\.com|[\w.-]+\.website-files\.com)\/[^"]+)"/g)) {
    const file = await download(href.replaceAll('&amp;', '&'), filesDir);
    if (file) html = html.replaceAll(href, () => '/' + relative('public', file));
  }
  return turndown.turndown(html);
}

// ── 4. Premier passage : tout lire, et noter le slug de chaque élément (pour les références) ──
const { collections } = await get(`${API}/sites/${WEBFLOW_SITE_ID}/collections`);
const loaded = [];
const slugById = new Map();
for (const { id } of collections) {
  const collection = await get(`${API}/collections/${id}`); // le schéma : la liste des champs
  const items = await getAllItems(id);
  for (const item of items) slugById.set(item.id, item.fieldData.slug);
  loaded.push({ collection, items });
  console.log(`${collection.slug} : ${items.length} éléments publiés`);
}

// ── 5. Second passage : écrire un fichier .md par élément ──
const urls = [];
const lastmod = {}; // adresse -> date de la dernière modification, pour le sitemap (étape 9)
for (const { collection, items } of loaded) {
  const contentDir = path.join('src/content', collection.slug);
  const assetsDir = path.join('src/assets', collection.slug);
  await fs.mkdir(contentDir, { recursive: true });

  for (const item of items) {
    const data = { webflowId: item.id };
    let body = '';

    for (const field of collection.fields) {
      const value = item.fieldData[field.slug];
      if (value === undefined || value === null || value === '') continue; // champ vide : absent, pas null
      const key = camel(field.slug);

      switch (field.type) {
        case 'RichText': {
          const markdown = await richTextToMarkdown(value, assetsDir, contentDir);
          if (!body) body = markdown; // le premier texte riche devient le corps de la page
          else data[key] = markdown;
          break;
        }
        case 'Image': {
          const file = await download(value.url, assetsDir);
          if (file) data[key] = relative(contentDir, file);
          if (value.alt) data[`${key}Alt`] = value.alt;
          break;
        }
        case 'MultiImage': {
          const files = [];
          for (const image of value) {
            const file = await download(image.url, assetsDir);
            if (file) files.push({ src: relative(contentDir, file), alt: image.alt ?? '' });
          }
          data[key] = files;
          break;
        }
        case 'File': { // PDF et autres : dans public/, pour garder une adresse fixe
          const file = await download(value.url, path.join('public/files', collection.slug));
          if (file) data[key] = '/' + relative('public', file);
          break;
        }
        case 'Option': // Webflow renvoie l'identifiant de l'option : on écrit son libellé
          data[key] = field.validations?.options?.find((option) => option.id === value)?.name ?? value;
          break;
        case 'Reference': // un identifiant Webflow -> le slug de l'élément visé
          data[key] = slugById.get(value) ?? value;
          break;
        case 'MultiReference':
          data[key] = value.map((id) => slugById.get(id) ?? id);
          break;
        default: // texte, nombre, date, interrupteur, couleur, lien, e-mail, vidéo
          data[key] = value;
      }
    }

    // Du JSON est du YAML valide : chaque valeur s'écrit sans souci d'échappement.
    const frontmatter = Object.entries(data).map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join('\n');
    await fs.writeFile(path.join(contentDir, `${item.fieldData.slug}.md`), `---\n${frontmatter}\n---\n\n${body}\n`);
    const url = `/${collection.slug}/${item.fieldData.slug}`; // le préfixe réel peut différer du slug de collection : corrigez-le ici
    urls.push(url);
    lastmod[url] = item.lastUpdated; // ou item.lastPublished : gardez celui qui correspond au <lastmod> du sitemap Webflow
  }
}

// La liste des adresses du CMS, à croiser avec l'inventaire, et la date de chaque adresse pour le sitemap.
await fs.mkdir('migration', { recursive: true });
await fs.writeFile('migration/adresses-cms.txt', urls.join('\n') + '\n');
await fs.writeFile('migration/lastmod.json', JSON.stringify(lastmod, null, 2) + '\n');
console.log(`Terminé : ${urls.length} fichiers, ${downloaded.size} fichiers téléchargés.`);

Ce que le script produit, pour un article :

Markdown
---
webflowId: "p0"
name: "Comment choisir son hébergeur"
slug: "comment-choisir-son-hebergeur"
mainImage: "../../assets/blog/62b7..._cover.png"
mainImageAlt: "Une salle de serveurs"
category: "SEO"
author: "ada"
featured: true
publishedOn: "2026-05-01T00:00:00.000Z"
---

## Le premier critère

Du **gras** et un [lien interne](/blog/un-autre-article).

![Schéma](../../assets/blog/62b7..._schema.png)

*La légende de l'image*

<iframe src="https://www.youtube.com/embed/abc" allowfullscreen=""></iframe>

Le schéma des collections (src/content.config.ts). Il décrit chaque champ, et le build échoue si un contenu ne le respecte pas : c'est le filet de sécurité qui remplace les contrôles de Webflow.

TypeScript
// src/content.config.ts
import { defineCollection, reference } from 'astro:content';
import { glob } from 'astro/loaders';
import { z } from 'astro/zod';

const auteurs = defineCollection({
  loader: glob({ base: './src/content/auteurs', pattern: '**/*.md' }),
  schema: ({ image }) =>
    z.object({
      name: z.string(),
      photo: image().optional(),
      photoAlt: z.string().optional(),
    }),
});

const blog = defineCollection({
  loader: glob({ base: './src/content/blog', pattern: '**/*.md' }),
  schema: ({ image }) =>
    z.object({
      webflowId: z.string(),
      name: z.string(),
      slug: z.string(),
      mainImage: image().optional(),
      mainImageAlt: z.string().optional(),
      summary: z.string().optional(),
      category: z.enum(['Design', 'SEO']).optional(),
      author: reference('auteurs').optional(),
      featured: z.boolean().default(false),
      publishedOn: z.coerce.date().optional(),
    }),
});

export const collections = { auteurs, blog };

La page d'un article (src/pages/blog/[slug].astro) :

Astro
---
// src/pages/blog/[slug].astro : le même chemin que sur Webflow (/blog/mon-article)
import { getCollection, getEntry, render } from 'astro:content';
import { Image, getImage } from 'astro:assets';
import Layout from '../../layouts/Layout.astro';

export async function getStaticPaths() {
  const posts = await getCollection('blog');
  return posts.map((post) => ({ params: { slug: post.id }, props: { post } }));
}

const { post } = Astro.props;
const { Content } = await render(post);
const author = post.data.author ? await getEntry(post.data.author) : undefined;
// L'image de partage de l'article, comme sur Webflow (où le gabarit de collection la lie en général à l'image principale) : une seule taille
const ogImage = post.data.mainImage
  ? (await getImage({ src: post.data.mainImage, width: 1200, format: 'jpg', layout: 'none' })).src
  : undefined;
---
<Layout
  title={`${post.data.name} | Blog`}
  description={post.data.summary ?? ''}
  image={ogImage}
  jsonLd={{
    '@context': 'https://schema.org',
    '@type': 'BlogPosting',
    headline: post.data.name,
    datePublished: post.data.publishedOn?.toISOString(),
    author: author ? { '@type': 'Person', name: author.data.name } : undefined,
  }}
>
  <article>
    <h1>{post.data.name}</h1>
    {author && <p>Par {author.data.name}</p>}
    {post.data.mainImage && <Image src={post.data.mainImage} alt={post.data.mainImageAlt ?? ''} />}
    <Content />
  </article>
</Layout>

La liste des articles (src/pages/blog/index.astro). ⚠️ Astro ne garantit pas l'ordre des éléments d'une collection : on trie toujours soi-même.

Astro
---
import { getCollection } from 'astro:content';
import Layout from '../../layouts/Layout.astro';
const posts = (await getCollection('blog')).sort(
  (a, b) => (b.data.publishedOn?.valueOf() ?? 0) - (a.data.publishedOn?.valueOf() ?? 0),
);
---
<Layout title="Blog" description="Les articles">
  <h1>Blog</h1>
  <ul>{posts.map((post) => <li><a href={`/blog/${post.id}`}>{post.data.name}</a></li>)}</ul>
</Layout>

Pour une liste paginée, Astro fournit paginate() dans getStaticPaths, avec une route src/pages/blog/[...page].astro (/blog, /blog/2…). Les adresses de pagination de Webflow (?xxxx_page=2) n'ont pas d'équivalent : elles retombent sur la première page, dont la canonical pointe vers /blog : c'est sans conséquence notable.

Le contrôle de fin d'étape : pour chaque collection, le nombre de fichiers égale le nombre d'éléments publiés dans Webflow. Le script l'affiche. Puis cherchez tout ce qui pointe encore vers Webflow, sous tous ses noms : selon l'âge du site, les fichiers vivent sur cdn.prod.website-files.com, assets-global.website-files.com ou uploads-ssl.webflow.com.

Terminal
grep -rnIE "website-files\.com|webflow\.(com|io)" src public

La commande ne doit rien renvoyer, sauf un lien voulu vers le site de Webflow lui-même. Sinon, chaque ligne est une image en échec (restée une adresse distante) ou un fichier que le script n'a pas pris : téléchargez-le à la main (dans src/assets/ pour une image, dans public/files/ pour un fichier), et remplacez le lien par la nouvelle adresse. L'ancien domaine écrit en entier dans les liens internes ne doit pas apparaître non plus.

Ce script a tourné sans modification contre la vraie API, sur le site de démonstration (5 éléments, 5 images), et ses conversions ont été testées sur des cas pièges : un « < » écrit dans un article, une légende avec des astérisques, une vidéo, un bloc Embed. Ses deux ajouts (les fichiers liés dans les textes riches, les dates pour le sitemap) n'ont été testés que contre une copie simulée de l'API.

Les cas que le script ne couvre pas, à traiter à la main :

  • Les langues secondaires : chaque langue a son identifiant (cmsLocaleId, lu dans les réglages du site par l'API) ; on relance la lecture des éléments avec ?cmsLocaleId=... et on écrit dans src/content/blog/fr/. Dans content.config.ts, donnez alors à chaque langue sa collection (pattern: '*.md' pour la langue principale, une collection blogFr sur src/content/blog/fr) : avec **/*.md, les articles traduits rejoindraient la liste principale, et deux slugs identiques entreraient en conflit. Les références d'un article traduit visent le slug traduit de l'élément cible : remplissez slugById langue par langue. Pour les pages statiques traduites, le plus simple est de relever le texte sur le site en ligne.
  • Les composants insérés dans un texte riche (balises <wf-component>, possibles depuis septembre 2026) : cherchez-les dans les fichiers exportés et remplacez-les par un composant Astro.
  • Les blocs de code dans le texte riche : l'API ne les prend pas en charge, vérifiez-les un par un.

Variante recommandée : le contenu dans Sanity. ⚠️ Cette partie n'a pas été testée dans ce guide. La configuration ci-dessous est celle de ma stack, éprouvée sur un vrai déploiement ; l'import du contenu, lui, suit la méthode officielle de Sanity, que je n'ai pas rejouée sur le site de démonstration.

Terminal
npm install @sanity/astro @astrojs/react @sanity/client sanity react react-dom styled-components
JavaScript
// astro.config.mjs : le Studio de Sanity intégré au site, sur /studio
import sanity from '@sanity/astro';
import react from '@astrojs/react';

// dans defineConfig({ … }) :
integrations: [
  sanity({
    projectId: 'votre-id-de-projet',
    dataset: 'production',
    apiVersion: '2024-01-01',
    useCdn: false,                 // chaque build suit une publication : le CDN de Sanity aurait quelques secondes de retard
    studioBasePath: '/studio',
    studioRouterHistory: 'hash',   // le Studio reste une page statique ; ses adresses internes vivent après un #
  }),
  react(),                         // pour le Studio seulement : aucune page du site n'en charge
  sitemap(),
],
  • studioRouterHistory: 'hash' : sans lui, le Studio devient une page à la demande, et Astro 7.4 attache alors son CSS (environ 200 Ko) à toutes les pages à la demande, /contact comprise.
  • Lire le contenu au build : un client @sanity/client (createClient, useCdn: false) et des requêtes GROQ dans les pages, par exemple *[_type == "post"] | order(publishedAt desc){ title, "slug": slug.current, excerpt }. Le texte riche (Portable Text) s'affiche avec une bibliothèque comme astro-portabletext.
  • L'import depuis Webflow : chaque élément devient un document NDJSON, avec un identifiant stable tiré de l'identifiant Webflow (on peut relancer sans doublons) ; le texte riche passe en Portable Text avec htmlToBlocks de @portabletext/block-tools ; les images sont envoyées dans Sanity. On importe d'abord sur un dataset de test (sanity datasets import), puis en production. Le cours officiel de Sanity, Migrating content from Webflow to Sanity, déroule cette méthode en entier (partie 9).
  • La republication : un webhook Sanity appelle l'adresse de reconstruction de Cloudflare à chaque publication (étape 11).

Variante : garder le CMS Webflow (la migration en deux temps). On ne copie rien : Astro lit le CMS à chaque build. Esquisse d'un loader, à compléter avec la gestion du code 429 du script ci-dessus :

TypeScript
// src/content.config.ts : le CMS Webflow reste la source du contenu
const blog = defineCollection({
  loader: async () => {
    const items = [];
    for (let offset = 0; ; offset += 100) {
      const res = await fetch(
        `https://api-cdn.webflow.com/v2/collections/${import.meta.env.WEBFLOW_BLOG_ID}/items/live?limit=100&offset=${offset}`,
        { headers: { Authorization: `Bearer ${import.meta.env.WEBFLOW_TOKEN}` } },
      );
      if (!res.ok) throw new Error(`Webflow a répondu ${res.status}`); // 429 : reprenez la gestion du script ci-dessus
      const page = await res.json();
      items.push(...page.items);
      if (page.items.length === 0 || items.length >= page.pagination.total) break;
    }
    return items.map((item) => ({ id: item.fieldData.slug, ...item.fieldData }));
  },
});

Ces éléments n'ont pas de rendu Markdown : la page article affiche leur texte riche avec <Fragment set:html={post.data['post-body']} /> au lieu de render(), et les champs gardent leurs noms Webflow (post-body). L'hôte api-cdn.webflow.com est l'API de diffusion de contenu de Webflow : ses réponses en cache n'ont pratiquement pas de limite de requêtes, mais elles restent en cache jusqu'à 5 minutes (2 sur un plan Enterprise), et une modification publiée peut mettre ce délai à apparaître. Un build lancé juste après une publication peut donc lire l'ancien contenu : lisez api.webflow.com au build, ou retardez la reconstruction. Les images restent alors sur le CDN de Webflow : déclarez son domaine dans image.remotePatterns pour qu'Astro les optimise. Pour republier à chaque modification, un webhook Webflow (déclenché à la publication) appelle l'adresse de reconstruction (« deploy hook ») de l'hébergeur.

Étape 7 · Les interactions et le JavaScript

Les interactions Webflow ne survivent pas à la reconstruction. Elles dépendent du fichier webflow.js, de jQuery, et d'attributs posés par Webflow sur chaque élément (data-w-id, et data-wf-page / data-wf-site sur la balise <html>). Dès que le HTML est réécrit en composants, ces attributs disparaissent.

La bonne nouvelle : GSAP, la bibliothèque d'animation sur laquelle reposent les interactions récentes de Webflow, est gratuite depuis le 30 avril 2025, plugins compris, y compris pour un usage commercial, grâce à son rachat par Webflow. La seule interdiction de sa licence : construire un outil d'animation visuelle sans code qui concurrence Webflow.

L'équivalent, élément par élément :

Élément WebflowSur Astro
Apparition au défilementCSS + IntersectionObserver (quelques lignes), ou GSAP ScrollTrigger
Séquence liée au défilementGSAP ScrollTrigger. Les animations CSS liées au défilement (animation-timeline) ne marchent pas encore dans tous les navigateurs : en bonus seulement
Menu mobile (Navbar)Un bouton et une vingtaine de lignes de script, ou <details>
Menu déroulant<details> / <summary>, ou un bouton avec aria-expanded
Onglets (Tabs)Un script de quelques lignes avec les rôles ARIA tablist / tab / tabpanel
SliderDéfilement CSS scroll-snap, ou une bibliothèque (Swiper, Embla)
LightboxL'élément <dialog>, ou une bibliothèque (PhotoSwipe, GLightbox)
Liste de collectiongetCollection() (étape 6)
Paginationpaginate() (étape 6)
Filtres Finsweet (List Filter, List Load, List Nest ; anciennement CMS Filter, Load More, Nest)Les listes se préparent au build ; le filtre devient un script de quelques lignes ou un composant interactif (« island »)
Recherche du sitePagefind (ci-dessous)
Vidéo d'arrière-plan<video autoplay muted loop playsinline>
Lottielottie-web ou @lottiefiles/dotlottie-web
Transitions entre pagesLes transitions natives du navigateur en CSS (@view-transition { navigation: auto; }), sans effet là où elles ne sont pas prises en charge, ou le <ClientRouter /> d'Astro

Une animation GSAP dans un composant Astro. Une balise <script> sans attribut est traitée par Astro : TypeScript, imports npm, regroupement en un fichier. Le script ne se charge qu'une fois par page, même si le composant est utilisé dix fois.

Astro
<div data-anim="fade-up">…</div>

<script>
  import { gsap } from 'gsap';
  import { ScrollTrigger } from 'gsap/ScrollTrigger';
  gsap.registerPlugin(ScrollTrigger);

  if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
    gsap.utils.toArray<HTMLElement>('[data-anim="fade-up"]').forEach((el) => {
      gsap.from(el, {
        y: 40, opacity: 0, duration: 0.8, ease: 'power2.out',
        scrollTrigger: { trigger: el, start: 'top 85%' },
      });
    });
  }
</script>
  • Le respect de prefers-reduced-motion coupe les animations pour les visiteurs qui l'ont demandé dans leur système.
  • Si vous ajoutez le <ClientRouter /> (navigation sans rechargement), les scripts ne s'exécutent plus qu'une fois pour toute la visite : placez le code dans un écouteur document.addEventListener('astro:page-load', …), et nettoyez les animations avant chaque changement de page (ScrollTrigger.getAll().forEach((t) => t.kill()) sur astro:before-swap).
  • Un script qui doit rester tel quel (code fourni par un outil tiers) prend l'attribut is:inline.
  • Le défilement doux (Lenis), que ma stack utilise : pendant une migration, ne l'ajoutez que si le site Webflow en avait déjà un (on reproduit d'abord). Synchronisez-le avec ScrollTrigger comme l'indique sa documentation : lenis.on('scroll', ScrollTrigger.update), gsap.ticker.add((t) => lenis.raf(t * 1000)) et gsap.ticker.lagSmoothing(0). Depuis sa version 1.3.26 (août 2026), Lenis respecte seul prefers-reduced-motion ; les animations GSAP, elles, se coupent avec gsap.matchMedia().

Reproduire une animation à l'identique. ZenML a filmé chaque animation du site Webflow, l'a découpée image par image, et l'a réécrite d'après ces images. C'est plus fiable que de lire la configuration des interactions dans le Designer.

La recherche interne avec Pagefind. Pagefind indexe les pages construites et ne pèse qu'une centaine de kilo-octets pour la plupart des sites. Dans package.json : "build": "astro build && npx pagefind --site dist/client" avec l'adaptateur (le HTML y est rangé), ou --site dist dans la variante minimale. Puis, sur la page de recherche :

Astro
<link href="/pagefind/pagefind-ui.css" rel="stylesheet" />
<script is:inline src="/pagefind/pagefind-ui.js"></script>
<div id="search"></div>
<script is:inline>
  window.addEventListener('DOMContentLoaded', () => new PagefindUI({ element: '#search' }));
</script>

Cet extrait utilise l'interface classique de Pagefind, toujours prise en charge ; depuis la version 1.5 (avril 2026), Pagefind recommande ses nouveaux composants (<pagefind-modal>). L'index n'existe qu'après un build : la recherche ne fonctionne pas en mode développement.

Étape 8 · Formulaires, scripts tiers et intégrations

Les formulaires s'arrêtent à la seconde où le site quitte Webflow : ils envoient leurs données à Webflow. Chacun a besoin d'un nouveau destinataire.

Ce que je recommande, avec l'adaptateur : une Astro Action et Resend. Le formulaire est un vrai <form method="POST">, qui fonctionne sans JavaScript. La page /contact est la seule rendue à la demande : elle valide les champs, écarte les robots, et envoie l'e-mail par l'API de Resend. Pas de service de formulaires tiers.

Testé sur une copie du site de démonstration, sur le serveur local de Cloudflare : formulaire absent sans les secrets (lien mailto: à la place), champ piège, envoi trop rapide, champs invalides, clé Resend refusée, envoi depuis un autre site (refusé, 403). Seul l'envoi réel d'un e-mail n'a pas été testé.

Les trois secrets, déclarés dans astro.config.mjs :

JavaScript
import { defineConfig, envField } from 'astro/config';

// dans defineConfig({ … }) :
env: {
  schema: {
    // Secrets du Worker, lus à chaque requête par /contact : les poser active le formulaire sans reconstruire le site.
    RESEND_API_KEY: envField.string({ context: 'server', access: 'secret', optional: true }),
    CONTACT_FORM_FROM: envField.string({ context: 'server', access: 'secret', optional: true }),
    CONTACT_FORM_RECIPIENT: envField.string({ context: 'server', access: 'secret', optional: true }),
  },
},

Le traitement (src/actions/index.ts) :

TypeScript
// src/actions/index.ts : le traitement du formulaire de contact, côté serveur.
import { defineAction } from 'astro:actions';
import { z } from 'astro/zod';
import { RESEND_API_KEY, CONTACT_FORM_FROM, CONTACT_FORM_RECIPIENT } from 'astro:env/server';

const MIN_ELAPSED_MS = 3000; // plus rapide que ça, ce n'est pas un humain qui lit la page

const schema = z.object({
  nom: z.string().trim().min(1).max(256),
  email: z.string().trim().email().max(256),
  message: z.string().trim().min(10).max(5000),
});

export type ContactState = { status: 'success' | 'error' | 'invalid'; fields?: string[] };

export const server = {
  contact: defineAction({
    accept: 'form',
    handler: async (form: FormData): Promise<ContactState> => {
      // 1. Les robots : un champ caché qu'un humain ne remplit jamais, et un délai minimal.
      //    On leur répond « envoyé » : un robot à qui l'on dit non s'adapte.
      const startedAt = Number(form.get('startedAt') ?? 0);
      if (String(form.get('website') ?? '') !== '' || !(Date.now() - startedAt >= MIN_ELAPSED_MS)) {
        return { status: 'success' };
      }

      // 2. La validation côté serveur, la seule qui compte.
      const parsed = schema.safeParse(Object.fromEntries(form));
      if (!parsed.success) {
        return { status: 'invalid', fields: parsed.error.issues.map((i) => String(i.path[0])) };
      }

      // 3. Les trois secrets, ou rien.
      if (!RESEND_API_KEY || !CONTACT_FORM_FROM || !CONTACT_FORM_RECIPIENT) return { status: 'error' };

      // 4. L'envoi : un seul appel à l'API de Resend, sans SDK.
      const { nom, email, message } = parsed.data;
      const res = await fetch('https://api.resend.com/emails', {
        method: 'POST',
        headers: { Authorization: `Bearer ${RESEND_API_KEY}`, 'Content-Type': 'application/json' },
        body: JSON.stringify({
          from: CONTACT_FORM_FROM,
          to: [CONTACT_FORM_RECIPIENT],
          reply_to: [email],                            // répondre au contact = un clic sur « Répondre »
          subject: `Nouveau message : ${nom}`.replace(/[\r\n]+/g, ' ').slice(0, 150),
          text: `Nom : ${nom}\nE-mail : ${email}\n\n${message}`,
        }),
      }).catch(() => null);
      if (!res?.ok) {
        console.error('[contact] Resend a répondu', res?.status, await res?.text()); // le visiteur ne voit jamais l'erreur interne
        return { status: 'error' };
      }
      return { status: 'success' };
    },
  }),
};

La page (src/pages/contact.astro), avec la ligne qui la rend dynamique :

Astro
---
export const prerender = false; // la seule page rendue à la demande
import { actions } from 'astro:actions';
import { RESEND_API_KEY, CONTACT_FORM_FROM, CONTACT_FORM_RECIPIENT } from 'astro:env/server';
import Layout from '../layouts/Layout.astro';

const configured = Boolean(RESEND_API_KEY && CONTACT_FORM_FROM && CONTACT_FORM_RECIPIENT);
const state = Astro.getActionResult(actions.contact)?.data;
const startedAt = Date.now(); // l'heure d'affichage, relue à l'envoi
---
<Layout title="Contact" description="Écrivez-nous">
  {!configured ? (
    <p>Écrivez-nous à <a href="mailto:contact@exemple.com">contact@exemple.com</a>.</p>
  ) : state?.status === 'success' ? (
    <p role="status">Merci, votre message est bien parti.</p>
  ) : (
    <form method="POST" action={actions.contact}>
      {state?.status === 'error' && <p role="alert">L'envoi a échoué. Écrivez-nous à <a href="mailto:contact@exemple.com">contact@exemple.com</a>.</p>}
      {state?.status === 'invalid' && <p role="alert">Vérifiez les champs : {state.fields?.join(', ')}.</p>}
      <label for="nom">Nom</label>
      <input id="nom" name="nom" autocomplete="name" required />
      <label for="email">E-mail</label>
      <input id="email" name="email" type="email" autocomplete="email" required />
      <label for="message">Message</label>
      <textarea id="message" name="message" required></textarea>
      <!-- Le piège : invisible, hors du parcours clavier -->
      <div aria-hidden="true" style="position:absolute;left:-9999px">
        <label>Site web <input name="website" tabindex="-1" autocomplete="off" /></label>
      </div>
      <input type="hidden" name="startedAt" value={startedAt} />
      <button type="submit">Envoyer</button>
    </form>
  )}
</Layout>

Les secrets se posent sur le Worker, pas dans le code : npx wrangler secret put RESEND_API_KEY (et les deux autres), ou Settings > Variables and Secrets dans le tableau de bord. Aucun build n'est nécessaire. En local, un fichier .dev.vars (jamais commité) les fournit à wrangler dev.

Le domaine d'envoi chez Resend. Tant qu'il n'est pas vérifié, l'adresse de test onboarding@resend.dev n'envoie qu'à l'adresse du compte Resend. Pour envoyer depuis le domaine du site, Resend demande d'ajouter les enregistrements affichés dans son onglet Records : en général une clé DKIM (TXT resend._domainkey), plus un SPF (TXT) et un MX sur le sous-domaine d'envoi send. Pour un domaine ajouté depuis août 2026, Resend peut proposer des CNAME à la place : sur Cloudflare, laissez-les en « DNS only » (nuage gris), sinon la vérification échoue. On ajoute des enregistrements, on n'en modifie aucun (sauf si send en porte déjà), et le courrier du domaine ne bouge pas.

Les alternatives, sans adaptateur (variante minimale) :

SolutionGratuit jusqu'à (20/10/2026)Quand
Netlify FormsIllimité sur les plans à créditsSi le site est sur Netlify : un attribut sur le formulaire suffit
Web3Forms250 envois par moisSites vitrines, sans serveur
Formspree50 envois par moisIdem, interface complète
HubSpot, Brevo, votre CRMSelon l'outilSi les contacts doivent arriver directement dans un CRM
Astro Actions + un service d'e-mailSelon l'hébergeurLogique sur mesure ; demande une page rendue à la demande, donc un adaptateur

Contre le spam, en renfort du champ piège : Cloudflare Turnstile est gratuit (jusqu'à 20 widgets, défis illimités). Sur Web3Forms, il est réservé à l'offre payante.

HTML
<!-- Netlify : le formulaire est détecté au build. Activez « Form detection » dans le tableau de bord. -->
<form name="contact" method="POST" data-netlify="true" action="/merci">
  <label for="email">E-mail</label>
  <input id="email" type="email" name="email" required />
  <label for="message">Message</label>
  <textarea id="message" name="message" required></textarea>
  <button type="submit">Envoyer</button>
</form>

<!-- Formspree, Web3Forms, Basin : seule l'adresse d'envoi change -->
<form action="https://formspree.io/f/VOTRE_IDENTIFIANT" method="POST">…</form>

Le test qui compte : un formulaire peut afficher « Merci, c'est envoyé » alors que rien n'arrive nulle part. On teste toute la chaîne : validation, réception, notification, arrivée dans le CRM, événement de conversion dans Google Tag Manager.

Les règles pour les formulaires :

  • mêmes champs, même destinataire, même page ou même message de remerciement qu'avant ;
  • les pages de remerciement portent un noindex et sortent du sitemap ;
  • les fichiers joints : vérifiez que le service choisi les accepte (le plan gratuit de Formspree, par exemple, n'en prend pas).

Les scripts tiers (Google Tag Manager, analytics, chat, bandeau cookies, Calendly) :

  • copiez le code d'origine tel quel depuis les réglages de Webflow, dans le gabarit commun, avec is:inline ;
  • les intégrations natives relevées à l'étape 1 n'ont pas de code à copier : posez leur balise officielle dans le gabarit commun, avec le même identifiant. Ouvrez aussi les déclencheurs de Google Tag Manager : ceux qui visent le formulaire Webflow (classe .w-form-done, envoi sans rechargement) ne se déclencheront plus. Réécrivez-les sur la page ou le message de remerciement du nouveau formulaire ;
  • ⚠️ Partytown (qui déplace les scripts tiers hors du fil principal) a provoqué chez StackOne une boucle infinie avec le bandeau cookies et des sessions perdues dans GA4. Ils sont revenus à Google Tag Manager chargé normalement. Ne l'ajoutez pas pendant une migration ;
  • ⚠️ les adresses d'aperçu ne doivent pas envoyer de données à vos outils de mesure (Google Analytics, GTM et les scripts repris tels quels) : ZenML a vu ses analytics se déclencher depuis chaque aperçu, parce que la variable import.meta.env.PROD vaut true aussi sur les aperçus. Conditionnez les scripts de mesure à une variable propre à la production.

Les outils branchés : Memberstack fonctionne avec n'importe quel site JavaScript, il se rebranche. Jetboost ne fonctionne que sur Webflow : à remplacer. Weglot peut rester, ou céder la place à de vraies pages traduites (Omnia l'a remplacé par 315 fichiers traduits et des adresses espagnoles propres).

Étape 9 · Le référencement technique

Le composant d'en-tête (src/components/seo/BaseHead.astro), utilisé par toutes les pages :

Astro
---
// src/components/seo/BaseHead.astro
import { Font } from 'astro:assets';

interface Props {
  title: string;
  description: string;
  image?: string;     // image de partage : un chemin servi par le site (/og/accueil.jpg dans public/, ou une adresse produite par getImage())
  noindex?: boolean;  // pour une page précise (ex. /merci)
  alternates?: { hreflang: string; href: string }[]; // les versions de la page dans chaque langue (« Les langues », plus bas)
  ogTitle?: string;   // ces trois-là : seulement pour les exceptions relevées au crawl de départ
  ogDescription?: string;
  canonical?: string; // si la canonical de Webflow visait une autre adresse
}
const { title, description, image = '/og-default.jpg', noindex = false, alternates = [], ogTitle = title, ogDescription = description } = Astro.props;
// Avec build.format: 'file', le chemin vu au build finit par .html (/blog.html, /index.html) :
// on le retire, sinon la canonical contredit le sitemap et les adresses Webflow.
const pathname = Astro.url.pathname.replace(/(\/index)?\.html$/, '') || '/';
const canonical = new URL(Astro.props.canonical ?? pathname, Astro.site); // toujours le domaine définitif
const ogImage = new URL(image, Astro.site);
// noindex uniquement là où on le demande explicitement : une variable oubliée
// laisse une préproduction indexable (réparable), jamais une production invisible.
const isPreview = import.meta.env.PUBLIC_DEPLOY_ENV === 'preview';
---
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />
{alternates.map((a) => <link rel="alternate" hreflang={a.hreflang} href={new URL(a.href, Astro.site)} />)}
{(noindex || isPreview) && <meta name="robots" content="noindex, nofollow" />}
<meta property="og:type" content="website" />
<meta property="og:title" content={ogTitle} />
<meta property="og:description" content={ogDescription} />
<meta property="og:url" content={canonical} />
<meta property="og:image" content={ogImage} />
<meta name="twitter:card" content="summary_large_image" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="sitemap" href="/sitemap-index.xml" />
<Font cssVariable="--font-inter" preload />

Un piège trouvé en testant ce guide.

Avec build.format: 'file', le chemin qu'Astro voit pendant le build se termine par .html. Le code de canonical que l'on trouve partout (new URL(Astro.url.pathname, Astro.site)) produit alors https://www.exemple.com/blog/mon-article.html et https://www.exemple.com/index.html, alors que le sitemap annonce /blog/mon-article et /. La ligne pathname ci-dessus corrige ce décalage. Vérifiez la canonical de trois pages avant la bascule.

Ce qu'on reproduit à l'identique, page par page (comparé au crawl de l'étape 1) :

  • le titre et la description ;
  • un seul H1, et une hiérarchie de titres qui se suit. Sur Webflow, un composant peut avoir l'air d'un titre sans en porter la balise : le problème est sémantique, pas visuel ;
  • l'image de partage, propre à chaque article si le gabarit de collection de Webflow la liait à une image du CMS (le code de l'étape 6 le fait) ;
  • les exceptions relevées au crawl : un titre ou une description Open Graph différents du titre et de la description SEO, une canonical qui pointe vers une autre adresse. Le composant d'en-tête a pour cela les propriétés ogTitle, ogDescription et canonical : ne les remplissez que pour ces pages. Si la description de Webflow venait d'un autre champ que summary, changez ce champ dans la page de l'article ;
  • les données structurées (JSON-LD) qui existaient, souvent cachées dans le code personnalisé des pages ;
  • les textes alternatifs des images ;
  • les liens internes : de vrais liens <a href>, jamais des éléments cliquables en JavaScript.

Le sitemap. @astrojs/sitemap produit sitemap-index.xml (et sitemap-0.xml), là où Webflow servait /sitemap.xml. Deux gestes :

  • une redirection /sitemap.xml vers /sitemap-index.xml (étape 10) ;
  • un public/robots.txt qui reprend les règles de celui de Webflow (relevé à l'étape 1) et annonce la nouvelle adresse du sitemap. Sans règle particulière chez Webflow, ceci suffit :
Texte
User-agent: *
Allow: /

Sitemap: https://www.exemple.com/sitemap-index.xml

Si Webflow servait un /llms.txt, copiez-le dans public/llms.txt : il garde son adresse. Si l'adresse du sitemap y figure, remplacez-la par /sitemap-index.xml.

Un détail : une date de modification (lastmod) identique sur toutes les pages, égale au jour du build, n'apporte rien. Webflow publiait la vraie date de chaque élément : reprenez-la. Le script de l'étape 6 l'écrit dans migration/lastmod.json, et l'intégration la lit :

JavaScript
// astro.config.mjs : la vraie date de chaque adresse du CMS, écrite par le script de l'étape 6
import lastmod from './migration/lastmod.json' with { type: 'json' };
const dates = new Map(Object.entries(lastmod));

// dans integrations, à la place de la ligne sitemap de l'étape 3 :
sitemap({
  filter: (page) => !page.endsWith('/merci'),
  serialize: (item) => {
    const date = dates.get(new URL(item.url).pathname);
    return date ? { ...item, lastmod: date } : item; // sans date connue, pas de lastmod : mieux qu'une fausse date
  },
}),

Le flux RSS, si le blog en avait un. Webflow le sert sous le chemin de la collection, en général /blog/rss.xml, pas /rss.xml : l'adresse exacte s'affiche dans RSS Feed Settings, dans les réglages du gabarit de collection. Le fichier reproduit ce chemin. Comparez ensuite les deux flux : chaque <guid> doit rester identique à celui de Webflow (l'adresse de l'article, sans barre finale), sinon les lecteurs de flux et les envois d'e-mails automatiques traitent tous les articles comme nouveaux. Gardez aussi, sur les pages du blog, la balise <link rel="alternate" type="application/rss+xml" href="/blog/rss.xml"> que Webflow posait. ⚠️ @astrojs/rss ajoute une barre finale aux liens par défaut, quel que soit le réglage du site :

JavaScript
// src/pages/blog/rss.xml.js : la même adresse que le flux Webflow (/blog/rss.xml)
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';

export async function GET(context) {
  const posts = await getCollection('blog');
  return rss({
    title: 'Blog',
    description: 'Les articles',
    site: context.site,
    trailingSlash: false, // sinon le flux ajoute un / que le site ne sert pas
    items: posts.map((post) => ({ title: post.data.name, pubDate: post.data.publishedOn, link: `/blog/${post.id}` })),
  });
}

La page 404 (src/pages/404.astro) : un vrai code 404, un lien vers l'accueil, un noindex. Sur Cloudflare, avec l'adaptateur, le Worker la sert lui-même avec son code 404 ; dans la variante minimale, c'est le réglage not_found_handling: "404-page".

HTTP vers HTTPS, et HSTS. Webflow redirige chaque adresse http:// vers https:// par une 301, et peut envoyer un en-tête HSTS. Un Worker, lui, répond aussi en HTTP si rien ne l'en empêche : sur le site de démonstration, http:// répond 200. Avant la bascule, activez sur la zone SSL/TLS > Edge Certificates > Always Use HTTPS, puis vérifiez le jour J : curl -sI http://www.exemple.com/une-page doit répondre 301 vers https://www.exemple.com/une-page. Pour HSTS, relevez l'en-tête actuel (curl -sI https://www.exemple.com | grep -i strict) et, s'il existe, reproduisez-le à l'identique dans la zone (même écran) : en général max-age=31536000 (12 mois), sans includeSubDomains ni preload. Ajouter ces deux options pendant la migration rendrait inaccessible tout sous-domaine qui n'a pas HTTPS.

Les autres en-têtes de sécurité et les mentions légales. Webflow posait lui-même certains en-têtes de sécurité : relevez-les avec le même curl -sI. Ceux posés dans public/_headers ne valent que pour les fichiers statiques, pas pour les réponses du Worker (/contact) : celles-ci passent par un middleware Astro. En France, les mentions légales nomment l'hébergeur : remplacez Webflow par Cloudflare, et ajoutez les nouveaux sous-traitants (Cloudflare, Sanity, Resend) à la politique de confidentialité.

Les langues. Si Webflow sert la langue principale à la racine et les autres dans un sous-dossier (/fr/...), Astro fait de même :

JavaScript
// dans astro.config.mjs
i18n: {
  defaultLocale: 'en',
  locales: ['en', 'fr'],
  routing: {
    prefixDefaultLocale: false, // l'anglais reste à la racine, le français sous /fr/
    fallbackType: 'rewrite',    // seulement si Webflow servait déjà ces adresses /fr/ non traduites (voir ci-dessous)
  },
  fallback: { fr: 'en' },
},

Ne gardez fallback et fallbackType: 'rewrite' que si le crawl de départ montre que Webflow servait déjà ces adresses /fr/… non traduites. Sinon, retirez ces deux réglages : Astro construirait une page en anglais sous /fr/ pour chaque page non traduite, des adresses qui n'existaient pas et que Google traite comme des doublons.

Puis les balises hreflang, que Webflow Localization écrivait seul, dans l'en-tête de chaque page et dans son sitemap. Astro n'en écrit aucune. Trois règles :

  • Les mêmes codes que Webflow (fr, pas fr-FR, si le crawl de départ montre fr) et la même cible pour x-default. L'attribut lang de la balise <html> suit la langue de la page : c'est la propriété lang du gabarit commun (étape 5).
  • Une traduction se retrouve par son identifiant Webflow, jamais par son slug. Webflow peut traduire les slugs, mais toutes les versions d'un élément partagent le même identifiant : c'est le champ webflowId que le script écrit dans chaque fichier. Une page ne liste que les traductions publiées, et chacune liste toutes les autres, elle-même comprise : sans lien retour, Google ignore les balises.
  • Le sitemap suit la même table, ou ne porte pas de hreflang. L'option i18n de @astrojs/sitemap apparie les adresses qui ont le même chemin après le préfixe de langue : avec des slugs traduits, elle produit des paires fausses. Dans ce cas, ne l'activez pas : les balises de l'en-tête suffisent, Google tient les deux méthodes pour équivalentes.
Astro
---
// src/pages/blog/[slug].astro (langue principale) : à ajouter au code de l'étape 6
const fr = (await getCollection('blogFr')).find((t) => t.data.webflowId === post.data.webflowId);
const alternates = [
  { hreflang: 'en', href: `/blog/${post.id}` },
  ...(fr ? [{ hreflang: 'fr', href: `/fr/blog/${fr.id}` }] : []), // seulement si la traduction est publiée
  { hreflang: 'x-default', href: `/blog/${post.id}` },             // la même cible que chez Webflow
];
---
<Layout lang="en" alternates={alternates} …>  <!-- en plus des propriétés de l'étape 6 -->

Le composant d'en-tête écrit une balise par entrée, en adresse absolue comme Google l'exige (propriété alternates). La page française fait la même recherche dans l'autre sens ; pour les pages statiques, écrivez la table à la main, d'après le crawl de départ. L'équipe d'easy.tools a trouvé 40 erreurs de hreflang à son audit d'avant mise en ligne : comparez les balises de chaque page au crawl de départ, ligne par ligne. Ce code a été testé sur un projet Astro 7.4.1 à deux langues, pas sur le site de démonstration, qui n'en a qu'une.

Étape 10 · Les redirections

Avec l'adaptateur (la stack recommandée) : la table redirects d'Astro. L'adaptateur Cloudflare l'écrit lui-même dans _redirects, en vraies 301 servies avant le Worker :

JavaScript
// astro.config.mjs : une ligne par ancienne adresse, vers sa destination finale
redirects: {
  '/sitemap.xml': { status: 301, destination: '/sitemap-index.xml' },
  '/actualites/visite-de-chantier': { status: 301, destination: '/blog/visite-de-chantier' },
  '/projets': { status: 301, destination: '/' }, // la liste des projets vit maintenant sur l'accueil : même contenu, pas un renvoi par défaut
},

⚠️ Un piège trouvé en testant ce guide : pas de joker dans la table redirects. Une règle dynamique ('/actualites/[slug]' vers '/blog/[slug]') est réécrite par l'adaptateur vers /blog/:slug.html, que Cloudflare redirige ensuite vers /blog/:slug : une chaîne de deux redirections (301 puis 307), mesurée le 21/10/2026 avec l'adaptateur 14.4.1. Avec build.format: 'directory', la destination prend /index.html : même chaîne. Si vous avez besoin de jokers, gardez plutôt toutes vos redirections dans public/_redirects, sans table redirects (l'adaptateur le recopie tel quel) : c'est ce que fait le site de démonstration. L'important est une seule source. Les règles écrites adresse par adresse sortent propres. C'est de toute façon ce que donne l'inventaire de l'étape 1 : une ligne par adresse.

Variante minimale (sans adaptateur) : le fichier de l'hébergeur. Pour un site statique sans adaptateur, la configuration redirects d'Astro produit des pages HTML avec une redirection « meta refresh », sans vrai code 301. Google les traite comme permanentes, mais il recommande les redirections côté serveur quand c'est possible. Et ne mélangez pas les deux mécanismes : avec l'adaptateur, les règles d'Astro s'ajoutent à la fin d'un public/_redirects écrit à la main, qui passe donc en premier, sans avertissement.

HébergeurFichierCode par défautLimites
Cloudflarepublic/_redirects⚠️ 302 : écrivez 301 sur chaque ligne2 000 règles fixes + 100 règles dynamiques, y compris les lignes que l'adaptateur écrit depuis la table redirects ; au-delà, les Bulk Redirects de la zone
Netlifypublic/_redirects301Pas de limite stricte ; au-delà de 10 000, utilisez des jokers
Vercelvercel.jsonpermanent: true donne 308 ; statusCode: 301 est accepté2 048 règles dans vercel.json ; au-delà, les Bulk Redirects (import CSV, plans Pro et Enterprise). ⚠️ Ils sont en 307 et perdent les paramètres de l'adresse (query string) par défaut : mettez permanent: true (ou statusCode: 301) et preserveQueryParams: true sur chaque ligne
Texte
# public/_redirects (Cloudflare et Netlify)
# L'ordre compte : les règles sont lues de haut en bas.
/sitemap.xml          /sitemap-index.xml   301
/ancienne-page        /nouvelle-page       301
/blog-old/*           /blog/:splat         301

Reprendre les redirections Webflow existantes. Webflow écrit ses jokers /ancien-dossier/(.*) vers /nouveau/%1. Dans _redirects, cela devient /ancien-dossier/* vers /nouveau/:splat. Une conversion automatique du CSV exporté (deux colonnes : ancienne adresse, nouvelle adresse) :

Terminal
tail -n +2 webflow-redirects.csv | tr -d '\r' \
  | awk -F',' '{ src=$1; dst=$2; gsub(/\(\.\*\)/, "*", src); gsub(/%/, "", src); gsub(/%1/, ":splat", dst); print src, dst, 301 }' \
  >> public/_redirects

Les règles Webflow qui utilisent plusieurs groupes (%1, %2) se réécrivent à la main. Le script retire aussi les % que Webflow place devant les caractères spéciaux de l'ancienne adresse (/old%-folder/), et suppose qu'aucun champ du CSV ne contient de virgule : relisez le fichier avant. Avec l'adaptateur, la même liste devient des lignes de la table redirects, sans joker.

Les règles :

  • une ligne par adresse qui change ; dans _redirects, les cas particuliers d'abord, les règles générales (tout un dossier) ensuite : la première règle qui correspond l'emporte ;
  • jamais de chaîne : une ancienne adresse va directement à sa destination finale, même si elle passait déjà par une redirection Webflow ;
  • jamais de redirection en masse vers l'accueil : Google la traite comme une page introuvable (« soft 404 ») ;
  • une page supprimée renvoie un 404, pas une redirection vers une page sans rapport. Google traite tous les codes 4xx de la même façon (sauf le 429) : un 404 vaut un 410, inutile d'ajouter une page à la demande pour émettre un 410 ;
  • les liens internes du nouveau site pointent directement vers les nouvelles adresses, jamais vers une redirection ;
  • on garde les redirections au moins un an, et de préférence pour toujours. C'est la recommandation de Google.

Étape 11 · Rendre la main à l'équipe

Si l'équipe ne sait plus publier, la migration a échoué, même avec un site plus rapide. Finn Dollimore, en pleine migration vers Astro et Sanity, le résume en une phrase : le plus gros défi d'une sortie de Webflow, c'est que le client perd l'édition visuelle.

Ce qu'il ne faut pas faire : reconstruire l'Editor de Webflow soi-même. Il a montré un outil maison construit par son frère (canevas des pages, clic pour modifier, bouton de publication), et en a tiré un prototype réduit pour un client : ça fonctionne, mais c'est trop complexe à maintenir. Sa conclusion : « Pourquoi réinventer la roue ? » L'édition visuelle de Sanity (outil Presentation) fait déjà le clic pour modifier, l'aperçu en direct et la composition de pages. ⚠️ Elle demande une route d'aperçu rendue à la demande, en plus de /contact.

Les garde-fous, quel que soit l'outil :

  • Des blocs prédéfinis, pas une page blanche. L'équipe compose ses pages à partir de la liste fermée des sections (étape 5), aux couleurs et typographies verrouillées. Dans Sanity, c'est un champ « page builder » qui n'accepte que ces sections. Elle garde la liberté de Webflow sans pouvoir sortir de la charte. Sans ce garde-fou, les pages ajoutées au fil des mois, à la main ou par une IA, dérivent jusqu'à ne plus ressembler à la marque ;
  • des champs obligatoires : texte alternatif des images, titre et description de référencement ;
  • des rôles : qui peut publier, qui peut seulement proposer ;
  • un aperçu avant publication, sur une adresse de préproduction ;
  • la republication automatique : chaque publication dans le CMS déclenche un build (webhook du CMS vers l'adresse de reconstruction de l'hébergeur). Sur Cloudflare : votre Worker > Settings > Builds > Deploy Hooks crée l'adresse, à garder secrète (elle déclenche un build) ; dans Sanity, manage > API > Webhooks l'appelle sur création, modification et suppression, sans filtre de type (un type oublié n'arrive jamais sur le site). Publiez un document et regardez le build partir. Comptez de quelques secondes à quelques minutes avant que la modification soit en ligne, selon la taille du site, et prévenez l'équipe : c'est la seule vraie différence avec le bouton « Publier » de Webflow ;
  • un guide d'une page, avec captures d'écran ;
  • une liste « qui possède quoi » : le registrar du domaine, la zone DNS, le compte Cloudflare, le dépôt GitHub, le projet Sanity, le compte Resend, les propriétaires Search Console et des outils de mesure. Tout au nom du client, avec au moins deux administrateurs ;
  • les sauvegardes et les mises à jour : un export régulier du contenu (sanity datasets export pour Sanity), et une personne nommée pour les mises à jour d'Astro et des dépendances (partie 1.2).

Le test qui compte : une personne de l'équipe publie un article, seule, sans aide, avant la mise en ligne.

Étape 12 · La recette, avant la bascule

La préproduction est en noindex (variable PUBLIC_DEPLOY_ENV=preview). Tout ce qui suit se teste sur elle.

Chaque adresse de l'inventaire répond. Ce script lit la liste des chemins (un par ligne) et signale tout ce qui n'est pas une page, ou une seule redirection permanente vers une page :

Terminal
#!/usr/bin/env bash
# verifier-urls.sh : teste chaque adresse de l'inventaire comme un navigateur, et suit chaque redirection jusqu'au bout
# Usage : ./verifier-urls.sh https://preview.mon-site.workers.dev migration/urls.txt
BASE="$1"; LIST="$2"
NAV=(-H 'Sec-Fetch-Mode: navigate')   # l'en-tête d'un navigateur : sans lui, curl ne voit pas le piège de not_found_handling (étape 3)
while IFS= read -r p; do
  [ -z "$p" ] && continue
  read -r code target < <(curl -s -o /dev/null "${NAV[@]}" -w '%{http_code} %{redirect_url}\n' "$BASE$p")
  case "$code" in
    200) ;;                                                     # la page répond : rien à signaler
    301|308)                                                    # on suit la redirection jusqu'à la page finale
      read -r final hops < <(curl -s -o /dev/null -L "${NAV[@]}" -w '%{http_code} %{num_redirects}\n' "$BASE$p")
      if [ "$final" = 200 ] && [ "$hops" = 1 ]; then echo "REDIRECTION  $p -> $target"
      else echo "⚠️  CHAÎNE OU DESTINATION CASSÉE  $p -> $target ($hops redirections, réponse finale $final)"; fi ;;
    *) echo "⚠️  $code  $p" ;;                                  # 404, 302, 307, 500 : à corriger
  esac
done < "$LIST"

Un 302 signalé ici est presque toujours une ligne de _redirects sans 301 sur Cloudflare (variante minimale ; la table redirects d'Astro est en 301 par défaut). Une « chaîne ou destination cassée » est une redirection dont la destination redirige encore, ou ne répond pas en 200 : faites pointer la ligne directement vers l'adresse finale. C'est souvent une ancienne redirection Webflow recopiée telle quelle à l'étape 10. Un 404 n'est normal que pour une adresse que l'inventaire a décidé de supprimer.

Ce que le test de ce guide a donné (variante minimale, build.format: 'file', Cloudflare en local puis sur le vrai réseau ; avec l'adaptateur, mêmes réponses) :

Adresse demandéeRéponse
/blog/mon-article200
/blog/mon-article/307 vers /blog/mon-article
/blog/mon-article.html307 vers /blog/mon-article
/index.html307 vers /
/sitemap.xml301 vers /sitemap-index.xml
/Blog/mon-article (majuscule)404, comme sur Webflow
/n-existe-pas404, avec la page 404 du site

Les variantes avec barre finale répondent par une redirection temporaire (307) vers l'adresse propre, en local comme sur le vrai réseau Cloudflare (vérifié sur le site de démonstration le 21/10). C'est sans conséquence tant que la canonical et tous les liens internes utilisent l'adresse sans barre, mais vérifiez le comportement sur votre hébergeur réel : les retours d'expérience se contredisent sur ce point.

La liste de la recette

Étape 13 · La bascule

Deux semaines avant, si le site part sur Cloudflare Workers : déplacer la zone DNS. Un domaine personnalisé sur Workers exige que le domaine soit géré par Cloudflare (ses serveurs de noms). Ce déplacement se fait avant la bascule, sans rien changer au site :

  • ajoutez le domaine dans Cloudflare, qui importe la zone existante, puis comparez-la ligne par ligne avec l'ancienne (MX, SPF, DKIM, DMARC, vérifications de domaine) : l'import automatique oublie des enregistrements, surtout des clés DKIM ;
  • à l'import, Cloudflare active le proxy (nuage orange) par défaut : passez en mode « DNS only » (nuage gris) les enregistrements qui pointent vers Webflow, pour que le site reste servi par Webflow, et tout ce qui ne sert pas le site : sous-domaine du serveur de mail, autodiscover, CNAME de vérification ou de signature DKIM d'outils comme HubSpot ou Resend ;
  • si DNSSEC est activé chez le registrar, désactivez-le avant de changer les serveurs de noms (retirez l'enregistrement DS) : sinon le domaine devient injoignable, site et e-mails compris. Réactivez-le une fois la zone active chez Cloudflare (DNS > Settings), en posant chez le registrar le nouvel enregistrement DS que Cloudflare affiche ;
  • changez les serveurs de noms chez le registrar, vérifiez l'envoi et la réception d'e-mails le jour même, et laissez la zone en place chez l'ancien fournisseur au moins une semaine : pendant la propagation, certains serveurs DNS la consultent encore.

Quelques jours avant :

  • baissez la durée de cache des enregistrements DNS (TTL) au moins une semaine avant, comme le recommande Google (une valeur basse prudente, de l'ordre de quelques heures ; je descends à 300 secondes), et dans tous les cas au moins aussi longtemps à l'avance que l'ancienne durée ;
  • notez (ou faites une capture de) toute la zone DNS actuelle : c'est votre plan de retour en arrière ;
  • ajoutez le site dans Bing Webmaster Tools par import depuis Search Console : il est vérifié aussitôt, et c'est la condition pour y soumettre le sitemap le jour J ;
  • gelez les publications sur Webflow, et relancez le script d'export juste avant la bascule pour récupérer les derniers contenus.

Le jour J :

  • ne changez que les enregistrements du site. Sur Workers : supprimez l'enregistrement qui pointe vers Webflow, puis ajoutez le domaine personnalisé au Worker (Settings > Domains & Routes), pour le domaine principal seulement (celui d'avant, avec ou sans www). Ailleurs : les A, AAAA et CNAME du domaine et du www. Le site déménage, le courrier ne bouge pas : MX, SPF, DKIM et DMARC restent intacts ;
  • le domaine principal reste le même qu'avant, avec ou sans www. L'autre version reçoit un enregistrement de remplacement en proxy (nuage orange : AAAA vers 100::) et une règle de redirection de la zone (Rules > Redirect Rules, pas la table redirects d'Astro) : 301, un joker qui garde le chemin (par exemple https://exemple.com/* vers https://www.exemple.com/${1}), Preserve query string activé. Une règle vers une adresse fixe enverrait toutes les pages vers l'accueil. Activez aussi Always Use HTTPS (SSL/TLS > Edge Certificates) : Webflow redirigeait seul http:// vers https://, et HSTS ne remplace pas ce réglage, puisqu'un navigateur ignore cet en-tête reçu sans HTTPS. Contrôlez : curl -sIL 'http://exemple.com/a-propos?utm_source=test' | grep -i -E '^(HTTP|location)' doit finir en 200 sur https://www.exemple.com/a-propos?utm_source=test, après des 301 uniquement, deux au plus ;
  • Webflow reste en ligne. En cas de problème, on retire le domaine du Worker et on remet l'enregistrement qui pointait vers Webflow : quelques minutes. Décidez à l'avance ce qui déclenche ce retour (pages en erreur, formulaire muet, chute de trafic), et gelez les publications tant que la question se pose ;
  • vérifiez la production avec curl : curl -sI https://www.exemple.com/une-ancienne-page doit répondre 301 et la bonne adresse, et curl -s https://www.exemple.com/ | grep -i noindex ne doit rien renvoyer. Le noindex de la préproduction qui survit en production est l'erreur qui rend un site invisible du jour au lendemain ;
  • relancez le script de l'étape 12 sur le domaine définitif, sur toute la liste : ./verifier-urls.sh https://www.exemple.com migration/urls.txt. La production ajoute ce que la préproduction n'a pas (règles de la zone, www, pare-feu, cache) : seul ce passage vérifie l'ensemble ;
  • si un CDN (par exemple Cloudflare en proxy) était placé devant Webflow, purgez son cache : Webflow envoie un en-tête qui garde les pages en cache plusieurs jours, et ZenML a servi d'anciennes pages après sa bascule jusqu'à la purge ;
  • vérifiez que le domaine personnalisé est actif chez l'hébergeur, plusieurs fois dans la journée (ZenML a vu le sien se désactiver deux fois, sur Cloudflare Pages) ;
  • une fois le domaine actif, coupez l'adresse workers.dev du Worker de production : "workers_dev": false dans wrangler.jsonc, puis redéployez (le bouton du tableau de bord ne suffit pas : Wrangler la réactive au déploiement suivant). Sinon, une copie complète du site, sans noindex, reste en ligne sur mon-site.<compte>.workers.dev : vérifiez qu'elle ne répond plus. Si votre préproduction passe par les adresses d'aperçu, ajoutez "preview_urls": true : par défaut, elles se coupent avec workers.dev ;
  • annotez la date de bascule dans Search Console (clic droit sur le graphique des Performances, Ajouter une annotation) et dans GA4 (même geste sur un graphique) ;
  • soumettez sitemap-index.xml dans Search Console et dans Bing Webmaster Tools, et retirez l'ancien /sitemap.xml de la liste des sitemaps de Search Console. Facultatif : signalez les adresses qui ont changé à Bing et aux autres moteurs du protocole IndexNow (Google n'en fait pas partie), en une seule requête ;
  • vérifiez les réglages robots de la zone Cloudflare (Security Settings > Configure AI bot policies). Une zone ajoutée à Cloudflare depuis le 15/09/2026, ce qui est votre cas si vous l'avez déplacée deux semaines avant, bloque par défaut les robots classés « Training » et « Agent » sur les pages qui affichent de la publicité, et Cloudflare précise que ce blocage touche aussi les robots qui servent à la fois à la recherche et à l'entraînement. Si votre site affiche de la publicité, choisissez vous-même ce que vous bloquez. Puis deux contrôles : curl https://votre-domaine/robots.txt (ces réglages ajoutent un bloc en tête du fichier), et surtout, dans Search Console, Inspection de l'URL sur l'accueil et deux pages importantes, puis Tester l'URL active : le résultat doit être « Google a accès à cette URL ». Un blocage par le pare-feu ne se voit pas dans le robots.txt ;
  • ⛔ n'utilisez pas l'outil « Changement d'adresse » de Search Console. Google l'écrit : il ne sert qu'à passer d'un domaine ou sous-domaine à un autre. Ici, le domaine ne change pas, seule la plateforme change.

Étape 14 · Les 30 jours après

Google prévient : pendant un déménagement, la visibilité peut fluctuer temporairement. Le travail consiste à distinguer une fluctuation d'une perte.

  • Chaque jour pendant deux semaines, puis chaque semaine pendant deux ou trois mois : dans Search Console, le rapport des pages (erreurs, pages exclues), les statistiques d'exploration, l'inspection des pages qui comptent, et la canonical choisie par Google (elle dit si Google a retenu celle que vous déclarez). Dans les Statistiques sur l'exploration, regardez la répartition par réponse : Google prévient que l'exploration baisse juste après un changement d'hébergement, puis remonte en quelques jours. Des 403 qui montent, ou une exploration qui ne remonte pas, signalent un blocage ; le journal des événements de sécurité de la zone Cloudflare, filtré sur « Googlebot », dit quelle règle l'a causé.
  • Une redirection manquante se corrige le jour où on la voit. StackOne a vu apparaître des 404 sur d'anciens slugs Webflow après sa mise en ligne ; ils ont transformé chacune en redirection 301.
  • Les 404 se voient le jour même dans l'outil de mesure : la page 404 s'affiche à l'adresse demandée. Filtrez les pages vues sur son titre (par exemple « Page introuvable ») : vous avez la liste des adresses manquantes sans attendre Search Console.
  • Comparez le trafic et les positions aux mesures de départ, page par page, pas seulement au total. Suivez à part, pendant les 30 jours, le trafic de Google Images (Performances, Type de recherche : Image) : les images ont changé d'adresse (étape 2).
  • Dans le rapport Sitemaps, le nombre de pages indexées du nouveau sitemap doit monter. Le rapport Core Web Vitals ne reflète le nouveau site qu'après 28 jours de données réelles (s'il a assez de visites) : c'est lui, pas Lighthouse, qui dit si les visiteurs y gagnent.
  • Pour les adresses qui ont changé, la redirection garde la valeur des liens, mais faites corriger les plus visités : repérez dans le rapport Liens de Search Console les sites qui pointent vers chaque ancienne adresse, et dans l'outil de mesure ceux qui envoient le plus de visites, puis écrivez aux 5 à 10 premiers. Corrigez vous-même ce que vous contrôlez : profils de réseaux sociaux, fiche Google Business Profile, signatures d'e-mail, adresses de destination des campagnes publicitaires.
  • Seulement ensuite, et jamais avant 30 jours (rapport des pages stable, aucune nouvelle 404 depuis une semaine, trafic page par page dans sa fourchette habituelle), résiliez le plan de site Webflow. Juste avant, tant que le plan est payé, coupez l'indexation du sous-domaine (Site settings > SEO > Indexing, Staging indexing sur off, puis publiez) et vérifiez https://votre-site.webflow.io/robots.txt : ce réglage demande un plan payant, et un site sans plan reste en ligne sur son adresse .webflow.io. ⚠️ Ne supprimez jamais le projet Webflow : les images et les fichiers hébergés par Webflow ne répondent que tant qu'il existe. Gardez l'export du code, l'export du CMS et les réponses aux formulaires archivés. Notez dès le début du projet la date de renouvellement du plan : un plan annuel se renouvelle seul, et Webflow ne rembourse pas la période déjà payée. La surveillance, elle, continue chaque semaine pendant deux ou trois mois.
  • Puis, et seulement puis, les améliorations : refonte, nouvelles pages, nouvelles animations. Une à la fois, chacune mesurée.

Partie 4 · Migrer avec un agent IAFaire

La plupart des migrations racontées en 2026 ont été faites avec Claude Code, parfois en quelques jours pour des sites de plusieurs centaines de pages. Les mêmes récits montrent où ça casse.

4.1 Ce que les migrations réussies avaient en commun

  • Un fichier de règles (CLAUDE.md) que l'agent relit à chaque session, et un fichier de leçons où chaque erreur coûteuse est notée pour ne pas se répéter (VGV tenait le sien).
  • Des phases avec une « définition de fini » : infrastructure, export, schémas, pages, référencement et redirections, formulaires et recette (ZenML, six phases). On ne passe pas à la suivante tant que la précédente n'est pas finie.
  • Une relecture indépendante. ZenML faisait relire chaque fin de phase par un autre modèle d'IA, et un audit du plan contre l'export a relevé sept problèmes critiques (slugs, contrôles manquants, éléments sans bloc SEO) avant qu'une seule page ne soit construite.
  • Des contrôles automatiques : build qui échoue sur un contenu invalide, captures comparées au site Webflow, crawl comparé au crawl de départ.
  • Des scripts d'extraction qu'on peut relancer, plutôt qu'une conversion faite une fois à la main (VGV).
  • Un humain qui garde le DNS et le référencement. Le skill de migration publié par Omnia s'arrête volontairement avant la bascule DNS.

4.2 Ce qui fait dérailler

  • Copier page par page, sans design system : les styles dérivent (easy.tools).
  • Demander à l'IA de déduire le design system du site en ligne : le HTML de Webflow est trop plat (Felix Dorner).
  • Pas d'aperçu, pas de relecture, pas de retour en arrière : chaque correction casse autre chose. Les récits de sites remplacés par Claude Code seul, sans ces garde-fous, décrivent la même suite : les erreurs Search Console arrivent en quelques jours.
  • Une liberté totale de l'agent après la migration : « Les méthodes de travail à base d'IA sont libres. Trop libres. » C'est la phrase du cas de retour en arrière sur Webflow (Reddit, juillet 2026 ; un témoignage dont l'authenticité a été discutée, mais dont le diagnostic fait consensus).
  • Une configuration d'outil inadaptée : dans Claude Desktop, un serveur MCP (le connecteur qui donne accès au CMS Webflow) se branche sur le compte, pas sur le projet. Pour plusieurs sites, travaillez avec Claude Code dans le dossier du projet.

4.3 Le fichier de règles à copier

À placer à la racine du projet, sous le nom CLAUDE.md :

Markdown
# Migration Webflow vers Astro : règles du projet

## Ce qui ne se négocie pas
- Aucune adresse ne change. Chaque page garde exactement son chemin Webflow (voir migration/inventaire.csv).
- On reproduit, on ne refait pas : aucune modification de design, de texte ou de structure pendant la migration.
- Aucun contenu inventé. Un texte manquant se signale dans migration/a-trancher.md, il ne s'écrit pas.
- Les couleurs, typographies et espacements viennent des tokens (src/styles/tokens.css, ou le bloc @theme de Tailwind). Aucune valeur en dur.
- Une page ne s'assemble qu'avec les composants de src/components/sections/. Un besoin nouveau est une question, pas un nouveau composant.
- Tu ne touches ni au DNS, ni à Search Console, ni aux réglages de l'hébergeur.

## Comment on travaille
- Une phase à la fois (voir migration/plan.md). Une phase est finie quand sa définition de fini est vraie, pas avant.
- Après chaque page : `npm run build`, puis une capture comparée au site Webflow aux largeurs 375, 768 et 1440 px.
- Ce qui a coûté du temps va dans migration/lecons.md. Tu le relis au début de chaque session.
- Une décision qui n'est pas technique (supprimer une page, changer une adresse, réécrire un texte) va dans migration/a-trancher.md. Tu ne la prends pas.

## Les références
- Le site en ligne : https://www.exemple.com (référence visuelle)
- L'export du code : webflow-export/ (référence, jamais importé dans src/)
- Le contenu : src/content/ (généré par scripts/export-webflow.mjs) ou le dataset Sanity de test. Ne pas l'éditer à la main avant la fin de la migration : il est régénéré.

Et migration/plan.md, les phases et leur définition de fini :

Markdown
# Plan de migration

1. Inventaire : fini quand chaque adresse a une ligne et une décision (migration/inventaire.csv).
2. Export : fini quand le nombre de fichiers égale le nombre d'éléments publiés, et qu'aucune adresse Webflow ne reste dans le contenu : `grep -rnIE "website-files\.com|webflow\.(com|io)" src public` ne renvoie rien.
3. Design system : fini quand les tokens et le gabarit commun reproduisent le site à 375, 768 et 1440 px.
4. Pages : finies quand chaque gabarit est comparé au site Webflow en capture.
5. Référencement et redirections : fini quand le crawl de la préproduction égale le crawl de départ, et que chaque ancienne adresse répond 200, ou 301 en une seule redirection vers une page en 200, ou 404 si l'inventaire l'a décidé.
6. Formulaires et recette : fini quand un e-mail de test est arrivé et que la checklist de recette est cochée.
7. Bascule : le jour J, puis 30 jours de surveillance.

4.4 Les outils utiles

  • Le serveur MCP de Webflow : l'agent lit le CMS et les pages directement. Pratique pour l'inventaire, à compléter par le script de l'étape 6 pour un export complet et reproductible.
  • Le serveur MCP de la documentation d'Astro et Context7 : l'agent écrit avec la syntaxe de la version installée, pas celle de ses souvenirs. Ça compte : Astro 6 a supprimé l'ancienne API des collections et Astro.glob().
  • Playwright : captures et tests sur trois navigateurs.
  • Un connecteur Search Console : StackOne s'en est servi pour transformer les 404 constatées après la mise en ligne en redirections.
  • Le skill de migration d'Omnia, publié en open source (github.com/useomnia/skills) : des phases avec des portes de contrôle, et les décisions renvoyées à un humain. ⚠️ Publié par une entreprise qui vend un agent : à lire comme une méthode, pas comme une garantie.

Partie 5 · Les chiffres réelsDécider

Des récits publics de 2026, écrits par les équipes elles-mêmes.

QuiTailleDuréeRésultat
ZenML (03/2026)2 224 pages, 20 collections, 2 340 éléments, 2 397 images et fichiers (384 Mo)Une semaine de soirées, avec Claude CodeLighthouse 99 sur ordinateur, 97 sur mobile. ~800 $/an économisés, « pas une raison suffisante à elle seule »
VGV (03/2026)309 pages, 204 articles, 1 189 images, 12 services tiersMoins de deux semainesAucun lien cassé, plus de 1 500 tests automatiques
StackOne (03/2026)42 gabarits, 914 pages généréesDeux semaines, à trois (deux fondateurs, une personne marketing)Lighthouse mobile de 42 à 92. Une nouvelle page : de 24-72 h à 1-2 h
easy.tools (08/2026, récit publié sur mega.dev)924 pages en deux langues, 21 collectionsNon préciséAudit d'avant lancement : 60 routes manquantes, 830 titres différents, 40 erreurs hreflang, 14 problèmes de H1 relevés. Au lancement : 920 adresses sur 920, aucun lien cassé
Omnia (10/2026)~800 pages en deux langues23 h jusqu'à la bascule DNS, ~1 200 $ d'IAImages de 396 à 138 Mo, 490 textes alternatifs écrits. Source : une entreprise qui vend un agent
Felix Dorner (04/2026)Site d'entreprise (Foxglove) sorti de Webflow Enterprise, mené par un designerEnviron un moisSans développeur, avec Claude Code, un composant à la fois

Le cas de ce guide : Atelier Nord, migré le 21 octobre 2026

Un petit site, construit sur Webflow pour ce guide puis migré en suivant les 14 étapes, d'abord dans la variante minimale (100 % statique, contenu en Markdown), puis avec la page de contact passée à la stack recommandée (adaptateur, Action + Resend ; sans clé Resend, la page affiche un lien mailto:) : 4 pages, 2 collections (articles et auteurs), 3 articles, un formulaire. Studio fictif, chiffres réels. Le site migré est en ligne : atelier-nord.astro-cf-spike.workers.dev.

PageLighthouse mobile, Webflow → AstroAffichage du contenu principal (LCP mobile)
Journal (liste des articles)87 → 984,0 s → 2,3 s
Article82 → 984,7 s → 2,1 s
Accueil98 → 972,2 s → 2,5 s
Contact (rendue à la demande)98 → 97-1002,0 s → 1,7-2,0 s
  • Les adresses : les 7 de l'inventaire répondent, aucune perdue. Trois redirections 301 en place, que l'offre gratuite de Webflow refusait.
  • Le script d'export de l'étape 6 a tourné du premier coup sur la vraie API : 5 éléments, 5 images téléchargées, la légende de l'image conservée, l'auteur et la catégorie traduits en texte.
  • La vitesse : nette sur les pages du blog, nulle sur l'accueil, que Webflow servait déjà vite. Sur un petit site bien construit, ce n'est pas la vitesse qui justifie de partir.
  • Ce qui a coûté du temps : l'inventaire. Un lien cassé côté Webflow cachait tout le blog au crawl (étape 1).

Ce que ces chiffres disent, et ce qu'ils ne disent pas :

  • Ils montrent que la construction va vite avec un agent IA bien encadré.
  • Ils ne montrent pas le temps de l'inventaire, de la recette et du suivi, que la plupart des récits ne comptent pas.
  • Aucun des récits lus ne rapporte de perte de trafic après une migration faite avec des redirections complètes. Aucun ne publie non plus de courbe de trafic sur six mois. Toute promesse de « zéro perte garantie » relève du marketing.

Partie 6 · Les 20 pièges qui reviennentDéciderFaire

  1. Migrer l'export du code au lieu du design et du contenu. L'export n'est pas un site, c'est une référence (et Astro 7 refuse son HTML s'il a des balises non fermées).
  2. Oublier une adresse hors du sitemap. Le crawl et Search Console la trouvent, le sitemap jamais.
  3. Perdre les images : elles restent sur le CDN de Webflow, et rien ne garantit qu'elles restent en ligne une fois le site supprimé. Certaines répondent déjà 403.
  4. Perdre les légendes, vidéos et blocs Embed du texte riche avec une conversion HTML vers Markdown sans règles, ou casser des centaines de fichiers en choisissant MDX.
  5. Laisser not_found_handling avec l'adaptateur : les pages à la demande (formulaire, Studio) reçoivent la 404 dans un vrai navigateur, alors que curl les voit répondre.
  6. Les formulaires qui affichent « envoyé » sans que rien n'arrive.
  7. Supprimer une page de remerciement qui servait de cible de conversion.
  8. Le noindex de préproduction qui survit en production.
  9. La canonical qui se termine par .html avec build.format: 'file'.
  10. Les redirections qui ne sont pas des 301 : une ligne de _redirects sans code (302 chez Cloudflare), ou un joker dans la table redirects d'Astro qui crée une chaîne 301 puis 307.
  11. Une chaîne de redirections, ou des dizaines d'adresses redirigées vers l'accueil.
  12. La barre finale : /page devient /page/, et toutes les adresses changent d'un coup.
  13. Changer le préfixe d'une collection (/blog/ vers /ressources/) « pendant qu'on y est ».
  14. Refaire le design en même temps : si le trafic bouge, impossible de savoir pourquoi.
  15. Construire les pages avant le design system : la marque dérive.
  16. Les langues secondaires oubliées : l'export du code ne contient que la langue principale, et les redirections Webflow ne s'appliquent pas aux adresses traduites.
  17. Les pages protégées par mot de passe, qui deviennent publiques dans un site statique si on ne les traite pas.
  18. Les analytics qui comptent les aperçus, et Partytown qui casse le bandeau cookies.
  19. Toucher aux enregistrements e-mail (MX) pendant la bascule DNS.
  20. Résilier Webflow trop tôt, avant 30 jours de surveillance et avant d'avoir tout téléchargé.

Partie 7 · La checklist complèteFaire

Décider
Inventaire
Export
Construction
Édition
Recette
Bascule
Après

Partie 8 · Glossaire

  • 301 / 308 : redirection permanente. Le moteur de recherche transfère la page vers la nouvelle adresse.
  • 302 / 307 : redirection temporaire. Le moteur garde l'ancienne adresse en mémoire.
  • 404 / 410 : page introuvable / supprimée volontairement.
  • Adaptateur : le module qui permet à Astro de fabriquer des pages à la demande chez un hébergeur donné.
  • Build : l'étape où Astro fabrique toutes les pages du site, une fois, avant leur mise en ligne.
  • Canonical : la balise qui indique au moteur de recherche l'adresse qu'on propose comme officielle pour une page (il peut en retenir une autre).
  • CDN : le réseau de serveurs qui distribue les fichiers du site au plus près des visiteurs.
  • CMS headless : un outil d'édition de contenu séparé du site, qui fournit le contenu par une API.
  • Content collection : dans Astro, un type de contenu (articles, auteurs) avec ses champs et ses éléments.
  • Crawl : le parcours automatique de toutes les pages d'un site, comme le ferait un moteur de recherche.
  • DNS : l'annuaire qui dit à quel serveur correspond un nom de domaine.
  • Enregistrement MX : la ligne du DNS qui dit où arrivent les e-mails du domaine.
  • Frontmatter : l'en-tête d'un fichier Markdown, entre deux lignes ---, qui porte les champs du contenu.
  • hreflang : la balise qui relie les versions d'une même page dans différentes langues.
  • Island : un composant interactif isolé dans une page statique.
  • Loader : dans Astro, la fonction qui va chercher le contenu d'une collection (dans des fichiers, ou dans une API).
  • MCP : le protocole qui permet de brancher un agent IA sur un outil ; un « serveur MCP » est le connecteur d'un outil précis (le CMS Webflow, Search Console).
  • noindex : la consigne qui demande aux moteurs de ne pas référencer une page.
  • Slug : la partie de l'adresse propre à une page (mon-article dans /blog/mon-article).
  • Token (design) : une valeur du design system nommée une fois (une couleur, une taille) et réutilisée partout.
  • Token (API) : la clé qui autorise un script à lire les données d'un service.
  • TTL : la durée pendant laquelle un enregistrement DNS est gardé en mémoire avant d'être relu.
  • Webhook : un message automatique qu'un outil envoie à un autre quand un événement se produit (une publication, par exemple).
  • Zod : la bibliothèque qui décrit et vérifie la forme des contenus dans Astro.

Partie 9 · Les meilleures ressources

Les récits de première main (les plus utiles, dans l'ordre)

Les références techniques

Le cas qui fait réfléchir

Les vidéos

  • Finn Dollimore, Update: Migrating from Webflow to full code (Astro) (06/2026) : pourquoi partir, et quand ne pas le faire. youtube.com/watch?v=W4ni39Hrf5I
  • Finn Dollimore, Watch this if you're migrating from Webflow to Astro (07/2026) : le problème de l'édition, et l'édition visuelle de Sanity. youtube.com/watch?v=oZMsT2i5aSI
  • Learn with Temlis, Migrate Any Webflow Site to Astro in Minutes with Claude AI (06/2026) : la conversion assistée, export + site en ligne + CMS par MCP. La démo s'arrête avant le contenu et les animations. youtube.com/watch?v=Th0xObu-TGU

Les documentations officielles

Les outils

  • @finsweet/webflow-astro (éléments natifs de Webflow en composants Astro, très jeune) · Pagefind (recherche) · Turndown (HTML vers Markdown) · GSAP et Lenis (animations, défilement) · Resend (e-mails du formulaire) · Screaming Frog (crawl) · Playwright (tests visuels)

Ce guide est mis à jour quand Astro, Webflow ou les hébergeurs changent quelque chose d'important. Si vous préparez une migration et que vous voulez un second regard sur votre inventaire, prenez 30 minutes avec moi.

Un site Webflow à migrer ?

30 minutes pour regarder votre site ensemble et voir si la migration vaut le coup. Gratuit et sans engagement.