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
- 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
noindexoublié, une canonical fausse, un titre, un texte ou des liens internes perdus. C'est le rôle des étapes 9 et 12. - 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.
- 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 :
| Poste | Prix |
|---|---|
| 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 construit | 39 $/mois (45 $ en mensuel) |
| Siège « Limited » par personne qui modifie le contenu | 15 $/mois (19 $ en mensuel) |
| Plan de Workspace nécessaire pour exporter le code | Core (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 :
| Poste | Prix |
|---|---|
| Hébergement Cloudflare | 0 $ : 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 Netlify | Gratuit 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 Vercel | Plan 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 maintenance | Le 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.
- 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.
- 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. - 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 ?
| Option | Comment on modifie le site | Pour qui | Coût (20/10/2026) |
|---|---|---|---|
| A. Fichiers Markdown dans le dépôt | Un 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, StackOne | 0 $ |
| B. CMS basé sur Git (Keystatic, Decap, TinaCMS, CloudCannon) | Une interface d'édition, mais le contenu reste des fichiers dans le dépôt | Une ou deux personnes qui publient des articles | Keystatic 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 pages | Une équipe marketing qui publie et compose des pages | Sanity : 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'API | L'équipe continue dans Webflow ; le site Astro se reconstruit à chaque publication | La 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ébergeur | Pour 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 gratuits | Le 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 applications | Rendu à 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 seul | Sur Astro, il faut le prévoir |
|---|---|
| Hébergement, CDN, certificat HTTPS | L'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ère | Git : 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-spam | Une Astro Action et un service d'e-mail, ou un service de formulaires (étape 8) |
| Recherche interne | astro:assets (étape 6). Les adresses des images changent forcément : elles quittent le domaine de Webflow (étape 2) |
| Optimisation des images | astro:assets (étape 6) |
| Sitemap automatique | @astrojs/sitemap (étape 9) |
| Gestionnaire de redirections 301 | La 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 Console | Une 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.
| Brique | Choix | Pourquoi |
|---|---|---|
| Framework | Astro 7.4, version figée (pas de ^) | Une mise à jour d'Astro ou de l'adaptateur est une décision, pas un accident |
| Hébergement | Cloudflare Workers, plan gratuit, sur le compte du client | Le 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) |
| Rendu | Tout 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 |
| Contenu | Sanity, son Studio intégré au site sur /studio | Une 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 |
| Formulaire | Astro 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 |
| Style | Tailwind 4, les variables du site Webflow en tokens | La charte reste verrouillée dans un seul fichier |
| Polices | L'API de polices d'Astro, fichiers servis par le site | Aucune adresse IP envoyée à Google |
| Images | astro:assets, optimisées au build (imageService: 'compile'), et le CDN de Sanity pour les images du CMS | Rien n'est facturé à la transformation |
| Animations | GSAP + Lenis, uniquement par des attributs data-anim ; le HTML reste complet sans JavaScript | Une animation qui casse ne casse pas la page |
| Redirections | La 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'audience | Cloudflare Web Analytics, pages vues seulement ; pendant la migration, en complément des outils existants, pas à leur place | Elle 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.
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.xmldu 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.iopeut 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 aveccurl, 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écision | Quand |
|---|---|
| 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ésindexer | Pages utiles aux visiteurs mais pas aux moteurs (remerciements, pages de test) |
Les colonnes que j'utilise pour migration/inventaire.csv, à copier dans un tableur :
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.
⚠️ 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.
- ⚠️ 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.pdfvers 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.
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.
É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).
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 turndownFigez 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 :
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.cssLa configuration (astro.config.mjs). Les trois premières lignes sont celles qui protègent vos adresses :
// @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 tailwinda 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 surwww.exemple.com, n'écrivez pasexemple.com.trailingSlashetbuild.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 aveccurlsur 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) :
// 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 :
// 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.
/* 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 | |
|---|---|---|
| Comment | On 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 classes | On réécrit le style à partir des variables, en CSS simple ou en Tailwind |
| Avantage | Identique au pixel près, vite. Les classes Client-First restent lisibles | Code propre, léger, facile à maintenir |
| Inconvénient | On embarque tout le framework CSS de Webflow, et des classes générées comme div-block-47 | Plus long. Chaque écart se corrige à la main |
| Pour qui | La priorité est « identique, vite, sans risque », puis un nettoyage progressif | Le 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) :
---
// 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 Webflow | Ce que renvoie l'API | Ce qu'il faut faire |
|---|---|---|
| Texte riche | Du 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 |
| Galerie | Une liste d'images | Idem, en liste |
| Option | L'identifiant de l'option, pas son libellé | Retrouver le libellé dans le schéma de la collection |
| Référence | L'identifiant de l'élément visé | Le remplacer par son slug (deux passages : on lit tout, puis on écrit) |
| Références multiples | Une liste d'identifiants | Idem |
| Champ vide | Absent, ou null : les deux se rencontrent | Tous 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.
⚠️ 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) :
// 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})${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('&', '&'), 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('&', '&'), 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 :
---
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).

*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.
// 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) :
---
// 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.
---
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.
grep -rnIE "website-files\.com|webflow\.(com|io)" src publicLa 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 danssrc/content/blog/fr/. Danscontent.config.ts, donnez alors à chaque langue sa collection (pattern: '*.md'pour la langue principale, une collectionblogFrsursrc/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 : remplissezslugByIdlangue 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.
npm install @sanity/astro @astrojs/react @sanity/client sanity react react-dom styled-components// 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,/contactcomprise.- 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 commeastro-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
htmlToBlocksde@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 :
// 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 Webflow | Sur Astro |
|---|---|
| Apparition au défilement | CSS + IntersectionObserver (quelques lignes), ou GSAP ScrollTrigger |
| Séquence liée au défilement | GSAP 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 |
| Slider | Défilement CSS scroll-snap, ou une bibliothèque (Swiper, Embla) |
| Lightbox | L'élément <dialog>, ou une bibliothèque (PhotoSwipe, GLightbox) |
| Liste de collection | getCollection() (étape 6) |
| Pagination | paginate() (é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 site | Pagefind (ci-dessous) |
| Vidéo d'arrière-plan | <video autoplay muted loop playsinline> |
| Lottie | lottie-web ou @lottiefiles/dotlottie-web |
| Transitions entre pages | Les 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.
<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-motioncoupe 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 écouteurdocument.addEventListener('astro:page-load', …), et nettoyez les animations avant chaque changement de page (ScrollTrigger.getAll().forEach((t) => t.kill())surastro: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))etgsap.ticker.lagSmoothing(0). Depuis sa version 1.3.26 (août 2026), Lenis respecte seulprefers-reduced-motion; les animations GSAP, elles, se coupent avecgsap.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 :
<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 :
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) :
// 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 :
---
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) :
| Solution | Gratuit jusqu'à (20/10/2026) | Quand |
|---|---|---|
| Netlify Forms | Illimité sur les plans à crédits | Si le site est sur Netlify : un attribut sur le formulaire suffit |
| Web3Forms | 250 envois par mois | Sites vitrines, sans serveur |
| Formspree | 50 envois par mois | Idem, interface complète |
| HubSpot, Brevo, votre CRM | Selon l'outil | Si les contacts doivent arriver directement dans un CRM |
| Astro Actions + un service d'e-mail | Selon l'hébergeur | Logique 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.
<!-- 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
noindexet 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.PRODvauttrueaussi 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 :
---
// 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,ogDescriptionetcanonical: ne les remplissez que pour ces pages. Si la description de Webflow venait d'un autre champ quesummary, 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.xmlvers/sitemap-index.xml(étape 10) ; - un
public/robots.txtqui 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 :
User-agent: *
Allow: /
Sitemap: https://www.exemple.com/sitemap-index.xmlSi 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 :
// 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 :
// 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 :
// 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, pasfr-FR, si le crawl de départ montrefr) et la même cible pourx-default. L'attributlangde la balise<html>suit la langue de la page : c'est la propriétélangdu 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
webflowIdque 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
i18nde@astrojs/sitemapapparie 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.
---
// 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 :
// 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ébergeur | Fichier | Code par défaut | Limites |
|---|---|---|---|
| Cloudflare | public/_redirects | ⚠️ 302 : écrivez 301 sur chaque ligne | 2 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 |
| Netlify | public/_redirects | 301 | Pas de limite stricte ; au-delà de 10 000, utilisez des jokers |
| Vercel | vercel.json | permanent: 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 |
# 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 301Reprendre 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) :
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/_redirectsLes 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 exportpour 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 :
#!/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ée | Réponse |
|---|---|
/blog/mon-article | 200 |
/blog/mon-article/ | 307 vers /blog/mon-article |
/blog/mon-article.html | 307 vers /blog/mon-article |
/index.html | 307 vers / |
/sitemap.xml | 301 vers /sitemap-index.xml |
/Blog/mon-article (majuscule) | 404, comme sur Webflow |
/n-existe-pas | 404, 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.
É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 duwww. 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 :AAAAvers100::) et une règle de redirection de la zone (Rules > Redirect Rules, pas la tableredirectsd'Astro) : 301, un joker qui garde le chemin (par exemplehttps://exemple.com/*vershttps://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 seulhttp://vershttps://, 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 surhttps://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-pagedoit répondre301et la bonne adresse, etcurl -s https://www.exemple.com/ | grep -i noindexne doit rien renvoyer. Lenoindexde 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.devdu Worker de production :"workers_dev": falsedanswrangler.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, sansnoindex, reste en ligne surmon-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 avecworkers.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.xmldans Search Console et dans Bing Webmaster Tools, et retirez l'ancien/sitemap.xmlde 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 lerobots.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 :
# 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 :
# 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.
| Qui | Taille | Durée | Ré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 Code | Lighthouse 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 tiers | Moins de deux semaines | Aucun lien cassé, plus de 1 500 tests automatiques |
| StackOne (03/2026) | 42 gabarits, 914 pages générées | Deux 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 collections | Non 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 langues | 23 h jusqu'à la bascule DNS, ~1 200 $ d'IA | Images 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 designer | Environ un mois | Sans 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.
| Page | Lighthouse mobile, Webflow → Astro | Affichage du contenu principal (LCP mobile) |
|---|---|---|
| Journal (liste des articles) | 87 → 98 | 4,0 s → 2,3 s |
| Article | 82 → 98 | 4,7 s → 2,1 s |
| Accueil | 98 → 97 | 2,2 s → 2,5 s |
| Contact (rendue à la demande) | 98 → 97-100 | 2,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
- 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).
- Oublier une adresse hors du sitemap. Le crawl et Search Console la trouvent, le sitemap jamais.
- 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.
- 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.
- Laisser
not_found_handlingavec l'adaptateur : les pages à la demande (formulaire, Studio) reçoivent la 404 dans un vrai navigateur, alors quecurlles voit répondre. - Les formulaires qui affichent « envoyé » sans que rien n'arrive.
- Supprimer une page de remerciement qui servait de cible de conversion.
- Le
noindexde préproduction qui survit en production. - La canonical qui se termine par
.htmlavecbuild.format: 'file'. - Les redirections qui ne sont pas des 301 : une ligne de
_redirectssans code (302 chez Cloudflare), ou un joker dans la tableredirectsd'Astro qui crée une chaîne 301 puis 307. - Une chaîne de redirections, ou des dizaines d'adresses redirigées vers l'accueil.
- La barre finale :
/pagedevient/page/, et toutes les adresses changent d'un coup. - Changer le préfixe d'une collection (
/blog/vers/ressources/) « pendant qu'on y est ». - Refaire le design en même temps : si le trafic bouge, impossible de savoir pourquoi.
- Construire les pages avant le design system : la marque dérive.
- 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.
- Les pages protégées par mot de passe, qui deviennent publiques dans un site statique si on ne les traite pas.
- Les analytics qui comptent les aperçus, et Partytown qui casse le bandeau cookies.
- Toucher aux enregistrements e-mail (MX) pendant la bascule DNS.
- 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
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-articledans/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)
- ZenML, How I Rebuilt zenml.io in a Week with Claude Code (03/2026) : le plus complet, avec tous les pièges. zenml.io/blog/how-i-rebuilt-zenml-io-in-a-week-with-claude-code
- VGV, Migrating VGV from Webflow to Astro (03/2026) : l'extraction par scripts et la recette automatisée. verygood.ventures/blog/migrating-vgv-from-webflow-to-astro
- easy.tools (Adam Gospodarczyk), From Visual Editors to an Agentic-First Web (08/2026), publié sur mega.dev : le design system extrait en seconde passe, l'audit d'avant lancement. mega.dev/from-visual-editors-to-agentic-first-web
- StackOne, We rebuilt our marketing site with Astro, Claude Code, and Cloudflare (03/2026) : les redirections après lancement, Partytown. stackone.com/blog/rebuilding-marketing-site-claude-code-cloudflare
- Felix Dorner, Webflow to Astro (04/2026) : le point de vue d'un designer. felixdorner.de/articles/webflow-to-astro-ai
- Omnia, Fixing our AEO in a day (10/2026) et son skill open source. useomnia.com/blog/webflow-to-astro-migration
- ypakala, How to migrate a site from Webflow to Astro : « ne migrez pas l'export, migrez le design et le contenu ». ypakala.com/notes/en/migrate-webflow-site-to-astro
Les références techniques
- MigrateLab, migration du contenu du CMS vers Astro : la meilleure table des types de champs. migratelab.com/resources/migrate-webflow-cms-content-to-astro
- migratingto.dev, le playbook Webflow vers Astro : ce qui se transfère, ce qui se perd. migratingto.dev/guides/webflow-to-astro
- WAYF, Replacing Webflow with Claude Code (09/2026) : la couche que Webflow faisait sans qu'on la voie. wayf.ai/blog/replacing-webflow-with-claude-code
Le cas qui fait réfléchir
- Reddit r/webflow, We migrated away from Webflow to AstroJS (with Claude Code), then we moved back (07/2026). reddit.com/r/webflow/comments/1uxepys
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
- Astro : content collections, redirections, images, langues, déploiement sur Cloudflare, mise à jour vers la v7 (docs.astro.build)
- Webflow : export du code, export CSV du CMS, API Data v2, redirections 301, export des formulaires, changement de tarifs de mai 2026 (help.webflow.com, developers.webflow.com)
- Google Search Central : Changing your hosting (le cas de ce guide : la plateforme change, les adresses non), Site moves with URL changes, Redirects and Google Search (developers.google.com/search)
- Sanity : le cours Migrating content from Webflow to Sanity (sanity.io/learn/course/migrating-content-from-webflow-to-sanity), et l'édition visuelle avec Astro (sanity.io/docs/astro)
- Cloudflare : redirections et gestion du HTML des fichiers statiques (developers.cloudflare.com/workers/static-assets), domaines personnalisés des Workers (developers.cloudflare.com/workers/configuration/routing/custom-domains)
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.






