GuideBy Térence Mbu · · 74 min read

Migrating a site from Webflow to Astro without losing your SEO

Should you leave, which architecture should you choose, and how do you migrate in 14 steps without losing a single URL? The complete guide, with tested code and a 78-point checklist.

Contents

The complete guide, step by step. October 21, 2026 edition (Astro 7.4).

By Térence Mbu, web developer. I build sites in both worlds: on Webflow and in custom code. This guide isn't trying to make you leave. It's trying to make sure that, if you do leave, you lose nothing.

Before you start

Who this guide is for

It reads on two levels.

  • The parts marked “Decide” are for the person who owns the site: founder, marketing lead, project manager. Not a single line of code.
  • The parts marked “Build” are for the developer who will do the migration. Commands and files are given in full, and each step says what was tested.

The golden rule, in three sentences

  1. Every URL on the site keeps exactly the same path. The most common loss comes from a URL that existed and no longer exists. The others come from what changes behind an identical URL: a forgotten noindex, a wrong canonical, a lost title, text or internal links. That is what steps 9 and 12 are for.
  2. Reproduce first, improve later. If you change platforms and redesign on the same day, you can't tell what moved the traffic.
  3. Cancel nothing at Webflow before 30 days of monitoring. As long as the old site exists, rolling back takes a few minutes: you point the domain back at it.

What this guide verified, and what it didn't

  • Tool versions, prices and rules were read on October 20 and 21, 2026, on the official pages of Astro, Webflow, Google, Cloudflare, Netlify and Vercel. They change: reread them before you decide.
  • The code was tested on October 20 on Astro 7.4.1 and Node 24, first against a simulated copy of the Webflow API (101 posts, pagination, rate limiting, a rejected image).
  • Then on a real site, on October 21: I built a demo site on Webflow, “Atelier Nord” (a fictional architecture studio), and migrated it to Astro by following this guide, all the way to going live on Cloudflare, first in its minimal variant (100% static, content in Markdown). Its contact page then moved to the recommended stack (the adapter and the Astro Action + Resend form), tested on Cloudflare's local server and then live, except for actually sending an email. Sanity was not tested in this guide: step 6 says what comes from my stack and what still needs to be proven. The screenshots and figures come from this site. The full account is in part 5.
  • This test doesn't replace a trial run on your own site: every Webflow site has its quirks, and step 12 is there to find them.

Part 1 · Decide

1.1 Should you leave?

Half of all failed migrations go wrong before the first line of code: they should never have happened. The right question isn't “Webflow or Astro?” It's: who edits the site, what do they edit, how often, and who will look after it two years from now?

The grid I use to answer it, case by case, is in a separate article: Webflow or custom code: the decision grid.

Stay on Webflow if:

  • your marketing team rebuilds its own pages every week, is comfortable doing it, and nothing is blocking what the site needs to do;
  • your site is a Webflow store: moving an e-commerce site is a project in its own right. Products and order history export (CSV, API), but the cart, payments, taxes and order emails have to be rebuilt on another tool;
  • your site is simple, it works, and one or two people keep it up to date;
  • a redesign, a rebrand or a big campaign is under way: wait until it's done;
  • nobody will handle maintenance. A comment in a Reddit thread about the price increase sums it up: “Moving to custom code often means hiring yourself as a permanent developer.” That's an excellent plan if it is the plan, and a trap if it isn't.

Move to Astro if:

  • someone technical is on the team, or an agency or freelancer handles maintenance. Finn Dollimore, a Webflow developer who made this choice in 2026, puts it simply: migrating makes sense when the team includes technical people;
  • your product team already codes in JavaScript: on Webflow, only the Webflow developer can touch the site; in custom code, the whole team can contribute, and you hire from a much larger pool;
  • you need features that Webflow doesn't support, or reserves for its Team and Enterprise plans;
  • you build with AI agents (Claude Code, Cursor) and Webflow slows you down: a component built by hand, element by element, versus a few seconds in custom code;
  • the number of people editing the site is growing, and your Webflow seat bill is growing with it;
  • you want control over every technical detail of SEO, performance or multilingual support.

Reasons that aren't enough:

  • “It's cheaper.” ZenML migrated 2,224 pages and saves about $800 a year. Its author says so himself: “that alone wouldn't have justified the migration.” The real reasons lie elsewhere.
  • “Everyone is leaving.” On Reddit, an agency that sells both Webflow and Astro reported in September 2026 that demand for migrations to Webflow had risen sharply. Traffic goes both ways.
  • “Webflow is simpler for a non-technical team.” That's not a criterion in itself: a well-configured editing tool (part 3, step 11) is just as quick to pick up. The real criterion is what the site needs to do.

The trap to know about before you decide.

A specialist agency says it in its own sales guide: a team of designers with no developer ends up more locked in after the migration than before. Code that nobody knows how to change is a worse trap than an account you can log into.

1.2 What it costs to stay, what it costs to leave

(Prices checked on Oct 20, 2026 on the official pages, in US dollars, billed annually unless noted.)

Staying on Webflow:

ItemPrice
Premium site plan (with CMS, 20,000 items)$25/month ($39 billed monthly)
Basic site plan (no CMS)$15/month
“Full” seat for each person who builds$39/month ($45 billed monthly)
“Limited” seat for each person who edits content$15/month ($19 billed monthly)
Workspace plan required to export the codeCore ($19/month) or Freelancer ($16/month); a site plan alone doesn't include it

⚠️ A date to note: since May 13, 2026, Webflow has merged its CMS and Business plans into a single Premium plan. Existing sites switch over at their first renewal after June 29, 2026. Sites hosted in a Freelancer or Agency Workspace switch over at their first renewal after November 16, 2026. If that's your situation, now is the time to redo your math, in either direction.

Time the cutover around your renewal dates. Webflow doesn't refund: cancelling lets the period you already paid for run to its end, and the site plan and the Workspace plan are cancelled separately. Write down both dates before you set the cutover day: an annual plan that renews during the 30 days of monitoring costs you another full year. If you expect to leave within the year, don't switch to annual billing to lock in the old price. On the “stay” side, the Premium plan includes 50 GB of bandwidth per month (100 GB on the old Business plan); beyond that, budget $20/month per extra 50 GB billed annually ($30 billed monthly).

Moving to Astro:

ItemPrice
Cloudflare hosting$0: static files are free and unlimited; on-demand pages (here /contact) count toward the free plan's 100,000 requests per day. Paid plan from $5/month
Sending the form's emails (Resend)Free up to 3,000 emails per month (100 per day), plenty for a contact form
Netlify hostingFree with 300 credits per month, no top-ups: once they run out, every site on the account is paused. Paid plans at $9 or $20/month (what the credits are worth: part 2.2)
Vercel hostingPro plan: $20/month per developer seat (the free plan excludes any commercial use: part 2.2)
Editing tool (CMS)From $0 to several hundred dollars per month: see part 2
MaintenanceThe line item that “leave Webflow” posts forget (see below)

The cost nobody puts a number on: maintenance. On Webflow, hosting, security and updates are included. On Astro, someone has to update the framework and its dependencies. To give you an idea of the pace: Astro 5 came out on December 3, 2024, Astro 6 on March 10, 2026, and Astro 7 on June 22, 2026. That's two major versions in 2026, and only the previous major version still receives fixes, and only security fixes. The question to ask before you leave: who takes care of it, and how much time does it take per quarter?

1.3 Why Astro, and when it isn't Astro

Astro is a framework built for content sites: brochure sites, blogs, documentation, marketing sites. It's one of the destinations most often cited by the teams that wrote about leaving Webflow in 2026: most of them keep their content in Markdown, others go through a CMS like Sanity.

  • It outputs static HTML by default, with no unnecessary JavaScript. Pages are fast and readable by search engines and AI tools without any special effort.
  • Its “content collections” look like Webflow CMS collections: a content type, its fields, its items.
  • It works with any tool: plain HTML and CSS, or React, Vue or Svelte components where you need them.
  • It's on solid ground: the Astro team joined Cloudflare on January 16, 2026. The project remains open source (MIT license) and still deploys to Netlify, Vercel or a regular server. Webflow itself gave the project $150,000 in September 2025, and its application hosting service (Webflow Cloud) runs Astro apps.

When it isn't Astro: if your site is mostly an application (logged-in customer area, dashboard, heavy business logic), an application framework like Next.js will be a more natural fit. Astro can do server rendering, but that isn't where it shines.

1.4 Alternatives to a full exit

Leaving isn't all or nothing. There are three middle paths.

  1. The two-phase migration. You first move the site (the design and the pages) to Astro, and keep the Webflow CMS as the content source, read through its API. The team keeps writing in Webflow. You switch editing tools later, once the new site is stable. It's Google's advice applied to the letter: “Plan your changes one after the other, not all at once.” Details in step 6.
  2. Webflow Cloud. Webflow hosts Astro or Next.js apps alongside a Webflow site (under a path like /app). Useful for adding an application section without leaving Webflow. Less suited to an entire marketing site: by default, every page there is rendered on demand, and the cache header is forced to private, no-cache.
  3. Waiting for Webflow Source. Unveiled at Webflow Conf (September 1-3, 2026) and still in limited preview, it's meant to make code the source of truth in Webflow. Nothing is publicly available, and the question “what can you export and host elsewhere?” remains open. One industry analyst sums it up: if your site is holding back your marketing today, waiting for a future architecture with no release date is not a strategy.

Still torn between staying and leaving?

Book 30 minutes with me and I'll look at your case: book a call.

Part 2 · Choosing the architectureDecideBuild

Two decisions shape everything else: where the content lives, and where the site lives.

2.1 Where will the content live?

OptionHow the site gets editedWho it's forCost (Oct 20, 2026)
A. Markdown files in the repositoryA developer, or an AI agent, edits the files; every change is reviewed before it's publishedTeams with no non-technical editor. This is what ZenML, VGV and StackOne chose$0
B. Git-based CMS (Keystatic, Decap, TinaCMS, CloudCannon)An editing interface, but the content stays as files in the repositoryOne or two people who publish postsKeystatic and Decap: free. TinaCMS: free for 2 users, then $24/project/month. CloudCannon: from $49/month billed annually ($55 billed monthly)
C. “Headless” CMS (Sanity, Storyblok, Contentful)A real editing application, with preview, roles and page buildingA marketing team that publishes and builds pagesSanity: free up to 20 users and 10,000 documents, then $15/user/month. Storyblok: free for 1 user, then $99/month ($90.75 billed annually). Contentful: free up to 10,000 entries, then $300/month
D. Keep the Webflow CMS, read through the APIThe team keeps working in Webflow; the Astro site rebuilds on every publishThe two-phase migration (1.4)The Webflow site plan continues ($25/month on Premium)

How to decide:

  • Nobody publishes without a developer → A.
  • A few people publish posts, rarely pages → B, or Sanity if the content is rich.
  • A marketing team that builds its own pages → C, with predefined blocks (step 11).
  • You want the lowest possible risk on cutover day → D, then A, B or C later.

⚠️ Sanity and its visual editing (you click a piece of text on the page to edit it): with Astro, it only works on pages rendered on demand. So you need an adapter and a preview route, even if the public site stays static.

2.2 Where will the site be hosted?

HostFor a static Astro siteGood to know
Cloudflare (Workers)✅ The default choice: free, unlimited static files, and the official Astro adapter for the few on-demand pages (Astro joined Cloudflare in January 2026)⚠️ A custom domain on Workers requires Cloudflare to manage the domain's DNS zone (its nameservers); on the free plan, there is no other option. The move happens without touching the site (step 13). If your DNS can't leave its current provider, go with Netlify or Vercel. Cloudflare recommends Workers over Pages for any new project, and the Astro adapter no longer supports Pages as of version 13. The _redirects file sends a 302 by default: always write 301
Netlify✅ Very simple, free built-in formsThe free plan counts in credits: 15 credits per production deploy, 20 credits per GB of traffic, 300 credits per month, with no top-ups. ⚠️ Once the credits run out, Netlify pauses every site on the account until the next month (a “Site not available” page replaces every page). For a business site, take at least the Personal plan ($9/month, 1,000 credits, top-ups available) and watch usage during the first month. A CMS that republishes on every edit spends 15 credits each time
Vercel✅ Technically flawless⚠️ Free plan limited to personal, non-commercial use, and that includes a site built for a client. A business site needs Pro
Webflow Cloud🟠 Possible, designed for applicationsOn-demand rendering and caching disabled by default (see 1.4)

