Webflow to Astro: what the migration actually looks like in code
Most articles about this describe the decision. This one shows the four things somebody has to write: what a Webflow collection becomes, how the content comes out, what an exported section turns into, and the one part of the old site that has to survive untouched.
What is in this
I build on Webflow for a living and I still do this move a few times a year, which is the only reason I am willing to write it down. Most sites should stay where they are. The case for leaving is narrow and I have argued it on the migration page, so this post assumes the decision is made and gets to the part nobody publishes: the code.
Four things carry the whole job. Everything else is a consequence of getting them right.
The collection is a schema, not a folder
A Webflow collection is a content model with types in it. The mistake is treating the export as a pile of files and discovering the model later, from the errors.
Write the schema first. Here is a Blog Posts collection with an author reference, a category multi-reference and a required hero image, as Astro content collections:
// src/content.config.ts
import { defineCollection, reference, z } from 'astro:content';
import { glob } from 'astro/loaders';
const posts = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/posts' }),
schema: z.object({
// Webflow gives every item a Name and a Slug. Everything below is yours.
title: z.string(),
slug: z.string(),
excerpt: z.string().max(200),
// A Webflow date field arrives as an ISO string.
published: z.coerce.date(),
// Reference and multi-reference fields come out as item ids. They become
// relations here, and the build fails if one points at nothing.
author: reference('authors'),
categories: z.array(reference('categories')).default([]),
hero: z.object({ src: z.string(), alt: z.string() }),
draft: z.boolean().default(false),
}),
});
export const collections = { posts }; The schema is the migration's contract. An item that fails it fails at build time rather than rendering a blank template in production.
Validation makes supported content errors visible earlier. Define the referenced collections as well as the article collection, and verify relationships through the actual query and rendering path. The abbreviated schema above is not a complete project.
The content comes out through the API, one page at a time
The site export gives you HTML, CSS, JavaScript and assets. It does not give you the CMS. That comes from the Data API, and the only real constraint is that you cannot ask for it all at once.
// Every item in a collection, 100 at a time.
const listItems = async (collectionId, token) => {
const all = [];
let offset = 0;
while (true) {
const res = await fetch(
`https://api.webflow.com/v2/collections/${collectionId}/items` +
`?limit=100&offset=${offset}`,
{ headers: { authorization: `Bearer ${token}` } }
);
if (!res.ok) throw new Error(`${res.status} at offset ${offset}`);
const { items, pagination } = await res.json();
all.push(...items);
offset += items.length;
if (offset >= pagination.total) return all;
// Stay under the rate limit rather than discovering it at item 400.
await new Promise((r) => setTimeout(r, 1100));
}
}; This abbreviated loop illustrates pagination. It does not persist a resume checkpoint, handle retry headers or guard against an unexpected empty page; add those controls before using an importer in production.
Use the source API’s current rate-limit documentation and response headers. A fixed delay is not a general guarantee of compliance, and the final import must account for content edited during the rebuild.
Three things in the response surprise people the first time:
- Everything you authored is under `fieldData`, keyed by the field's slug. The item's own
id,lastPublishedandisDraftsit outside it. - Reference values need explicit mapping to destination records. Export the relevant collections and choose an import strategy that can resolve their relationships.
- Rich text is an HTML string, with Webflow's figure and image markup inside it. If the new site stores Markdown, that conversion is a real step with real edge cases, and the images inside it point at Webflow's CDN until something rewrites them.
A section becomes a component, not a page
The export's markup is evidence of what the design is. It is not the architecture. Webflow names classes for the Designer, and those names describe where a thing sits rather than what it is:
<!-- From the Webflow export -->
<div class="section-3 hero-wrapper">
<div class="w-layout-blockcontainer container-2 w-container">
<div class="hero-content-wrap">
<h1 class="heading-2">Enterprise-grade Webflow</h1>
<p class="paragraph-4">Shipped at startup speed.</p>
<a href="/book" class="button-primary w-button">Start a project</a>
</div>
</div>
</div> The same section, once it knows what it is:
---
// src/components/Hero.astro
interface Props {
title: string;
lede: string;
cta?: { label: string; href: string };
}
const { title, lede, cta } = Astro.props;
---
<section class="hero">
<div class="shell">
<h1>{title}</h1>
<p>{lede}</p>
{cta && <a class="btn" href={cta.href}>{cta.label}</a>}
</div>
</section> The example separates the hero’s content contract from its exported wrapper structure; styling and behavior still need implementation and verification.
section-3 and heading-2 are Designer bookkeeping. hero-wrapper is the only name in that markup that says anything, and the container divs exist because the Designer needed somewhere to hang a max width. A component keeps the design and drops the scaffolding.
This is where the time goes, and it is also where the payoff is. Twenty pages built from eight components is a different maintenance job from twenty pages of exported markup, and the second one is not better than the Webflow site it replaced.
The URLs do not move
Everything above changes. The public addresses should not.
// astro.config.mjs
export default defineConfig({
redirects: {
// Only the paths that genuinely changed shape.
'/blog/posts/[slug]': '/blog/[slug]',
'/resources/2024-guide': '/blog/webflow-migration-guide',
},
}); Six rules beats six hundred, and the way to get six is to keep the paths.
Check what your host does with that. With an adapter that writes native redirect rules you get real 301s. A plain static build emits small HTML pages that redirect the browser, which works for a person and is a weaker signal than a status code, so this is worth verifying on the actual deployment rather than assuming.
The bigger rule is upstream of the config: a framework change is not a reason to restructure the site. If /blog/webflow-seo works today and the new site has no reason to disagree, it stays /blog/webflow-seo and no rule is needed at all.
Can you export Webflow to Astro?
Partly, and the half that does not export is the half that matters.
Webflow's export gives you the rendered HTML, the CSS, the JavaScript and the assets for the static pages. That is real and it is useful: it is the visual source of truth for the rebuild, and it saves measuring type and spacing off a screenshot. What it is not is an Astro project. There are no components in it, no routes, no layouts, and no content model, and the class names in it describe where things sit rather than what they are.
The CMS does not come out that way at all. Collection content is a separate job through the Data API, as above, and the form handling, search, memberships and localization were platform features rather than files, so none of them appear in an export either.
So the honest answer is that an export shortens the rebuild and does not replace it. Anybody who tells you the site converts is quoting for the first hour of the work.
Treat the snippets as boundaries, not a complete importer
The examples above illustrate the main interfaces in a migration. A production project also needs the collections referenced by the article schema, the route that queries those records, the body renderer, asset handling and the deployment configuration. Keep those responsibilities visible when turning a sample into working code.
In particular, a reference to an authors collection does not define that collection. Create the referenced content types and decide how their identifiers relate to the original Webflow item IDs. Test resolution through the same query and rendering path the website uses. Validation rules are useful, but they do not replace checking the resulting relationships.
Preserve source identity throughout the transformation
An importer should carry the source item ID from extraction through the final report. Slugs are useful route data, but they may change. Stable source identity makes it easier to rerun the migration and understand which destination record corresponds to an original item.
A conceptual transformation record could look like this:
{
"sourceId": "example-source-article-id",
"destinationId": "article-example-source-article-id",
"sourceUrl": "/blog/example-guide/",
"destinationUrl": "/blog/example-guide/",
"publicationState": "published",
"warnings": []
} This is an illustrative record shape, not a Webflow response or an Astro API. It gives the migration code a common language for logging, route generation and reconciliation. The actual fields should match the requirements of the project.
Keep the URL decision separate from the identifier mapping. Two records should not collapse into one merely because their titles are similar, and a renamed article should not become a new record merely because its slug changed.
Make pagination failures explicit
A pagination loop needs more than a happy-path stopping condition. Consider an empty response before the expected total, a rate-limit response, a network interruption and content changing while the export runs. Each case should produce an intentional outcome.
Use the API's current limits and retry information rather than assuming that a fixed sleep guarantees compliance. Save a checkpoint if the job must resume after interruption. The checkpoint should record enough context to establish which collection, extraction and offset it belongs to.
Do not describe a loop as resumable unless the implementation actually persists and accepts that state. Likewise, appending records to an array is not a guarantee of a complete export. Reconcile the extracted identifiers against the expected inventory and investigate unexplained differences.
For a final migration run, agree how source edits are controlled. A consistent snapshot or managed final update is easier to validate than an export whose records change unpredictably while pages are being requested.
Connect content to the route deliberately
The route should express the public URL contract. If the existing address remains appropriate, preserve it and map the migrated content to it. Do not let a convenient filename convention silently restructure the archive.
Test the route with representative content, including missing optional values and difficult rich text. Confirm that the title, author, body, assets and metadata come from the intended record. A route that returns a successful response with the wrong article is a migration failure.
Where a path genuinely changes, verify the behavior on the deployed host. Configuration syntax alone does not prove the visitor receives the intended status and destination. The deployment environment is part of the implementation.
Add a reconciliation report before calling the script finished
| Check | What it catches |
|---|---|
| Source IDs versus destination IDs | Missing, duplicate or unexpected records |
| Publication states | Drafts accidentally published or public records omitted |
| Resolved relationships | Missing or incorrect authors and categories |
| Asset references | Pages still depending on storage meant to be retired |
| Expected URLs versus observed routes | Missing pages and unintended path changes |
| Representative rendered bodies | Lost tables, embeds, links or formatting |
The report should distinguish failures from deliberate exclusions. If ten archived records are intentionally omitted, record those IDs and the reason. Otherwise a later reviewer cannot tell whether the difference was approved or accidental.
A useful importer can explain what it did, repeat its work safely and expose what it could not handle. That is the difference between a one-off conversion script and a migration process the team can trust.
For the broader content pipeline, read Webflow to Sanity migration. For the release checks around the code, use the migration checklist.
The parts this post deliberately skips
Everything after the rebuild is the same work as any other migration, and none of it is Astro-specific. Getting the real URL list out of Search Console rather than out of the navigation, and proving the map after launch. The forms, the analytics and the structured data that go quietly, because nothing on the page looks wrong when they do. The order the cutover runs in, and what a normal dip looks like before you start pulling things apart. The migration page sets out the stages each of those falls into.
Read together with the four samples above, that is the whole job: model it, export it, rebuild it, and leave the addresses alone.
If you are at the point where this looks like your site, tell me about it. What I need first is the collection list, the number of published items and whether anybody outside engineering expects to keep publishing. The third answer decides more than the first two.
Also worth reading
-
Migrations
What a handover actually includes
A handover is not a zip file and a call. It is the accounts in your name, the repository you can build, the decisions written down, and a first change your team ships without the person who built the site.
-
Content
Who changes what, once the site is live
Most teams divide a website into content and code and find that half their work falls between the two. The useful split is by who reviews the change, not by where it is stored.
-
Migrations
What makes a site safe for an agent to change
AI can write the code. The harder problem is a codebase where the right change is obvious, the wrong one gets caught, and a person can approve the result without reading every line.
Reach out and see if we are a good fit.
Currently booking two to four weeks out.