2.3 Static or on demand?

  • Static (the default): every page is generated once, at build time. It's the fastest and cheapest option, and it's what 90% of brochure sites need.
  • On demand: the page is generated on every visit, on a server. You need it for forms handled by Astro (Actions), Sanity's preview and personalized content.

A site can mix both: everything static except one or two routes. To do that, you install the host's adapter (@astrojs/cloudflare, @astrojs/netlify, @astrojs/vercel) and mark the dynamic routes with export const prerender = false.

2.4 What Webflow was doing without you noticing

This is the list that makes rushed migrations fail. One agency guide sums it up: “Setting up this layer is most of the migration work.”

Webflow did it on its ownOn Astro, you have to plan for it
Hosting, CDN, HTTPS certificateThe host (Cloudflare, Netlify, Vercel). On Cloudflare, redirecting http:// to https:// is a separate setting you have to turn on: SSL/TLS > Edge Certificates > Always Use HTTPS (step 13)
Version history, rollbackGit: every change is a commit, and rolling back is another one
Staging domain (.webflow.io)A preview URL per branch, provided by the host
The technical subdomain kept out of the index (the Staging indexing setting)"workers_dev": false in the production wrangler.jsonc, otherwise the site stays live, and indexable, at my-site.<account>.workers.dev. Add "preview_urls": true to keep the preview URLs (step 13)
“Publish” buttonAn automatic build on every approved change
Form handling and spam protectionAn Astro Action and an email service, or a form service (step 8)
Site searchastro:assets (step 6). Image URLs change no matter what: they leave Webflow's domain (step 2)
Image optimizationastro:assets (step 6)
Automatic sitemap@astrojs/sitemap (step 9)
301 redirect managerAstro's redirects config with the adapter, or the host's _redirects file (step 10)
Security updatesThe build fails on invalid content (the collection schema, step 6). Dead links and links that go through a redirect show up in the staging crawl (step 12)
Nothing broken gets published (or almost nothing)An automatic check: the build fails on a dead link or invalid content
Security headers (including HSTS, according to Webflow's help center)The HSTS setting of the Cloudflare zone, and the headers set by the site (step 9)
The Search Console verification tagA “Domain” property, verified through DNS (step 1)
The audit panel (alt text, accessibility)Lighthouse and an accessibility check during the QA pass (step 12)

2.5 The stack I recommend

It's the stack of my starter template for all my Astro sites, proven on Cloudflare's real network in October 2026. It applies the choices above to the most common case: a brochure site that a team keeps up to date.

Building blockChoiceWhy
FrameworkAstro 7.4, pinned version (no ^)Updating Astro or the adapter is a decision, not an accident
HostingCloudflare Workers, free plan, on the client's accountThe client owns their site; static files cost nothing. Condition: the domain's DNS zone must be able to move to Cloudflare (part 2.2)
RenderingAll static except /contact (output: 'static' + the @astrojs/cloudflare adapter)The speed and cost of static; a single on-demand page, to handle the form
ContentSanity, with its Studio built into the site at /studioA real editing interface with roles, and an automatic rebuild on every publish (step 11). Its visual editing (click on the page to edit) also requires an on-demand preview route, which this stack doesn't include
FormAstro Action + Resend for sending email, no JavaScript on the visitor's side, with a honeypot field and a minimum delay against bots (step 8)No third-party form service; falls back to a mailto: link until the key is set
StylingTailwind 4, with the Webflow site's variables as tokensThe brand styles stay locked in a single file
FontsAstro's Fonts API, with the files served by the siteNo IP address sent to Google
Imagesastro:assets, optimized at build time (imageService: 'compile'), and Sanity's CDN for CMS imagesNothing is billed per transformation
AnimationsGSAP + Lenis, only through data-anim attributes; the HTML stays complete without JavaScriptAn animation that breaks doesn't break the page
RedirectsAstro's redirects config, one line per old URL; the adapter turns them into real 301s (step 10)A single source of truth
AnalyticsCloudflare Web Analytics, page views only; during the migration, alongside the existing tools, not in place of themIt sets no cookies and uses no local storage

The minimal variant: a site that only a developer edits, with no form handled by the site, can stay 100% static, with no adapter and its content in Markdown files. That's the route the demo site for this guide took (part 5). Without an adapter, Astro's redirects config doesn't produce real 301s: redirects go through the _redirects file (step 10). The minimal variant is described and tested end to end; for the recommended stack, each step says what was tested.

Part 3 · The 14-step methodBuild

This is the method I apply to every site migration, whichever the direction. The screenshots come from this guide's demo site, migrated step by step on October 21, 2026.

The Atelier Nord demo site open in the Webflow Designer
The starting site: “Atelier Nord,” a fictional architecture studio, in the Webflow Designer.

Steps 1 and 2 take the longest, longer than the code. Simon Olivier, who moved his agency Bienfait's site off Webflow in September 2026, wrote it word for word: “The inventory first. […] The code took less time than this step.”

Step 1 · The inventory, before you touch anything

When a migration loses SEO, it is most often for the same reason: a URL that existed no longer exists, and nobody had written it down. The inventory is your contract: by the end, every row must be accounted for.

Every URL:

  • the site's sitemap.xml;
  • a full crawl (Screaming Frog, or a script): the sitemap lies. On a migration of 143 URLs, a 144th lived outside the sitemap. And a site still on its .webflow.io domain may have no sitemap at all (that was the case for the demo site);
  • the URLs read from the CMS, one per published item. On the demo site, the crawl missed all three blog posts: the cards in the list pointed to a broken URL. Only the list pulled from the CMS caught them. The crawl finds what's linked; the CMS finds what exists;
  • the pages that get clicks or impressions in Search Console, over 16 months. ⚠️ The interface export stops at 1,000 rows: beyond that, use the Search Console API (up to 25,000 rows per request) or the bulk export to BigQuery. Export each search type (Web, Image, Video) separately, since the report counts them apart. Keep the Image figure: it is the traffic the migration exposes most, since every image URL changes (step 2);
  • the landing pages of your other channels, which Search Console doesn't see: your analytics tool's landing page report over 12 months, all channels combined, the destination URLs of your ad campaigns, and the links in emails and printed QR codes;
  • the pages that get links from other sites (Search Console's Links report). A page can carry inbound links without a single click: it doesn't show up in the Performance report;
  • the files (PDFs, images) linked from other sites: they live on Webflow's CDN, not on your domain (see step 2);
  • the technical files, which neither the sitemap nor the crawl lists as pages: /robots.txt (Webflow lets you write your own rules in it), /llms.txt (Site settings > SEO > LLMs.txt), and each collection's RSS feed (/blog/rss.xml, see step 9). Download them as they are with curl, and add their URLs to the inventory.

For each URL, the crawl must record: the status code, the title, the description, the canonical tag, the sharing tags (Open Graph), the language tags (hreflang), the structured data and the H1. ZenML called its crawl its “contract with reality”: the new site has to reproduce it exactly.

The content:

  • every CMS collection: its fields, their types, its references to other collections, its item count;
  • the custom code hidden in the settings: the site's (Site settings > Custom code), each page's (Page settings), and the collection templates'. It doesn't show up anywhere in the design;
  • the native integrations, which don't show up in any code field: the Google Analytics ID and the Meta Pixel entered in Site settings > Apps & integrations, and the Google Site Tools app. Write down each ID: on Astro, you'll add the tag yourself, with the same ID and the same consent handling;
  • the SEO settings of the collection templates: for example “title = post name | Blog”. You'll need to reproduce them.

Everything that lives outside the design:

  • forms: their name, their fields, where they send, what happens after submission;
  • scripts: Google Tag Manager, analytics, chat, cookie banner, appointment booking;
  • connected tools: Finsweet attributes, Memberstack, Jetboost, Weglot;
  • thank-you pages: they're often conversion targets in Google Tag Manager. Deleting them breaks sales tracking without any visible error;
  • site search, password-protected pages, languages, e-commerce;
  • member areas: Webflow shut down its “User Accounts” feature on January 29, 2026. If the site has one, it already runs through a third-party tool (Memberstack, Outseta…): note which one, since it gets reconnected;
  • Search Console verification: Webflow adds the verification tag (SEO > Google site verification), and the tag disappears with the site. The safest move: before the migration, create a “Domain” property verified through DNS;
  • Webflow add-ons: Analyze (its history won't follow you, so export it), Optimize (A/B tests in progress), Marketplace apps.

Existing redirects: export them as a CSV (Site settings > Publishing > 301 redirects > Export).

Baseline measurements (dated, before you change anything): Lighthouse on each page type, real-user Core Web Vitals, traffic and rankings of the pages that matter. Without them, you can't prove afterward that nothing was lost.

The deliverable for this step: a spreadsheet with one row per URL and one decision per row:

DecisionWhen
Keep (same URL)The normal case. 95% of rows
Redirect (301)The URL has to change, or the page merges with another one
Remove (404)Only if the page has no traffic, no inbound links and no purpose. All three conditions, not just one
NoindexPages that are useful to visitors but not to search engines (thank-you pages, test pages)

The columns I use for migration/inventory.csv, to copy into a spreadsheet:

Text
path,status,title,description,canonical,og_title,og_image,robots,hreflang,h1,jsonld,clicks_16_months,inbound_links,decision,destination,note

Step 2 · Get everything out of Webflow

⛔ Delete nothing and cancel nothing before step 14.

The code export (main menu, the W logo at the top left of the Designer > Code export, or Shift+E):

  • it requires a paid Workspace plan (Core, Growth, Freelancer or Agency); a site plan alone isn't enough;
  • it contains the HTML pages, three stylesheets (normalize.css, webflow.css, and the site's own), Webflow's JavaScript and an images folder;
  • it doesn't contain: CMS content (collection lists show their empty state, templates are left with no content), secondary locales, form handling, reCAPTCHA, search, password protection, e-commerce, code components;
  • it's a reference, not a working base. Keep it in a separate folder (webflow-export/), outside the site's code, so it never becomes a dependency.
Webflow's Export Code window, with the warning about the CMS and forms
Webflow says so itself, at the bottom of the export window: “Exports don't include CMS or native form functionality”.

⚠️ Also review the exported CSS before you use it as a reference. On the demo site, the published stylesheet contained invalid transitions (border-color undefined undefined) and duplicate classes (_w-button, _w-input).

CMS content: through the API rather than the CSV.

  • Each collection's Export button produces a CSV: rich text comes out as HTML, multi-references as a text list, images as links.
  • The Data API v2 returns typed data: each field with its type, references by ID, images with their alt text. It's what the step 6 script uses.
The Blog collection in the Webflow CMS, with its Author and Category fields and the Export button
The demo site's Blog collection: an “Author” field that references another collection, an option field for “Category,” and the Export button at the top right.
  • ⚠️ Images come out as links to files hosted by the Webflow project. Webflow says so itself: if the site is deleted, those links break, including the images inside rich text. Download everything.
  • ⚠️ Some files on Webflow's CDN return a 403 error. ZenML and VGV both ran into it. The images folder from the code export then serves as a spare, for design images only: it doesn't contain the CMS images, which you recover from the published page.

Every image and file URL changes, and none of them can be redirected.

On Webflow, an image or a PDF is never served from your domain but from Webflow's CDN (cdn.prod.website-files.com/…, or uploads-ssl.webflow.com/… on an older site). After the migration, your site serves them itself (/_astro/… for images, /files/… for files). The old URLs are not on your domain: no redirect can catch them. Three consequences:

  • Google Images finds the new images by recrawling your pages. Measure that traffic beforehand (step 1, Search type: Image), and track it separately during the 30 days. If it matters (portfolio, architecture, products), expect a temporary drop, and keep descriptive file names and the same alt text.
  • A PDF linked from another site keeps its Webflow CDN URL: it only responds as long as the Webflow project exists. For files that carry important links, ask the sites in question to update the link.
  • A file that already had a URL on your domain (for example a Webflow redirect from /brochure.pdf to the CDN) is in the redirects CSV: put the file at that URL (public/brochure.pdf), or point the redirect to its new location.

Hence the rule: never delete the Webflow project. Webflow itself recommends this when you leave the platform: its old images keep responding while Google picks up the new ones, and its PDFs stay reachable. At step 14, you cancel the site plan, not the project. Webflow doesn't say how long it keeps the files of a project with no plan: don't rely on it for an important link.

Form submissions: Site settings > Forms > View submissions > Export all, one form at a time. Files uploaded by visitors come out as links: download them too. The submissions belong to the client, who keeps them.

The list of Contact form submissions in Webflow, with the Export all button
A form's submissions and the “Export all” button. They contain each visitor's IP address (blurred here): this is personal data, and it should be archived as such.

Redirects: the CSV from step 1. (The redirects API is reserved for Enterprise accounts.) A site without a paid site plan often has none: Webflow only allows 301 redirects there with a Growth or Agency Workspace.

Webflow's 301 redirects section, with the message saying they aren't available on an unhosted site
On a site without a paid plan, here in a Freelancer Workspace, the setting exists but stays locked. On Cloudflare, the same redirects fit in three lines (step 10).

Step 3 · Create the Astro project

Prerequisites: Node.js 22.22 or newer (odd-numbered versions such as 23 aren't supported).

Terminal
npm create astro@latest my-site -- --template minimal
cd my-site
npx astro add cloudflare sitemap tailwind   # the adapter (for /contact), the sitemap, Tailwind 4
npm install @astrojs/rss gsap lenis turndown

Then pin the Astro and adapter versions in package.json (remove the ^ in front of astro and @astrojs/cloudflare). The minimal variant skips cloudflare and tailwind, and removes the output, session, adapter and redirects lines from the configuration below: its redirects go through public/_redirects (step 10). If the content goes into Sanity, its packages are added in step 6.

How the project is organized:

Text
my-site/
├── astro.config.mjs
├── wrangler.jsonc              ← if hosting on Cloudflare
├── sanity.config.ts            ← if the content lives in Sanity (step 6)
├── migration/                  ← the project control folder (outside the site)
│   ├── plan.md                 ← the phases and their definition of done (part 4)
│   ├── inventory.csv           ← step 1: one row per URL
│   ├── redirects.csv
│   ├── lessons.md              ← what cost time, reread at every session
│   └── open-questions.md       ← the decisions that aren't technical
├── webflow-export/             ← the code export: a reference, never imported
├── scripts/
│   └── export-webflow.mjs      ← step 6
├── public/                     ← served as is, at a fixed URL
│   ├── _redirects              ← minimal variant only
│   ├── robots.txt
│   ├── favicon.svg
│   └── og-default.jpg
└── src/
    ├── actions/index.ts        ← form handling (step 8)
    ├── assets/                 ← downloaded images (optimized by Astro)
    ├── components/
    │   ├── seo/BaseHead.astro
    │   ├── sections/           ← the closed list of sections (step 5)
    │   └── ui/                 ← buttons, containers
    ├── content/                ← the content exported from the CMS
    ├── content.config.ts
    ├── layouts/Layout.astro
    ├── pages/
    │   ├── index.astro
    │   ├── blog/index.astro
    │   ├── blog/[slug].astro
    │   ├── contact.astro       ← the only page rendered on demand (step 8)
    │   └── 404.astro
    └── styles/
        ├── tokens.css
        └── global.css

The configuration (astro.config.mjs). The first three lines are the ones that protect your URLs:

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

export default defineConfig({
  site: 'https://www.example.com', // the final domain, with or without www, exactly as it is today
  trailingSlash: 'never',          // Webflow serves /about, never /about/
  build: { format: 'file' },       // generates about.html instead of about/index.html
  output: 'static',                // everything is generated at build time, except pages marked prerender = false
  session: false,                  // a brochure site has no sessions (otherwise the adapter creates a KV store)
  adapter: cloudflare({ imageService: 'compile' }), // images optimized at build time, nothing billed
  redirects: {},                   // the old URLs, one line each (step 10)
  integrations: [
    sitemap({ filter: (page) => !page.endsWith('/thank-you') }), // thank-you pages kept out of the sitemap
  ],
  image: { layout: 'constrained', responsiveStyles: true },
  fonts: [
    { provider: fontProviders.google(), name: 'Inter', cssVariable: '--font-inter' },
  ],
});
  • Tailwind: astro add tailwind added its plugin to the configuration on its own (vite: { plugins: [tailwindcss()] }); it isn't repeated here.
  • site: required for the sitemap and the canonical tags. Use exactly the current domain. If the site lives on www.example.com, don't write example.com.
  • trailingSlash and build.format: Webflow serves its pages without a trailing slash and redirects /page/ to /page. These two settings produce the same URLs. One nuance: Webflow answers /page/ with a 301, Cloudflare with a 307 (temporary redirect) to /page. That has no consequence as long as the canonical and the internal links have no trailing slash. ⚠️ For static pages, the host has the final say: check with curl on staging (step 12).
  • fonts: Astro's Fonts API (stable since Astro 6) downloads the fonts and serves them from your site. Bonus: Webflow loads Google Fonts from Google's servers, which sends your visitors' IP addresses to Google; Webflow itself warns that this may not be GDPR-compliant.

Cloudflare hosting (wrangler.jsonc, at the root):

JSON
// wrangler.jsonc: a static Astro site with one on-demand page (/contact), on Cloudflare Workers
{
  "name": "my-site",
  "main": "@astrojs/cloudflare/entrypoints/server",
  "compatibility_date": "2026-10-15",
  "compatibility_flags": ["nodejs_compat"],
  "assets": { "binding": "ASSETS", "directory": "./dist" }
  // ⛔ no "not_found_handling" here (see the callout below): the Worker serves the 404 itself
}

On the form page, one line makes it dynamic: export const prerender = false; (src/pages/contact.astro). All the other pages stay static.

Going live: npx astro build && npx wrangler deploy, or an automatic deploy on every push from GitHub.

Minimal variant (100% static, no adapter). The Astro configuration is the same, without the output, session, adapter and redirects lines. The hosting:

JSON
// wrangler.jsonc: a 100% static Astro site on Cloudflare Workers, no adapter
{
  "name": "my-site",
  "compatibility_date": "2026-10-01",
  "assets": {
    "directory": "./dist",
    "html_handling": "auto-trailing-slash", // /about serves about.html
    "not_found_handling": "404-page"         // serves 404.html with a real 404 status code
  }
}

This variant only works without an adapter.

As soon as you add the @astrojs/cloudflare adapter and on-demand pages (a form handled by Astro, an embedded Sanity Studio), remove not_found_handling: otherwise, when a visitor opens one of those pages in their browser, Cloudflare serves them the static 404 page without ever calling the server. The trap doesn't show up with curl, which doesn't send the browser header (Sec-Fetch-Mode: navigate): test these pages in a real browser, or with curl -H 'Sec-Fetch-Mode: navigate'.

Astro 7 is stricter about pasted HTML.

Its compiler now rejects unclosed tags: HTML copied from the Webflow export or from an “Embed” block can make the build fail. Badly nested HTML (a <div> inside a <p>) gets through, but Astro no longer fixes it: the browser rearranges it, sometimes differently than you intended. One more detail: Astro 7 compacts your components' HTML more aggressively, and two inline elements written on two lines (<span>a</span> then <em>b</em>) can end up stuck together. Proofread your formatted text.

Step 4 · Design system first, pages second

This is the step fast migrations skip, and the one that makes them fail. Three accounts from 2026 say so, each in its own way:

  • mega.dev (924 pages): copying page by page made the styles drift. It took a second pass to extract the colors, sizes and shared components, then rewire every page to them.
  • Felix Dorner, designer: asking an AI to infer the design system from the live site failed, because Webflow's HTML flattens the structure. What worked: taking the HTML of one component at a time from the browser, and rebuilding it.
  • The rollback case (Reddit, July 2026): a site converted to Astro in one day, then new pages “slightly off-brand” over the following weeks, and a move back to Webflow.

The order that works:

1. Variables (“tokens”): colors, typefaces, text sizes, spacing, radii, shadows. If the Webflow site uses the Variables panel, or Finsweet's Client-First convention, everything is already named: reuse the same names.

CSS
/* src/styles/tokens.css: the Webflow site's values, named once and for all */
: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;
}

With Tailwind 4 (the recommended stack), the same variables are declared in an @theme block in the main stylesheet: @theme { --color-ink: #0b0b0b; } creates both the CSS variable and the text-ink and bg-ink classes. The style guide stays in a single file.

2. The layout: the shared shell (header, navigation, footer) in src/layouts/Layout.astro.

3. Components: buttons, cards, then sections.

4. Pages, assembled only from those components.

Two ways to carry over the styles. Choose deliberately:

A. Keep Webflow's CSS as a “shell”B. Rebuild cleanly
HowKeep normalize.css, webflow.css and the site stylesheet as global styles, and rewrite the HTML as Astro components that keep the same classesRewrite the styles from the variables, in plain CSS or Tailwind
ProsPixel-identical, fast. Client-First classes stay readableClean, lightweight code that is easy to maintain
ConsYou ship Webflow's entire CSS framework, along with generated classes like div-block-47Slower. Every discrepancy is fixed by hand
Best forThe priority is “identical, fast, no risk”, followed by a gradual cleanupThe site will live a long time and keep evolving

For option A, Finsweet (the makers of Client-First) has published @finsweet/webflow-astro since September 2026: Astro components that reproduce Webflow's native elements (navbar, dropdown, tabs, slider, lightbox, forms, collection lists), rewritten in TypeScript, without jQuery. The package ships no CSS: you keep Webflow's. ⚠️ It is very young (version 0.2, five weeks old as of Oct 20): test it before you rely on it.

What the sources agree on: the export is a reference and a store of images, never the base for the HTML (its CSS can serve as a shell: that is option A). You don't “migrate the export”. You migrate the design and the content.

Step 5 · Layouts, components and pages

The shared layout (src/layouts/Layout.astro):

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

// The head component's props (step 9), plus the page's language and its structured data
type Props = ComponentProps<typeof BaseHead> & { lang?: string; jsonLd?: object };
const { lang = 'en', jsonLd, ...seo } = Astro.props; // lang follows the page's language, never hard-coded (step 9)
---
<html lang={lang}>
  <head>
    <BaseHead {...seo} />
    {jsonLd && <script type="application/ld+json" set:html={JSON.stringify(jsonLd)} />}
  </head>
  <body>
    <slot />
  </body>
</html>

A closed list of sections. Each section of the Webflow site becomes a component in src/components/sections/: Hero.astro, Features.astro, Testimonials.astro, Faq.astro, Cta.astro. A page is assembled from these components only. A new need creates a new component, approved once, instead of a page patched together. That is the safeguard the rollback case was missing.

Static pages (home, about, contact): one .astro page per Webflow page, at the same path. src/pages/about.astro gives /about.

Collection pages: the folder has the same name as the Webflow prefix. If posts live at /blog/my-post, the route is src/pages/blog/[slug].astro. If Webflow uses /blog-posts/, keep /blog-posts/. You don't fix URLs during a migration.

Step 6 · Content: from the Webflow CMS to content collections

The principle. A script reads the Webflow API, writes one Markdown file per published item, downloads every image, and converts the rich text without losing captions or videos. You can rerun it as many times as you need until the cutover.

What the API returns, and the pitfalls of each field type:

Webflow fieldWhat the API returnsWhat to do
Rich textHTML (w-richtext-figure-type-image or -video figures, w-embed blocks)Convert it to Markdown with rules for figures, videos and blocks. Never “fix” the Markdown with regular expressions afterward
Image{ fileId, url, alt }Download it into src/assets/, keep the alt text
Multi-imageA list of imagesSame, as a list
OptionThe option's ID, not its labelLook up the label in the collection schema
ReferenceThe ID of the referenced itemReplace it with its slug (two passes: read everything, then write)
Multi-referenceA list of IDsSame
Empty fieldMissing, or null: you will see bothEvery optional field must be marked optional in the schema

Other API rules: 100 items at most per request (you paginate), 60 requests per minute on smaller plans and 120 on CMS plans (beyond that, a 429 response and a delay to respect), and slugs are unique per collection only. You create the token in Site settings > Apps & integrations > API access, with CMS read permissions. Only a site admin can create it. Keep it out of the code (in an environment variable), and delete it once the migration is done.

The API access section of the Webflow settings, with the Generate API token button
Site settings, Apps & Integrations tab: this is where you generate the token, and read-only access is enough.

⚠️ No MDX for imported content. ZenML measured it: MDX interprets braces and Webflow's raw HTML as code, and would have broken 1,904 files. Plain Markdown (.md) is enough.

The full script (scripts/export-webflow.mjs):

JavaScript
// scripts/export-webflow.mjs
// Exports every published collection of the Webflow CMS as Markdown files for Astro,
// and downloads every image and every file to disk.
//
// Usage:  WEBFLOW_TOKEN=xxx WEBFLOW_SITE_ID=xxx node scripts/export-webflow.mjs
// Requires: Node 22 or later, and `npm install turndown`
// Create the token in Webflow: Site settings > Apps & integrations > API access (CMS read access).

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 and WEBFLOW_SITE_ID are required');
const headers = { Authorization: `Bearer ${WEBFLOW_TOKEN}`, accept: 'application/json' };

// ── 1. API calls: pause on 429 (too many requests), retry on a server or network error ──
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 = network error
    if (res?.status === 429) {
      const retry = Number(res.headers.get('retry-after')); // in seconds; an HTTP date would give NaN
      const seconds = Number.isFinite(retry) && retry > 0 ? retry : 60;
      console.log(`429: pausing for ${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 ?? 'network error'} on ${url}`);
  }
}

// Webflow returns items in batches of 100 at most.
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. Downloading files: Webflow links break if the site is deleted ──
const downloaded = new Map(); // original URL -> local file, so nothing is downloaded twice
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(`⚠️  file ${res?.status ?? 'network error'}: ${url}`); // fix by hand (see step 2)
    return null;
  }
  // A name with no spaces or parentheses (or the Markdown link breaks), and never the same one twice.
  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. Rich text: Webflow HTML -> Markdown, without losing captions or videos ──
const turndown = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced', bulletListMarker: '-' });
turndown.keep(['iframe']);
// A “<” typed in a post (e.g. “the <head> tag”) must stay text and never become a tag.
const escapeMarkdown = turndown.escape.bind(turndown);
turndown.escape = (text) => escapeMarkdown(text).replace(/</g, '\\<');
turndown.addRule('webflow-figure', {
  filter: 'figure', // images and videos inserted in the rich text (w-richtext-figure-type-*)
  replacement: (_content, node) => {
    const iframe = node.querySelector('iframe');
    if (iframe) { // the video keeps its proportions (Webflow set them on a wrapper, lost here)
      iframe.setAttribute('width', '100%');
      iframe.setAttribute('height', '100%');
      return `\n\n<div style="aspect-ratio: 16 / 9">${iframe.outerHTML}</div>\n\n`;
    }
    const img = node.querySelector('img');
    if (!img) return '';
    const alt = (img.getAttribute('alt') ?? '').replace(/[[\]]/g, '\\$&');
    const caption = node.querySelector('figcaption')?.textContent.trim();
    return `\n\n![${alt}](${img.getAttribute('src')})${caption ? `\n\n*${turndown.escape(caption)}*` : ''}\n\n`;
  },
});
turndown.addRule('webflow-embed', {
  filter: (node) => node.nodeName === 'DIV' && (node.classList.contains('w-embed') || node.hasAttribute('data-rt-embed-type')), // “Embed” blocks
  replacement: (_content, node) => `\n\n${node.innerHTML.trim()}\n\n`,
});

async function richTextToMarkdown(html, assetsDir, contentDir) {
  for (const [, src] of html.matchAll(/<img[^>]+src="([^"]+)"/g)) {
    const file = await download(src.replaceAll('&amp;', '&'), assetsDir);
    if (file) html = html.replaceAll(src, () => relative(contentDir, file)); // a function: otherwise “$&” would still be interpreted
  }
  // Files linked in the rich text (PDFs…), on all of Webflow's file domains: into public/, at a fixed URL.
  const filesDir = path.join('public/files', path.basename(assetsDir));
  for (const [, href] of html.matchAll(/<a[^>]+href="(https?:\/\/(?:uploads-ssl\.webflow\.com|[\w.-]+\.website-files\.com)\/[^"]+)"/g)) {
    const file = await download(href.replaceAll('&amp;', '&'), filesDir);
    if (file) html = html.replaceAll(href, () => '/' + relative('public', file));
  }
  return turndown.turndown(html);
}

// ── 4. First pass: read everything and record each item's slug (for references) ──
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}`); // the schema: the list of fields
  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} published items`);
}

// ── 5. Second pass: write one .md file per item ──
const urls = [];
const lastmod = {}; // URL -> date of the last change, for the sitemap (step 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; // empty field: omitted, not null
      const key = camel(field.slug);

      switch (field.type) {
        case 'RichText': {
          const markdown = await richTextToMarkdown(value, assetsDir, contentDir);
          if (!body) body = markdown; // the first rich text field becomes the page body
          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': { // PDFs and other files: in public/, to keep a fixed URL
          const file = await download(value.url, path.join('public/files', collection.slug));
          if (file) data[key] = '/' + relative('public', file);
          break;
        }
        case 'Option': // Webflow returns the option's ID: write its label instead
          data[key] = field.validations?.options?.find((option) => option.id === value)?.name ?? value;
          break;
        case 'Reference': // a Webflow ID -> the slug of the referenced item
          data[key] = slugById.get(value) ?? value;
          break;
        case 'MultiReference':
          data[key] = value.map((id) => slugById.get(id) ?? id);
          break;
        default: // text, number, date, switch, color, link, email, video
          data[key] = value;
      }
    }

    // JSON is valid YAML: every value can be written without worrying about escaping.
    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}`; // the real prefix can differ from the collection slug: fix it here
    urls.push(url);
    lastmod[url] = item.lastUpdated; // or item.lastPublished: keep whichever matches the <lastmod> in Webflow's sitemap
  }
}

// The list of CMS URLs, to cross-check against the inventory, and each URL's date for the sitemap.
await fs.mkdir('migration', { recursive: true });
await fs.writeFile('migration/cms-urls.txt', urls.join('\n') + '\n');
await fs.writeFile('migration/lastmod.json', JSON.stringify(lastmod, null, 2) + '\n');
console.log(`Done: ${urls.length} Markdown files, ${downloaded.size} files downloaded.`);

What the script produces for one post:

Markdown
---
webflowId: "p0"
name: "How to choose a host"
slug: "how-to-choose-a-host"
mainImage: "../../assets/blog/62b7..._cover.png"
mainImageAlt: "A server room"
category: "SEO"
author: "ada"
featured: true
publishedOn: "2026-05-01T00:00:00.000Z"
---

## The first criterion

Some **bold** text and an [internal link](/blog/another-post).

![Diagram](../../assets/blog/62b7..._schema.png)

*The image caption*

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

The collection schema (src/content.config.ts). It describes every field, and the build fails if any content doesn't match it: that is the safety net that replaces Webflow's checks.

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

const authors = defineCollection({
  loader: glob({ base: './src/content/authors', 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('authors').optional(),
      featured: z.boolean().default(false),
      publishedOn: z.coerce.date().optional(),
    }),
});

export const collections = { authors, blog };

The post page (src/pages/blog/[slug].astro):

Astro
---
// src/pages/blog/[slug].astro: the same path as on Webflow (/blog/my-post)
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;
// The post's share image, as on Webflow (where the collection template usually binds it to the main image): a single size
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>By {author.data.name}</p>}
    {post.data.mainImage && <Image src={post.data.mainImage} alt={post.data.mainImageAlt ?? ''} />}
    <Content />
  </article>
</Layout>

The post list (src/pages/blog/index.astro). ⚠️ Astro doesn't guarantee the order of a collection's entries: always sort them yourself.

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

For a paginated list, Astro provides paginate() in getStaticPaths, with a src/pages/blog/[...page].astro route (/blog, /blog/2…). Webflow's pagination URLs (?xxxx_page=2) have no equivalent: they fall back to the first page, whose canonical points to /blog, which has no notable consequence.

The end-of-step check: for each collection, the number of files equals the number of items published in Webflow. The script prints it. Then search for anything that still points to Webflow, under all its names: depending on the site's age, files live on cdn.prod.website-files.com, assets-global.website-files.com or uploads-ssl.webflow.com.

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

The command must return nothing, except a deliberate link to Webflow's own website. Otherwise, each line is a failed image (left as a remote URL) or a file the script didn't pick up: download it by hand (into src/assets/ for an image, public/files/ for a file), and replace the link with the new URL. The old domain written out in full must not appear in internal links either.

This script ran unmodified against the real API, on the demo site (5 items, 5 images), and its conversions were tested on tricky cases: a “<” typed in a post, a caption with asterisks, a video, an Embed block. Its two additions (files linked in rich text, dates for the sitemap) were only tested against a simulated copy of the API.

Cases the script doesn't cover, to handle by hand:

  • Secondary locales: each locale has its own ID (cmsLocaleId, read from the site settings through the API); rerun the item fetch with ?cmsLocaleId=... and write to src/content/blog/fr/. In content.config.ts, then give each locale its own collection (pattern: '*.md' for the primary locale, a blogFr collection on src/content/blog/fr): with **/*.md, the translated posts would join the main list, and two identical slugs would conflict. References in a translated post point to the translated slug of the target item: fill slugById locale by locale. For translated static pages, the simplest option is to copy the text from the live site.
  • Components inserted in rich text (<wf-component> tags, possible since September 2026): find them in the exported files and replace them with an Astro component.
  • Code blocks in rich text: the API doesn't support them, so check them one by one.

Recommended variant: content in Sanity. ⚠️ This part was not tested in this guide. The configuration below is the one from my stack, proven on a real deployment; the content import, on the other hand, follows Sanity's official method, which I did not replay on the demo site.

Terminal
npm install @sanity/astro @astrojs/react @sanity/client sanity react react-dom styled-components
JavaScript
// astro.config.mjs: the Sanity Studio built into the site, at /studio
import sanity from '@sanity/astro';
import react from '@astrojs/react';

// inside defineConfig({ … }):
integrations: [
  sanity({
    projectId: 'your-project-id',
    dataset: 'production',
    apiVersion: '2024-01-01',
    useCdn: false,                 // every build follows a publish: Sanity's CDN would lag a few seconds behind
    studioBasePath: '/studio',
    studioRouterHistory: 'hash',   // the Studio stays a static page; its internal URLs live after a #
  }),
  react(),                         // for the Studio only: no page of the site loads it
  sitemap(),
],
  • studioRouterHistory: 'hash': without it, the Studio becomes an on-demand page, and Astro 7.4 then attaches its CSS (about 200 KB) to every on-demand page, /contact included.
  • Reading content at build time: a @sanity/client client (createClient, useCdn: false) and GROQ queries in the pages, for example *[_type == "post"] | order(publishedAt desc){ title, "slug": slug.current, excerpt }. Rich text (Portable Text) renders with a library such as astro-portabletext.
  • Importing from Webflow: each item becomes an NDJSON document, with a stable ID derived from the Webflow ID (so you can rerun without creating duplicates); rich text becomes Portable Text through htmlToBlocks from @portabletext/block-tools; images are uploaded to Sanity. Import into a test dataset first (sanity datasets import), then into production. Sanity's official course, Migrating content from Webflow to Sanity, walks through this method in full (see part 9).
  • Republishing: a Sanity webhook calls Cloudflare's rebuild URL on every publish (step 11).

Variant: keep the Webflow CMS (the two-stage migration). Nothing gets copied: Astro reads the CMS on every build. Here is a sketch of a loader, to complete with the 429 handling from the script above:

TypeScript
// src/content.config.ts: the Webflow CMS remains the source of the content
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 responded ${res.status}`); // 429: reuse the handling from the script above
      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 }));
  },
});

These entries have no Markdown rendering: the post page displays their rich text with <Fragment set:html={post.data['post-body']} /> instead of render(), and the fields keep their Webflow names (post-body). The api-cdn.webflow.com host is Webflow's content delivery API: its cached responses have practically no rate limit, but they stay cached for up to 5 minutes (2 on an Enterprise plan), and a published change can take that long to show up. A build started right after a publish can therefore read the old content: read from api.webflow.com at build time, or delay the rebuild. The images then stay on Webflow's CDN: declare its domain in image.remotePatterns so Astro optimizes them. To republish on every change, a Webflow webhook (triggered on publish) calls the host's rebuild URL (“deploy hook”).

Step 7 · Interactions and JavaScript

Webflow interactions don't survive the rebuild. They depend on the webflow.js file, on jQuery, and on attributes Webflow puts on each element (data-w-id, plus data-wf-page / data-wf-site on the <html> tag). As soon as the HTML is rewritten as components, those attributes are gone.

The good news: GSAP, the animation library Webflow's recent interactions are built on, has been free since April 30, 2025, plugins included, commercial use included, following its acquisition by Webflow. The only thing its license forbids: building a no-code visual animation tool that competes with Webflow.

The equivalent, element by element:

Webflow elementIn Astro
Reveal on scrollCSS + IntersectionObserver (a few lines), or GSAP ScrollTrigger
Scroll-linked sequenceGSAP ScrollTrigger. CSS scroll-driven animations (animation-timeline) don't work in every browser yet: use them as a bonus only
Mobile menu (Navbar)A button and about twenty lines of script, or <details>
Dropdown<details> / <summary>, or a button with aria-expanded
TabsA few lines of script with the ARIA roles tablist / tab / tabpanel
SliderCSS scroll-snap scrolling, or a library (Swiper, Embla)
LightboxThe <dialog> element, or a library (PhotoSwipe, GLightbox)
Collection listgetCollection() (step 6)
Paginationpaginate() (step 6)
Finsweet filters (List Filter, List Load, List Nest; formerly CMS Filter, Load More, Nest)Lists are prepared at build time; the filter becomes a few lines of script or an interactive component (an “island”)
Site searchPagefind (below)
Background video<video autoplay muted loop playsinline>
Lottielottie-web or @lottiefiles/dotlottie-web
Page transitionsThe browser's native CSS transitions (@view-transition { navigation: auto; }), which simply do nothing where they aren't supported, or Astro's <ClientRouter />

A GSAP animation in an Astro component. A <script> tag with no attributes is processed by Astro: TypeScript, npm imports, bundling into a single file. The script loads only once per page, even if the component is used ten times.

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

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

  if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
    gsap.utils.toArray<HTMLElement>('[data-anim="fade-up"]').forEach((el) => {
      gsap.from(el, {
        y: 40, opacity: 0, duration: 0.8, ease: 'power2.out',
        scrollTrigger: { trigger: el, start: 'top 85%' },
      });
    });
  }
</script>
  • Respecting prefers-reduced-motion turns the animations off for visitors who asked for that in their system settings.
  • If you add the <ClientRouter /> (navigation without page reloads), scripts run only once for the whole visit: put the code in a document.addEventListener('astro:page-load', …) listener, and clean up the animations before each page change (ScrollTrigger.getAll().forEach((t) => t.kill()) on astro:before-swap).
  • A script that must stay exactly as it is (code supplied by a third-party tool) gets the is:inline attribute.
  • Smooth scrolling (Lenis), which my stack uses: during a migration, only add it if the Webflow site already had it (reproduce first). Sync it with ScrollTrigger as its documentation describes: lenis.on('scroll', ScrollTrigger.update), gsap.ticker.add((t) => lenis.raf(t * 1000)) and gsap.ticker.lagSmoothing(0). Since version 1.3.26 (August 2026), Lenis respects prefers-reduced-motion on its own; GSAP animations, on the other hand, are switched off with gsap.matchMedia().

Reproducing an animation exactly. ZenML filmed every animation on the Webflow site, broke each one down frame by frame, and rewrote it from those frames. That's more reliable than reading the interaction settings in the Designer.

Site search with Pagefind. Pagefind indexes the built pages and weighs only about a hundred kilobytes for most sites. In package.json: "build": "astro build && npx pagefind --site dist/client" with the adapter (that's where the HTML ends up), or --site dist in the minimal variant. Then, on the search page:

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

This snippet uses Pagefind's classic UI, which is still supported; since version 1.5 (April 2026), Pagefind recommends its new components (<pagefind-modal>). The index only exists after a build: search doesn't work in dev mode.

Step 8 · Forms, third-party scripts and integrations

Forms stop working the second the site leaves Webflow: they send their data to Webflow. Each one needs a new destination.

What I recommend, with the adapter: an Astro Action and Resend. The form is a real <form method="POST">, which works without JavaScript. The /contact page is the only one rendered on demand: it validates the fields, filters out bots, and sends the email through Resend's API. No third-party form service.

Tested on a copy of the demo site, on Cloudflare's local server: no form when the secrets are missing (a mailto: link instead), honeypot field, submission too fast, invalid fields, Resend key rejected, submission from another site (rejected, 403). Only the actual sending of an email was not tested.

The three secrets, declared in astro.config.mjs:

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

// inside defineConfig({ … }):
env: {
  schema: {
    // Worker secrets, read by /contact on every request: setting them turns the form on without rebuilding the 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 }),
  },
},

The handler (src/actions/index.ts):

TypeScript
// src/actions/index.ts: the contact form handler, on the server.
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; // faster than that, it's not a human reading the page

const schema = z.object({
  name: 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. Bots: a hidden field a human never fills in, and a minimum delay.
      //    We tell them “sent”: tell a bot no and it adapts.
      const startedAt = Number(form.get('startedAt') ?? 0);
      if (String(form.get('website') ?? '') !== '' || !(Date.now() - startedAt >= MIN_ELAPSED_MS)) {
        return { status: 'success' };
      }

      // 2. Server-side validation, the only kind that counts.
      const parsed = schema.safeParse(Object.fromEntries(form));
      if (!parsed.success) {
        return { status: 'invalid', fields: parsed.error.issues.map((i) => String(i.path[0])) };
      }

      // 3. All three secrets, or nothing.
      if (!RESEND_API_KEY || !CONTACT_FORM_FROM || !CONTACT_FORM_RECIPIENT) return { status: 'error' };

      // 4. Sending: a single call to Resend's API, no SDK.
      const { name, 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],                            // replying to the sender = one click on “Reply”
          subject: `New message: ${name}`.replace(/[\r\n]+/g, ' ').slice(0, 150),
          text: `Name: ${name}\nEmail: ${email}\n\n${message}`,
        }),
      }).catch(() => null);
      if (!res?.ok) {
        console.error('[contact] Resend responded', res?.status, await res?.text()); // the visitor never sees the internal error
        return { status: 'error' };
      }
      return { status: 'success' };
    },
  }),
};

The page (src/pages/contact.astro), with the line that makes it dynamic:

Astro
---
export const prerender = false; // the only page rendered on demand
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(); // when the page was displayed, read back on submit
---
<Layout title="Contact" description="Get in touch">
  {!configured ? (
    <p>Email us at <a href="mailto:contact@example.com">contact@example.com</a>.</p>
  ) : state?.status === 'success' ? (
    <p role="status">Thank you, your message has been sent.</p>
  ) : (
    <form method="POST" action={actions.contact}>
      {state?.status === 'error' && <p role="alert">Sending failed. Email us at <a href="mailto:contact@example.com">contact@example.com</a>.</p>}
      {state?.status === 'invalid' && <p role="alert">Please check these fields: {state.fields?.join(', ')}.</p>}
      <label for="name">Name</label>
      <input id="name" name="name" autocomplete="name" required />
      <label for="email">Email</label>
      <input id="email" name="email" type="email" autocomplete="email" required />
      <label for="message">Message</label>
      <textarea id="message" name="message" required></textarea>
      <!-- The honeypot: invisible, out of the keyboard tab order -->
      <div aria-hidden="true" style="position:absolute;left:-9999px">
        <label>Website <input name="website" tabindex="-1" autocomplete="off" /></label>
      </div>
      <input type="hidden" name="startedAt" value={startedAt} />
      <button type="submit">Send</button>
    </form>
  )}
</Layout>

The secrets are set on the Worker, not in the code: npx wrangler secret put RESEND_API_KEY (and the other two), or Settings > Variables and Secrets in the dashboard. No build is needed. Locally, a .dev.vars file (never committed) supplies them to wrangler dev.

The sending domain at Resend. Until it's verified, the test address onboarding@resend.dev only sends to the Resend account's own address. To send from the site's domain, Resend asks you to add the records shown in its Records tab: usually a DKIM key (TXT resend._domainkey), plus an SPF (TXT) and an MX on the send sending subdomain. For a domain added since August 2026, Resend may offer CNAMEs instead: on Cloudflare, leave them as “DNS only” (grey cloud), or verification fails. You add records, you don't modify any (unless send already has some), and the domain's existing email is untouched.

The alternatives, without the adapter (minimal variant):

SolutionFree tier (as of Oct 20, 2026)When to use it
Netlify FormsUnlimited on credit-based plansIf the site is on Netlify: one attribute on the form is all it takes
Web3Forms250 submissions per monthBrochure sites, no server
Formspree50 submissions per monthSame, with a full dashboard
HubSpot, Brevo, your CRMDepends on the toolIf contacts need to land directly in a CRM
Astro Actions + an email serviceDepends on the hostCustom logic; requires a page rendered on demand, so an adapter

Against spam, on top of the honeypot field: Cloudflare Turnstile is free (up to 20 widgets, unlimited challenges). On Web3Forms, it's reserved for the paid plan.

HTML
<!-- Netlify: the form is detected at build time. Turn on “Form detection” in the dashboard. -->
<form name="contact" method="POST" data-netlify="true" action="/thank-you">
  <label for="email">Email</label>
  <input id="email" type="email" name="email" required />
  <label for="message">Message</label>
  <textarea id="message" name="message" required></textarea>
  <button type="submit">Send</button>
</form>

<!-- Formspree, Web3Forms, Basin: only the submission URL changes -->
<form action="https://formspree.io/f/YOUR_FORM_ID" method="POST">…</form>

The test that matters: a form can display “Thanks, it's sent” while nothing arrives anywhere. Test the whole chain: validation, delivery, notification, arrival in the CRM, conversion event in Google Tag Manager.

The rules for forms:

  • same fields, same recipient, same page or same thank-you message as before;
  • thank-you pages carry a noindex and are left out of the sitemap;
  • file attachments: check that the service you choose accepts them (Formspree's free plan, for example, doesn't).

Third-party scripts (Google Tag Manager, analytics, chat, cookie banner, Calendly):

  • copy the original code as is from Webflow's settings into the shared layout, with is:inline;
  • the native integrations found at step 1 have no code to copy: add their official tag to the shared layout, with the same ID. Also open your Google Tag Manager triggers: those aimed at the Webflow form (the .w-form-done class, submission without a page reload) will no longer fire. Rewrite them on the new form's thank-you page or message;
  • ⚠️ Partytown (which moves third-party scripts off the main thread) caused an infinite loop with the cookie banner at StackOne, along with lost sessions in GA4. They went back to loading Google Tag Manager the normal way. Don't add it during a migration;
  • ⚠️ preview URLs must not send data to your measurement tools (Google Analytics, GTM and the scripts copied as is): ZenML saw its analytics fire from every preview, because the import.meta.env.PROD variable is true on previews too. Make your measurement scripts depend on a variable that only production sets.

Connected tools: Memberstack works with any JavaScript site, so you reconnect it. Jetboost only works on Webflow: it has to be replaced. Weglot can stay, or make way for real translated pages (Omnia replaced it with 315 translated files and clean Spanish URLs).

Step 9 · Technical SEO

The head component (src/components/seo/BaseHead.astro), used by every page:

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

interface Props {
  title: string;
  description: string;
  image?: string;     // share image: a path served by the site (/og/home.jpg in public/, or a URL produced by getImage())
  noindex?: boolean;  // for one specific page (e.g. /thank-you)
  alternates?: { hreflang: string; href: string }[]; // the page's versions in each language ("Languages", below)
  ogTitle?: string;   // these three: only for the exceptions found in the baseline crawl
  ogDescription?: string;
  canonical?: string; // if Webflow's canonical pointed to another URL
}
const { title, description, image = '/og-default.jpg', noindex = false, alternates = [], ogTitle = title, ogDescription = description } = Astro.props;
// With build.format: 'file', the path seen at build time ends in .html (/blog.html, /index.html):
// strip it, or the canonical contradicts the sitemap and the Webflow URLs.
const pathname = Astro.url.pathname.replace(/(\/index)?\.html$/, '') || '/';
const canonical = new URL(Astro.props.canonical ?? pathname, Astro.site); // always the final domain
const ogImage = new URL(image, Astro.site);
// noindex only where it's explicitly requested: a forgotten variable
// leaves staging indexable (fixable), never 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 />

A trap found while testing this guide.

With build.format: 'file', the path Astro sees during the build ends in .html. The canonical code you find everywhere (new URL(Astro.url.pathname, Astro.site)) then produces https://www.example.com/blog/my-post.html and https://www.example.com/index.html, while the sitemap lists /blog/my-post and /. The pathname line above fixes that mismatch. Check the canonical of three pages before the cutover.

What you reproduce exactly, page by page (compared against the crawl from step 1):

  • the title and description;
  • a single H1, and a heading hierarchy that doesn't skip levels. On Webflow, a component can look like a heading without carrying the heading tag: the problem is semantic, not visual;
  • the share image, specific to each post if Webflow's collection template bound it to a CMS image (the step 6 code does this);
  • the exceptions found in the crawl: an Open Graph title or description that differs from the SEO title and description, a canonical that points to another URL. The head component has the ogTitle, ogDescription and canonical props for these: fill them in only for those pages. If Webflow's description came from a field other than summary, change that field in the post page;
  • the structured data (JSON-LD) that already existed, often hidden in the pages' custom code;
  • the image alt text;
  • internal links: real <a href> links, never elements made clickable with JavaScript.

The sitemap. @astrojs/sitemap produces sitemap-index.xml (and sitemap-0.xml), where Webflow served /sitemap.xml. Two things to do:

  • a redirect from /sitemap.xml to /sitemap-index.xml (step 10);
  • a public/robots.txt that keeps the rules of Webflow's own (saved at step 1) and points to the new sitemap URL. If Webflow had no special rules, this is enough:
Text
User-agent: *
Allow: /

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

If Webflow served an /llms.txt, copy it to public/llms.txt: it keeps its URL. If the sitemap URL appears in it, replace it with /sitemap-index.xml.

A detail: a modification date (lastmod) that is the same on every page, set to the day of the build, tells search engines nothing. Webflow published the real date of each item: keep it. The step 6 script writes it to migration/lastmod.json, and the integration reads it:

JavaScript
// astro.config.mjs: the real date of each CMS URL, written by the step 6 script
import lastmod from './migration/lastmod.json' with { type: 'json' };
const dates = new Map(Object.entries(lastmod));

// in integrations, instead of the sitemap line from step 3:
sitemap({
  filter: (page) => !page.endsWith('/thank-you'),
  serialize: (item) => {
    const date = dates.get(new URL(item.url).pathname);
    return date ? { ...item, lastmod: date } : item; // no known date, no lastmod: better than a wrong date
  },
}),

The RSS feed, if the blog had one. Webflow serves it under the collection's path, usually /blog/rss.xml, not /rss.xml: the exact URL is shown in RSS Feed Settings, in the collection template's settings. The file reproduces that path. Then compare the two feeds: every <guid> must stay identical to Webflow's (the post's URL, without a trailing slash), or feed readers and automated RSS-to-email campaigns treat every post as new. Also keep, on the blog pages, the <link rel="alternate" type="application/rss+xml" href="/blog/rss.xml"> tag that Webflow added. ⚠️ @astrojs/rss adds a trailing slash to links by default, whatever the site's own setting:

JavaScript
// src/pages/blog/rss.xml.js: the same URL as the Webflow feed (/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: 'All posts',
    site: context.site,
    trailingSlash: false, // otherwise the feed adds a / the site doesn't serve
    items: posts.map((post) => ({ title: post.data.name, pubDate: post.data.publishedOn, link: `/blog/${post.id}` })),
  });
}

The 404 page (src/pages/404.astro): a real 404 status, a link to the home page, a noindex. On Cloudflare with the adapter, the Worker serves it itself with its 404 status; in the minimal variant, that's the not_found_handling: "404-page" setting.

HTTP to HTTPS, and HSTS. Webflow redirects every http:// URL to https:// with a 301, and can send an HSTS header. A Worker, on the other hand, also answers over HTTP unless something stops it: on the demo site, http:// returns 200. Before the cutover, turn on SSL/TLS > Edge Certificates > Always Use HTTPS on the zone, then check on launch day: curl -sI http://www.example.com/some-page must return 301 to https://www.example.com/some-page. For HSTS, record the current header (curl -sI https://www.example.com | grep -i strict) and, if there is one, reproduce it exactly in the zone (same screen): usually max-age=31536000 (12 months), without includeSubDomains or preload. Adding those two options during the migration would make any subdomain without HTTPS unreachable.

Other security headers, and legal notices. Webflow set some security headers itself: record them with the same curl -sI. Headers set in public/_headers only apply to static files, not to responses from the Worker (/contact): those go through an Astro middleware. In France, the mandatory legal notice (mentions légales) names the host: replace Webflow with Cloudflare, and add the new subprocessors (Cloudflare, Sanity, Resend) to the privacy policy.

Languages. If Webflow serves the main language at the root and the others in a subfolder (/fr/...), Astro does the same:

JavaScript
// in astro.config.mjs
i18n: {
  defaultLocale: 'en',
  locales: ['en', 'fr'],
  routing: {
    prefixDefaultLocale: false, // English stays at the root, French goes under /fr/
    fallbackType: 'rewrite',    // only if Webflow already served these untranslated /fr/ URLs (see below)
  },
  fallback: { fr: 'en' },
},

Keep fallback and fallbackType: 'rewrite' only if the baseline crawl shows that Webflow already served those untranslated /fr/… URLs. Otherwise, remove both settings: Astro would build an English page under /fr/ for every untranslated page, URLs that didn't exist before and that Google treats as duplicates.

Then the hreflang tags, which Webflow Localization wrote on its own, in each page's head and in its sitemap. Astro writes none. Three rules:

  • The same codes as Webflow (fr, not fr-FR, if the baseline crawl shows fr) and the same target for x-default. The lang attribute of the <html> tag follows the page's language: that's the lang prop of the shared layout (step 5).
  • Find a translation by its Webflow ID, never by its slug. Webflow can translate slugs, but every version of an item shares the same ID: that's the webflowId field the script writes into each file. A page lists only the published translations, and each one lists all the others, itself included: without a return link, Google ignores the tags.
  • The sitemap follows the same table, or carries no hreflang. The i18n option of @astrojs/sitemap pairs URLs that have the same path after the language prefix: with translated slugs, it produces wrong pairs. In that case, don't turn it on: the tags in the head are enough, and Google treats both methods as equivalent.
Astro
---
// src/pages/blog/[slug].astro (primary locale): add this to the step 6 code
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}` }] : []), // only if the translation is published
  { hreflang: 'x-default', href: `/blog/${post.id}` },             // the same target as on Webflow
];
---
<Layout lang="en" alternates={alternates} …>  <!-- on top of the step 6 props -->

The head component writes one tag per entry, as an absolute URL, as Google requires (the alternates prop). The French page does the same lookup the other way round; for static pages, write the table by hand, from the baseline crawl. The easy.tools team found 40 hreflang errors in its pre-launch audit: compare each page's tags with the baseline crawl, line by line. This code was tested on a two-language Astro 7.4.1 project, not on the demo site, which has only one.

Step 10 · Redirects

With the adapter (the recommended stack): Astro's redirects config. The Cloudflare adapter writes it into _redirects itself, as real 301s served before the Worker:

JavaScript
// astro.config.mjs: one line per old URL, pointing to its final destination
redirects: {
  '/sitemap.xml': { status: 301, destination: '/sitemap-index.xml' },
  '/news/site-visit': { status: 301, destination: '/blog/site-visit' },
  '/projects': { status: 301, destination: '/' }, // the project list now lives on the home page: same content, not a catch-all
},

⚠️ A trap found while testing this guide: no wildcards in the redirects config. A dynamic rule ('/news/[slug]' to '/blog/[slug]') gets rewritten by the adapter to /blog/:slug.html, which Cloudflare then redirects to /blog/:slug: a chain of two redirects (301 then 307), measured on Oct 21, 2026 with adapter 14.4.1. With build.format: 'directory', the destination gets /index.html appended: same chain. If you need wildcards, keep all your redirects in public/_redirects instead, with no redirects config (the adapter copies the file as is): that's what the demo site does. What matters is having a single source. Rules written URL by URL come out clean. And that's what the step 1 inventory gives you anyway: one line per URL.

Minimal variant (no adapter): the host's file. For a static site without an adapter, Astro's redirects config produces HTML pages with a “meta refresh” redirect, with no real 301 status. Google treats them as permanent, but recommends server-side redirects whenever possible. And don't mix the two mechanisms: with the adapter, Astro's rules are appended to the end of a hand-written public/_redirects, which therefore takes precedence, with no warning.

HostFileDefault statusLimits
Cloudflarepublic/_redirects⚠️ 302: write 301 on every line2,000 static rules + 100 dynamic rules, including the lines the adapter writes from the redirects config; beyond that, the zone's Bulk Redirects
Netlifypublic/_redirects301No hard limit; beyond 10,000, use wildcards
Vercelvercel.jsonpermanent: true gives a 308; statusCode: 301 is accepted2,048 rules in vercel.json; beyond that, Bulk Redirects (CSV import, Pro and Enterprise plans). ⚠️ By default they return 307 and drop the query string: set permanent: true (or statusCode: 301) and preserveQueryParams: true on every line
Text
# public/_redirects (Cloudflare and Netlify)
# Order matters: rules are read from top to bottom.
/sitemap.xml          /sitemap-index.xml   301
/old-page             /new-page            301
/blog-old/*           /blog/:splat         301

Carrying over existing Webflow redirects. Webflow writes its wildcards as /old-folder/(.*) to /new/%1. In _redirects, that becomes /old-folder/* to /new/:splat. An automatic conversion of the exported CSV (two columns: old URL, new URL):

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

Webflow rules that use several groups (%1, %2) have to be rewritten by hand. The script also strips the % that Webflow puts before special characters in the old URL (/old%-folder/), and assumes no field in the CSV contains a comma: read through the file first. With the adapter, the same list becomes lines in the redirects config, with no wildcards.

The rules:

  • one line per URL that changes; in _redirects, specific cases first, general rules (a whole folder) after: the first matching rule wins;
  • never a chain: an old URL goes straight to its final destination, even if it already went through a Webflow redirect;
  • never a mass redirect to the home page: Google treats it as a page not found (a “soft 404”);
  • a deleted page returns a 404, not a redirect to an unrelated page. Google treats all 4xx codes the same way (except 429): a 404 is as good as a 410, so there's no need to add an on-demand page just to send a 410;
  • the new site's internal links point straight to the new URLs, never to a redirect;
  • keep the redirects for at least a year, and preferably forever. That's Google's recommendation.

Step 11 · Handing over to the team

If the team can no longer publish, the migration has failed, even if the site is faster. Finn Dollimore, in the middle of a migration to Astro and Sanity, sums it up in one sentence: the biggest challenge of leaving Webflow is that the client loses visual editing.

What not to do: rebuild Webflow's Editor yourself. He showed an in-house tool his brother had built (a canvas of the pages, click to edit, a publish button), and derived a stripped-down prototype from it for a client: it works, but it's too complex to maintain. His conclusion: “Why reinvent the wheel?” Sanity's visual editing (the Presentation tool) already does click-to-edit, live preview and page building. ⚠️ It requires a preview route rendered on demand, in addition to /contact.

The guardrails, whatever the tool:

  • Predefined blocks, not a blank page. The team builds its pages from the closed list of sections (step 5), with colors and typography locked. In Sanity, that's a “page builder” field that only accepts those sections. The team keeps the freedom it had in Webflow without being able to step outside the brand guidelines. Without this guardrail, the pages added over the months, by hand or by an AI, drift until they no longer look like the brand;
  • required fields: image alt text, SEO title and description;
  • roles: who can publish, who can only suggest changes;
  • a preview before publishing, on a staging URL;
  • automatic republishing: every publish in the CMS triggers a build (a webhook from the CMS to the host's rebuild URL). On Cloudflare, your Worker > Settings > Builds > Deploy Hooks creates the URL, which you keep secret (it triggers a build); in Sanity, manage > API > Webhooks calls it on create, update and delete, with no document type filter (a forgotten type never reaches the site). Publish a document and watch the build start. Expect anywhere from a few seconds to a few minutes before the change is live, depending on the size of the site, and tell the team: it's the only real difference from Webflow's “Publish” button;
  • a one-page guide, with screenshots;
  • a “who owns what” list: the domain registrar, the DNS zone, the Cloudflare account, the GitHub repository, the Sanity project, the Resend account, the owners of Search Console and of the measurement tools. Everything in the client's name, with at least two admins;
  • backups and updates: a regular export of the content (sanity datasets export for Sanity), and a named person in charge of updating Astro and the dependencies (part 1.2).

The test that matters: someone on the team publishes a post, alone, with no help, before launch.

Step 12 · The QA pass, before the cutover

Staging is set to noindex (variable PUBLIC_DEPLOY_ENV=preview). Everything that follows is tested on staging.

Every URL in the inventory responds. This script reads the list of paths (one per line) and flags anything that isn't a page, or a single permanent redirect to a page:

Terminal
#!/usr/bin/env bash
# check-urls.sh: tests every URL in the inventory the way a browser does, and follows every redirect to the end
# Usage: ./check-urls.sh https://preview.my-site.workers.dev migration/urls.txt
BASE="$1"; LIST="$2"
NAV=(-H 'Sec-Fetch-Mode: navigate')   # a browser's header: without it, curl misses the not_found_handling trap (step 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) ;;                                                     # the page responds: nothing to report
    301|308)                                                    # follow the redirect to the final page
      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 "REDIRECT  $p -> $target"
      else echo "⚠️  CHAIN OR BROKEN DESTINATION  $p -> $target ($hops redirects, final response $final)"; fi ;;
    *) echo "⚠️  $code  $p" ;;                                  # 404, 302, 307, 500: to fix
  esac
done < "$LIST"

A 302 flagged here is almost always a _redirects line without 301 on Cloudflare (minimal variant; Astro's redirects config uses 301 by default). A “chain or broken destination” is a redirect whose destination redirects again, or doesn't return a 200: point the line straight at the final URL. It's often an old Webflow redirect copied as is in step 10. A 404 is only expected for a URL the inventory decided to remove.

What the test for this guide returned (minimal variant, build.format: 'file', Cloudflare locally then on the real network; with the adapter, same responses):

Requested URLResponse
/blog/my-post200
/blog/my-post/307 to /blog/my-post
/blog/my-post.html307 to /blog/my-post
/index.html307 to /
/sitemap.xml301 to /sitemap-index.xml
/Blog/my-post (uppercase)404, as on Webflow
/does-not-exist404, with the site's 404 page

The trailing-slash variants respond with a temporary redirect (307) to the clean URL, locally and on Cloudflare's real network alike (checked on the demo site on Oct 21). That's harmless as long as the canonical and every internal link use the URL without the slash, but check the behavior on your actual host: reports from people who have done it contradict each other on this point.

The QA checklist

Step 13 · The cutover

Two weeks before, if the site is moving to Cloudflare Workers: move the DNS zone. A custom domain on Workers requires the domain to be managed by Cloudflare (its nameservers). You make this move before the cutover, without changing anything on the site:

  • add the domain in Cloudflare, which imports the existing zone, then compare it line by line with the old one (MX, SPF, DKIM, DMARC, domain verifications): the automatic import misses records, DKIM keys in particular;
  • on import, Cloudflare turns the proxy on by default (orange cloud): set to “DNS only” (grey cloud) both the records that point to Webflow, so Webflow keeps serving the site, and everything that doesn't serve the site: the mail server's subdomain, autodiscover, verification or DKIM signing CNAMEs for tools like HubSpot or Resend;
  • if DNSSEC is turned on at your registrar, turn it off before changing the nameservers (remove the DS record): otherwise the domain becomes unreachable, site and email included. Turn it back on once the zone is active in Cloudflare (DNS > Settings), by adding at the registrar the new DS record that Cloudflare shows;
  • change the nameservers at the registrar, check that email still goes out and comes in that same day, and leave the zone in place at the old provider for at least a week: during propagation, some DNS servers still query it.

A few days before:

  • lower the cache duration of your DNS records (TTL) at least a week before, as Google recommends (a cautious low value, on the order of a few hours; I go down to 300 seconds), and in any case at least as far ahead as the old duration;
  • write down (or screenshot) the entire current DNS zone: that is your rollback plan;
  • add the site to Bing Webmaster Tools by importing it from Search Console: it is verified right away, and you need that to submit the sitemap there on launch day;
  • freeze publishing on Webflow, and rerun the export script just before the cutover to pick up the latest content.

On launch day:

  • change only the site's records. On Workers: delete the record that points to Webflow, then add the custom domain to the Worker (Settings > Domains & Routes), for the primary domain only (the one you had before, with or without www). Elsewhere: the A, AAAA and CNAME records of the domain and of www. The site moves, the email stays put: MX, SPF, DKIM and DMARC stay untouched;
  • the primary domain stays the same as before, with or without www. The other version gets a proxied placeholder record (orange cloud: AAAA to 100::) and a redirect rule in the zone (Rules > Redirect Rules, not Astro's redirects config): 301, a wildcard that keeps the path (for example https://example.com/* to https://www.example.com/${1}), Preserve query string turned on. A rule pointing to a fixed URL would send every page to the home page. Also turn on Always Use HTTPS (SSL/TLS > Edge Certificates): Webflow redirected http:// to https:// on its own, and HSTS doesn't replace this setting, since a browser ignores that header when it arrives without HTTPS. Check: curl -sIL 'http://example.com/about?utm_source=test' | grep -i -E '^(HTTP|location)' must end in a 200 on https://www.example.com/about?utm_source=test, after 301s only, two at most;
  • Webflow stays online. If something goes wrong, you remove the domain from the Worker and put back the record that pointed to Webflow: a few minutes. Decide ahead of time what triggers that rollback (pages returning errors, a form that sends nothing, a traffic drop), and keep publishing frozen as long as the question is open;
  • check production with curl: curl -sI https://www.example.com/an-old-page must return 301 and the right URL, and curl -s https://www.example.com/ | grep -i noindex must return nothing. A staging noindex that survives into production is the mistake that makes a site invisible overnight;
  • rerun the step 12 script against the final domain, on the whole list: ./check-urls.sh https://www.example.com migration/urls.txt. Production adds what staging doesn't have (zone rules, www, firewall, cache): only this run checks it all;
  • if a CDN (Cloudflare as a proxy, for example) sat in front of Webflow, purge its cache: Webflow sends a header that keeps pages cached for several days, and ZenML served old pages after its cutover until the purge;
  • check that the custom domain is active at the host, several times during the day (ZenML saw its own get deactivated twice, on Cloudflare Pages);
  • once the domain is active, turn off the production Worker's workers.dev URL: "workers_dev": false in wrangler.jsonc, then redeploy (the dashboard toggle isn't enough: Wrangler turns it back on at the next deploy). Otherwise a full copy of the site, without noindex, stays online at my-site.<account>.workers.dev: check that it no longer responds. If your staging runs on preview URLs, add "preview_urls": true: by default, they turn off along with workers.dev;
  • annotate the cutover date in Search Console (right-click the Performance chart, Add annotation) and in GA4 (same gesture on a chart);
  • submit sitemap-index.xml in Search Console and in Bing Webmaster Tools, and remove the old /sitemap.xml from Search Console's list of sitemaps. Optional: report the URLs that changed to Bing and the other engines of the IndexNow protocol (Google isn't one of them), in a single request;
  • check the robots settings of the Cloudflare zone (Security Settings > Configure AI bot policies). A zone added to Cloudflare since September 15, 2026, which is your case if you moved it two weeks before, blocks bots classified as “Training” and “Agent” by default on pages that display ads, and Cloudflare notes that this also hits crawlers used for both search and training. If your site displays ads, choose what you block yourself. Then two checks: curl https://your-domain/robots.txt (these settings add a block at the top of the file), and above all, in Search Console, URL Inspection on the home page and two important pages, then Test Live URL: the result must be “URL is available to Google”. A firewall block doesn't show in robots.txt;
  • ⛔ do not use Search Console's “Change of Address” tool. Google says so in writing: it is only for moving from one domain or subdomain to another. Here the domain does not change, only the platform does.

Step 14 · The 30 days after

Google warns that during a site move, visibility can fluctuate temporarily. The job is to tell a fluctuation from a loss.

  • Every day for two weeks, then every week for two or three months: in Search Console, the Page indexing report (errors, excluded pages), the Crawl stats, URL Inspection on the pages that matter, and the Google-selected canonical (it tells you whether Google kept the one you declare). In the Crawl stats, look at the breakdown by response: Google warns that crawling drops right after a hosting change, then climbs back within a few days. Rising 403s, or crawling that doesn't recover, point to a block; the zone's security events log in Cloudflare, filtered on “Googlebot”, tells you which rule caused it.
  • A missing redirect gets fixed the day you spot it. StackOne saw 404s appear on old Webflow slugs after its launch; they turned each one into a 301 redirect.
  • 404s show up the same day in your analytics tool: the 404 page is displayed at the requested URL. Filter page views on its title (for example “Page not found”): you get the list of missing URLs without waiting for Search Console.
  • Compare traffic and rankings with the baseline measurements, page by page, not just in total. Track Google Images traffic separately for the 30 days (Performance, Search type: Image): the images have changed URLs (step 2).
  • In the Sitemaps report, the number of indexed pages for the new sitemap should go up. The Core Web Vitals report only reflects the new site after 28 days of real-user data (if it gets enough visits): that report, not Lighthouse, tells you whether visitors gain anything.
  • For the URLs that changed, the redirect keeps the value of the links, but get the busiest ones fixed: find in Search Console's Links report the sites that point to each old URL, and in your analytics tool the ones that send the most visits, then write to the top 5 to 10. Fix what you control yourself: social media profiles, your Google Business Profile, email signatures, the landing URLs of your ad campaigns.
  • Only then, and never before 30 days (Page indexing report stable, no new 404s for a week, page-by-page traffic within its usual range), cancel the Webflow site plan. Just before, while the plan is still paid, turn off indexing for the subdomain (Site settings > SEO > Indexing, Staging indexing off, then publish) and check https://your-site.webflow.io/robots.txt: this setting requires a paid plan, and a site without a plan stays online at its .webflow.io URL. ⚠️ Never delete the Webflow project: the images and files hosted by Webflow only respond as long as it exists. Keep the code export, the CMS export and the form submissions archived. Note the plan's renewal date at the start of the project: an annual plan renews automatically, and Webflow doesn't refund the period already paid. Monitoring, for its part, continues every week for two or three months.
  • Then, and only then, the improvements: redesign, new pages, new animations. One at a time, each one measured.

Part 4 · Migrating with an AI agentBuild

Most of the migrations written up in 2026 were done with Claude Code, sometimes in a few days for sites with several hundred pages. The same write-ups show where it breaks.

4.1 What the successful migrations had in common

  • A rules file (CLAUDE.md) that the agent rereads at every session, and a lessons file where every costly mistake is written down so it doesn't happen twice (VGV kept one).
  • Phases with a “definition of done”: infrastructure, export, schemas, pages, SEO and redirects, forms and QA (ZenML, six phases). You don't move on to the next phase until the previous one is done.
  • An independent review. ZenML had another AI model review the end of each phase, and an audit of the plan against the export found seven critical problems (slugs, missing checks, items without an SEO block) before a single page was built.
  • Automated checks: a build that fails on invalid content, screenshots compared with the Webflow site, a crawl compared with the baseline crawl.
  • Extraction scripts you can rerun, rather than a one-off conversion done by hand (VGV).
  • A human who stays in charge of DNS and SEO. The migration skill published by Omnia deliberately stops before the DNS cutover.

4.2 What derails a migration

  • Copying page by page with no design system: the styles drift (easy.tools).
  • Asking the AI to infer the design system from the live site: Webflow's HTML is too flat (Felix Dorner).
  • No preview, no review, no rollback: every fix breaks something else. The accounts of sites replaced by Claude Code alone, without these safeguards, describe the same sequence: the Search Console errors show up within days.
  • Giving the agent total freedom after the migration: “AI-based workflows are free. Too free.” That is the line from the team that moved back to Webflow (Reddit, July 2026; the post's authenticity has been questioned, but its diagnosis is widely shared).
  • A tool setup that doesn't fit: in Claude Desktop, an MCP server (the connector that gives access to the Webflow CMS) is attached to the account, not to the project. If you work on several sites, use Claude Code in the project folder.

4.3 The rules file to copy

Put it at the root of the project, named CLAUDE.md:

Markdown
# Webflow to Astro migration: project rules

## Non-negotiable
- No URL changes. Every page keeps its exact Webflow path (see migration/inventory.csv).
- We reproduce, we don't redo: no changes to design, copy or structure during the migration.
- No invented content. Missing text gets flagged in migration/open-questions.md, it doesn't get written.
- Colors, typography and spacing come from the tokens (src/styles/tokens.css, or Tailwind's @theme block). No hard-coded values.
- A page is assembled only from the components in src/components/sections/. A new need is a question, not a new component.
- You don't touch DNS, Search Console or the host's settings.

## How we work
- One phase at a time (see migration/plan.md). A phase is done when its definition of done is true, not before.
- After each page: `npm run build`, then a screenshot compared with the Webflow site at 375, 768 and 1440 px widths.
- Whatever cost time goes in migration/lessons.md. You reread it at the start of every session.
- A decision that isn't technical (removing a page, changing a URL, rewriting copy) goes in migration/open-questions.md. You don't make it.

## References
- The live site: https://www.example.com (visual reference)
- The code export: webflow-export/ (reference, never imported into src/)
- The content: src/content/ (generated by scripts/export-webflow.mjs) or the test Sanity dataset. Don't edit it by hand before the migration is finished: it gets regenerated.

And migration/plan.md, with the phases and their definition of done:

Markdown
# Migration plan

1. Inventory: done when every URL has a row and a decision (migration/inventory.csv).
2. Export: done when the number of files equals the number of published items, and no Webflow URL remains in the content: `grep -rnIE "website-files\.com|webflow\.(com|io)" src public` returns nothing.
3. Design system: done when the tokens and the shared layout reproduce the site at 375, 768 and 1440 px.
4. Pages: done when every layout has been compared with the Webflow site in screenshots.
5. SEO and redirects: done when the staging crawl matches the baseline crawl, and every old URL returns 200, or a single 301 to a page that returns 200, or 404 if the inventory decided so.
6. Forms and QA: done when a test email has arrived and the QA checklist is fully checked.
7. Cutover: launch day, then 30 days of monitoring.

4.4 Useful tools

  • Webflow's MCP server: the agent reads the CMS and the pages directly. Handy for the inventory; pair it with the script from step 6 for a complete, reproducible export.
  • The Astro docs MCP server and Context7: the agent writes with the syntax of the installed version, not the one it remembers. That matters: Astro 6 removed the old collections API and Astro.glob().
  • Playwright: screenshots and tests across three browsers.
  • A Search Console connector: StackOne used one to turn the 404s found after launch into redirects.
  • Omnia's migration skill, published as open source (github.com/useomnia/skills): phases with checkpoints, and decisions handed back to a human. ⚠️ Published by a company that sells an agent: read it as a method, not as a guarantee.

Part 5 · The real numbersDecide

Public write-ups from 2026, written by the teams themselves.

WhoSizeTime takenResult
ZenML (03/2026)2,224 pages, 20 collections, 2,340 items, 2,397 images and files (384 MB)A week of evenings, with Claude CodeLighthouse 99 on desktop, 97 on mobile. ~$800/year saved, but “that alone wouldn't have justified the migration”
VGV (03/2026)309 pages, 204 posts, 1,189 images, 12 third-party servicesUnder two weeksNo broken links, more than 1,500 automated tests
StackOne (03/2026)42 templates, 914 generated pagesTwo weeks, with three people (two founders, one marketer)Mobile Lighthouse from 42 to 92. A new page: from 24-72 h down to 1-2 h
easy.tools (08/2026, write-up published on mega.dev)924 pages in two languages, 21 collectionsNot statedPre-launch audit: 60 missing routes, 830 mismatched titles, 40 hreflang errors, 14 H1 problems found. At launch: 920 URLs out of 920, no broken links
Omnia (10/2026)~800 pages in two languages23 h up to the DNS cutover, ~$1,200 of AI usageImages from 396 MB down to 138 MB, 490 alt texts written. Source: a company that sells an agent
Felix Dorner (04/2026)Company site (Foxglove) moved off Webflow Enterprise, led by a designerAbout a monthNo developer, with Claude Code, one component at a time

This guide's case: Atelier Nord, migrated on October 21, 2026

A small site, built on Webflow for this guide and then migrated by following the 14 steps, first in the minimal variant (100% static, content in Markdown), then with the contact page moved to the recommended stack (adapter, Action + Resend; without a Resend key, the page shows a mailto: link): 4 pages, 2 collections (posts and authors), 3 posts, one form. Fictional studio, real numbers. The migrated site is live: atelier-nord.astro-cf-spike.workers.dev.

PageMobile Lighthouse, Webflow → AstroMain content display (mobile LCP)
Journal (list of posts)87 → 984.0 s → 2.3 s
Post82 → 984.7 s → 2.1 s
Home98 → 972.2 s → 2.5 s
Contact (rendered on demand)98 → 97-1002.0 s → 1.7-2.0 s
  • URLs: all 7 in the inventory respond, none lost. Three 301 redirects in place, which Webflow's free plan would not allow.
  • The export script from step 6 worked on the first run against the real API: 5 items, 5 images downloaded, the image caption kept, the author and category references converted to text.
  • Speed: a clear gain on the blog pages, none on the home page, which Webflow was already serving fast. On a small, well-built site, speed is not what justifies leaving.
  • What cost time: the inventory. A broken link on the Webflow side hid the whole blog from the crawl (step 1).

What these numbers say, and what they don't:

  • They show that the build goes fast with a well-supervised AI agent.
  • They don't show the time spent on the inventory, the QA pass and the follow-up, which most write-ups leave out.
  • None of the write-ups I read reports a traffic loss after a migration done with complete redirects. None of them publishes a six-month traffic curve either. Any promise of “zero loss, guaranteed” is marketing.

Part 6 · The 20 pitfalls that keep coming backDecideBuild

  1. Migrating the code export instead of the design and the content. The export is not a site, it's a reference (and Astro 7 rejects its HTML if it has unclosed tags).
  2. Missing a URL that isn't in the sitemap. The crawl and Search Console find it; the sitemap never will.
  3. Losing the images: they stay on Webflow's CDN, and nothing guarantees they will stay online once the site is deleted. Some already return 403.
  4. Losing the captions, videos and Embed blocks in rich text through an HTML-to-Markdown conversion with no rules, or breaking hundreds of files by choosing MDX.
  5. Leaving not_found_handling in place with the adapter: on-demand pages (form, Studio) get the 404 in a real browser, while curl sees them respond.
  6. Forms that say “sent” while nothing arrives.
  7. Deleting a thank-you page that served as a conversion target.
  8. The staging noindex that survives into production.
  9. A canonical that ends in .html with build.format: 'file'.
  10. Redirects that aren't 301s: a _redirects line with no status code (302 on Cloudflare), or a wildcard in Astro's redirects config that creates a 301-then-307 chain.
  11. A redirect chain, or dozens of URLs redirected to the home page.
  12. The trailing slash: /page becomes /page/, and every URL changes at once.
  13. Changing a collection's prefix (/blog/ to /resources/) “while you're at it”.
  14. Redoing the design at the same time: if traffic moves, there is no way to know why.
  15. Building the pages before the design system: the brand drifts.
  16. Forgetting the secondary languages: the code export only contains the primary language, and Webflow redirects don't apply to translated URLs.
  17. Password-protected pages, which become public on a static site if you don't handle them.
  18. Analytics that count preview visits, and Partytown breaking the cookie banner.
  19. Touching the email records (MX) during the DNS cutover.
  20. Canceling Webflow too early, before 30 days of monitoring and before downloading everything.

Part 7 · The complete checklistBuild

Decide
Inventory
Export
Build
Editing
QA
Cutover
After

Part 8 · Glossary

  • 301 / 308: permanent redirect. The search engine transfers the page to the new URL.
  • 302 / 307: temporary redirect. The search engine keeps the old URL in memory.
  • 404 / 410: page not found / deliberately removed.
  • Adapter: the module that lets Astro build pages on demand on a given host.
  • Build: the step where Astro generates every page of the site, once, before they go live.
  • Canonical: the tag that tells the search engine which URL you put forward as the official one for a page (it may pick another).
  • CDN: the network of servers that delivers the site's files from as close to visitors as possible.
  • Headless CMS: a content editing tool separate from the site, which delivers the content through an API.
  • Content collection: in Astro, a content type (posts, authors) with its fields and its entries.
  • Crawl: an automated visit of every page of a site, the way a search engine would do it.
  • DNS: the directory that tells which server a domain name points to.
  • MX record: the DNS entry that says where the domain's email is delivered.
  • Frontmatter: the header of a Markdown file, between two --- lines, that holds the content's fields.
  • hreflang: the tag that links the versions of the same page in different languages.
  • Island: an interactive component isolated within a static page.
  • Loader: in Astro, the function that fetches a collection's content (from files, or from an API).
  • MCP: the protocol that lets you connect an AI agent to a tool; an “MCP server” is the connector for one specific tool (the Webflow CMS, Search Console).
  • noindex: the directive that asks search engines not to index a page.
  • Slug: the part of the URL specific to one page (my-post in /blog/my-post).
  • Token (design): a design system value named once (a color, a size) and reused everywhere.
  • Token (API): the key that authorizes a script to read a service's data.
  • TTL: how long a DNS record is kept in memory before it is looked up again.
  • Webhook: an automatic message one tool sends to another when an event happens (a publish, for example).
  • Zod: the library that describes and validates the shape of content in Astro.

Part 9 · The best resources

First-hand accounts (the most useful, in order)

Technical references

The cautionary tale

Videos

  • Finn Dollimore, Update: Migrating from Webflow to full code (Astro) (06/2026): why to leave, and when not to. youtube.com/watch?v=W4ni39Hrf5I
  • Finn Dollimore, Watch this if you're migrating from Webflow to Astro (07/2026): the editing problem, and Sanity's visual editing. youtube.com/watch?v=oZMsT2i5aSI
  • Learn with Temlis, Migrate Any Webflow Site to Astro in Minutes with Claude AI (06/2026): AI-assisted conversion, from the export + the live site + the CMS through MCP. The demo stops before the content and the animations. youtube.com/watch?v=Th0xObu-TGU

Official documentation

Tools

  • @finsweet/webflow-astro (native Webflow elements as Astro components, very new) · Pagefind (search) · Turndown (HTML to Markdown) · GSAP and Lenis (animations, scrolling) · Resend (form emails) · Screaming Frog (crawl) · Playwright (visual tests)

This guide is updated whenever Astro, Webflow or the hosts change something important. If you're preparing a migration and want a second pair of eyes on your inventory, book 30 minutes with me.

GOT A PROJECT? LET'S TALK.

Website, web app or AI: we look at your project together, in 30 minutes. Free, no strings attached.