Webflow CMS to Astro Content Collections
The CMS data layer only: pull collections through the Webflow Data API, map field types onto Astro content collection schemas, convert rich text HTML, resolve reference fields, and rehost images.
MigrateLab Team
Migration Experts

Read the Collection Schema Before You Read the Content
The Webflow CMS is a typed database, and the fastest way to lose a week is to start pulling items before you know what the fields are. Fetch the collection definition first, derive your Astro content collection schema from it, and only then pull the items. Every hard problem in this migration, reference resolution, option values, rich text, image rehosting, is a property of the field type rather than of the individual item.
This page covers only the CMS data layer: extraction, field mapping, rich text, references, images, and schema definition.
For the migration end to end, including planning and rebuild, see the complete Webflow to Astro migration guide.
For how the two platforms differ as products, see the Astro vs Webflow comparison.
For why Astro suits content sites in general, see why Astro is a strong framework for marketing sites.
Your Three Ways Out, and Only One of Them Is Real
Code export does not include CMS content. This is the single most common wrong assumption. Webflow's code export gives you HTML, three CSS files, a JS folder, Assets panel images, and Collection template pages that are structurally complete but empty. Collection items and Collection lists are explicitly excluded, along with Ecommerce, form submission processing, site search, password protection, and non-primary locales. Code export is also gated on the Workspace plan (Core, Growth, Freelancer, Agency), not on the Site plan. A free Starter Workspace does not have it. So export gives you the shell of your CMS templates and none of the data that goes in them.
CSV export is available per collection from the CMS panel. It is genuinely useful for a small blog and genuinely lossy beyond that. What you lose relative to the API:
The field schema. CSV is a flat grid of strings. Nothing tells you which column was a Number, a Switch, an Option, or a Reference, so you are reverse engineering types from values. A Switch column of
true/falseand a plain text column containing the word "true" look identical.Stable reference identity. The API returns reference fields as item IDs, which are unique and resolvable. A CSV cell gives you a human readable value, which is not guaranteed unique across a collection and breaks the moment two items share a name.
Structured image metadata. The API returns an image as an object with a file ID, a URL, and an alt string. CSV gives you a URL, so per image alt text is gone unless it also lives in a separate text field.
Draft and archived state. The API exposes
isDraftandisArchivedper item as real booleans.
The Data API is the only extraction path that preserves types, IDs, and item state. Use it for anything past a single simple collection.
Pulling the Data
Generate a site API token under Site Settings, Apps and Integrations, API access, and send it as a bearer token. The v2 endpoints you need are few:
`` GET /v2/sites list sites, get the site ID GET /v2/sites/{site_id}/collections list collections, get collection IDs GET /v2/collections/{collection_id} the FIELD SCHEMA for one collection GET /v2/collections/{collection_id}/items staged items, includes drafts GET /v2/collections/{collection_id}/items/live published items only ``
Two details that decide how your script is shaped.
Staged versus live is an endpoint, not a parameter. /items returns the staged set, which includes drafts and unpublished edits. /items/live returns only what is published. If you migrate from /items without filtering on isDraft, half finished drafts appear on your new site. If you migrate from /items/live, drafts are silently dropped. Decide which one you want per collection and be explicit about it.
Pagination is offset based and caps at 100. Pass limit and offset, and read pagination.total off the response to know when to stop. Do not assume the total is stable mid-run if editors are still working in Webflow.
Rate limits are per API key, and they are the reason a large backfill needs a queue rather than a for loop. Webflow documents 60 requests per minute on Starter and Basic plans, and 120 on CMS, Ecommerce and Business plans. Read X-RateLimit-Remaining off each response and back off on a 429 rather than guessing at a fixed sleep. A site with 12 collections and a few thousand items across them will spend real time here, and re-running a partially failed pull is a lot cheaper if you cache each raw response to disk before transforming anything.
The Item Envelope
Every item comes back with the same shape, and the distinction between the envelope and fieldData matters when you write the frontmatter:
``json { "id": "6420f0e0a1b2c3d4e5f60718", "cmsLocaleId": "6420f0e0a1b2c3d4e5f60000", "lastPublished": "2026-05-02T09:14:22Z", "lastUpdated": "2026-05-02T09:12:10Z", "createdOn": "2026-01-18T11:03:44Z", "isArchived": false, "isDraft": false, "fieldData": { "name": "How We Rebuilt Onboarding", "slug": "how-we-rebuilt-onboarding", "post-body": "<p>...</p>", "author": "6420f0e0a1b2c3d4e5f60999" } } ``
name and slug always exist inside fieldData. Everything else is keyed by the field slug, not the display name, which is why you need the collection schema to know that the column labelled "Author" is reachable as author. Timestamps live on the envelope, not in fieldData, so if your Astro schema has a publishedAt, it is coming from lastPublished or createdOn rather than from a field.
Mapping Webflow Field Types to Zod
| Webflow field type | Comes back as | Astro schema | | --- | --- | --- | | PlainText | string | z.string() | | RichText | HTML string | the body of the file, not a frontmatter key | | Number | number | z.number() | | DateTime | ISO 8601 string | z.coerce.date() | | Switch | boolean | z.boolean() | | Color | hex string | z.string() | | Link, Email, Phone | string | z.string().url() or z.string() | | Option | option identifier | z.enum([...]) after mapping | | Image | { fileId, url, alt } | image() from the schema context | | MultiImage | array of image objects | z.array(image()) | | File | { fileId, url } | z.string() | | Reference | single item ID string | reference('collection') | | MultiReference | array of item ID strings | z.array(reference('collection')) |
Two of these routinely bite.
Option fields do not hand you the label. The item returns an option identifier, and the human readable name lives in the collection schema under that field's validations.options, as a list of { id, name } pairs. Build that lookup while you have the schema in hand and translate to a slugified label before writing frontmatter, otherwise your z.enum() is validating against opaque IDs and every future editor sees gibberish. If your own export shows names rather than IDs, the mapping step is a no-op and costs nothing, so build it either way.
Empty is not the same as absent. Webflow omits keys from fieldData entirely when a field has never been filled, rather than sending null. A schema written from one well populated item will fail on the first sparse one. Mark anything not required in Webflow as .optional(), and let the build tell you which ones you guessed wrong about.
Rich Text: What Webflow's HTML Actually Contains
Rich text arrives as an HTML string with Webflow's own class names baked in, and a naive turndown pass will quietly discard content. The structures to write explicit rules for:
Figures. Images are not bare
<img>tags. They are wrapped as<figure class="w-richtext-align-center w-richtext-figure-type-image">containing a<div>with the<img>, and often a<figcaption>. Default HTML to Markdown conversion keeps the<img>and drops the caption, so captions vanish without an error.Video embeds. Same figure pattern with
w-richtext-figure-type-video, wrapping an<iframe>. Markdown has no representation for this, so either preserve the iframe as raw HTML in MDX or convert it to a component and pass the video URL as a prop. Deciding this once beats hand fixing it per post.Custom embeds. Blocks added through the rich text embed control come through inside
w-embedwrappers and can contain arbitrary script tags. Decide deliberately whether those survive the migration.Alignment and sizing classes.
w-richtext-align-fullwidthand friends carry layout intent that has no Markdown equivalent. Either map them to component props or accept that everything becomes default width.
The workable order is to parse the HTML, rewrite the figure and embed structures into the shape you want, and only then convert to Markdown. Converting first and repairing the Markdown afterwards means doing it with regexes against text that has already lost the class names that told you what each block was.
Because rich text becomes the file body rather than a frontmatter value, a collection with two rich text fields does not map cleanly onto one Markdown file. Pick the primary one for the body and keep the secondary as an MDX component or a separate rendered field.
References and the Ordering Problem
Reference fields return Webflow item IDs, not slugs. Astro's reference() wants the target entry's ID within its own collection, which after import is normally the file slug. So every reference needs translation through a map you do not have until you have read the target collection.
This is where a single pass import breaks. A post references an author; the author collection references a featured post. There is no order in which you can write both files with references already resolved, because each depends on the other being written first. Two collections can also reference each other transitively through a third.
The reliable shape is a two pass import:
Pass one. Read every collection, and build one global map of Webflow item ID to
{ collection, slug }. Write nothing yet. This pass is the only thing that has to happen before anything else.Pass two. Write the files, resolving every reference and multi-reference ID through the map at write time.
Three failure modes worth handling explicitly in pass two:
Dangling references. An ID that is not in the map, usually because the referenced item is a draft or was archived and you pulled the referencing collection from
/items/livewhile the target came from a different endpoint. Astro'sreference()validates at build time, so this surfaces as a build failure rather than a broken page, which is the good outcome, but only if you kept the check.Multi-reference order. MultiReference arrays are ordered in Webflow and that order is often meaningful, for example a manually curated related posts list. Preserve array order rather than sorting.
Self references. A collection referencing itself is legal in Webflow and is handled fine by the two pass approach, but it will infinite loop any recursive resolver written the obvious way.
Rehosting Images
Do not ship a site that reads images from cdn.prod.website-files.com or the legacy uploads-ssl.webflow.com. Those assets belong to a Webflow site you are about to stop paying for, and the whole point of the migration is to stop depending on it. Rehost everything.
The sequence:
Collect every URL. Two sources, not one: the
urlon image and multi-image field objects, and thesrcof every<img>inside rich text HTML. Missing the second set is the usual reason a migration looks complete and then shows broken images inside article bodies.Download into `src/assets/`, not `public/`. Files under
src/assets/go throughastro:assets, which generates responsive formats and hashes filenames at build time. Files inpublic/are copied verbatim with no processing, which throws away most of the reason to leave a hosted platform.Deduplicate by file ID. The same asset reused across items gives you the same
fileId, so key your download cache on that rather than on the URL, and keep a URL to local path map for the rewrite step.Rewrite references. Replace the CDN URL in both frontmatter and body content with the local path. This has to happen after the download step so the map is complete.
Carry alt text across. Image field objects have an
altproperty, and rich text<img>tags carry their ownalt. These are different sources and both need handling. Where Webflow has none, the import is a reasonable moment to fill the gaps.
Defining the Collection
In Astro 5 the config lives at src/content.config.ts and every collection declares a loader. Earlier versions used src/content/config.ts without one.
```ts import { defineCollection, reference, z } from 'astro:content' import { glob } from 'astro/loaders'
const posts = defineCollection({ loader: glob({ pattern: '**/*.mdx', base: './src/content/posts' }), schema: ({ image }) => z.object({ title: z.string(), slug: z.string(), publishedAt: z.coerce.date(), isDraft: z.boolean().default(false), category: z.enum(['engineering', 'product', 'company']), cover: image().optional(), coverAlt: z.string().optional(), author: reference('authors'), related: z.array(reference('posts')).default([]), }), })
const authors = defineCollection({ loader: glob({ pattern: '**/*.md', base: './src/content/authors' }), schema: ({ image }) => z.object({ name: z.string(), avatar: image().optional(), featured: reference('posts').optional(), }), })
export const collections = { posts, authors } ```
image() is only available through the schema context callback, which is why the schema is a function rather than a plain object. It resolves the path relative to the content file and hands the component a typed image with known dimensions.
Validating
Run astro build. Content collection validation is what turns a silent data problem into a stack trace, and it catches the things that matter here: missing required fields on sparse items, a type that did not survive the transform, an option value outside the enum, and a reference pointing at an entry that does not exist.
Then check the content, which the build cannot do for you:
Item counts per collection against Webflow, including drafts if you kept them.
A rich text post containing a figure with a caption, a video embed, and a nested list. These break independently of each other.
An item with every optional field empty.
An item whose multi-reference list is long enough to show ordering.
Every image rendering, in body content as well as in frontmatter.
Other Things That Bite
`w-richtext` wrapper classes survive conversion if you convert before cleaning. Strip Webflow's class names in the HTML stage.
Webflow's `srcset` markup does not transfer and should not. Let
astro:assetsgenerate responsive sources from the original.Localized content. Non-primary locales are excluded from code export entirely, and through the API each locale is a separate
cmsLocaleId. A multi-locale site is a materially larger job than a single-locale one.Slug collisions. Webflow slugs are unique per collection, not per site. Two collections can both contain
about, so namespace by collection when you write files.
Content extraction is the part of a platform move most likely to fail quietly, because a missing caption or a dropped alt attribute does not throw an error, it just is not there. If you would rather have this pipeline built and verified for you, MigrateLab does the extraction, transformation and validation end to end. Send us the site for a free review.
CMS Data Migration Process
Read the collection schemas
Fetch each collection definition and record field slugs, field types, option lists and reference targets.
Tip: Option fields return an option identifier, not the label. The name lives in the field validations.options list.
Extract items via the Data API
Pull items through the Webflow Data API. Code export does not include CMS content, and CSV drops field types.
Tip: Rate limits are per API key: 60 requests per minute on Starter and Basic, 120 on CMS, Ecommerce and Business. Pagination caps at 100 items.
Define Astro content schemas
Map Webflow field types onto Zod in src/content.config.ts, using reference() for relations and image() for assets.
Tip: Webflow omits keys entirely for never-filled fields rather than sending null, so mark anything not required as .optional().
Resolve references in two passes
Build a global map of Webflow item ID to slug, then write files and resolve every reference on write.
Tip: Collections referencing each other cannot be resolved in one pass. Preserve multi-reference array order, it is often curated.
Rehost images and validate
Rehost every image into src/assets/, from image fields and from img tags inside rich text, then rewrite the paths.
Tip: Run astro build to catch missing fields, wrong types, invalid enum values and references pointing at entries that do not exist.
Frequently asked questions
- Does Webflow code export include my CMS content?
- No. Code export gives you HTML, three CSS files, a JS folder, Assets panel images and Collection template pages that are structurally complete but empty. Collection items and Collection lists are explicitly excluded, along with Ecommerce, form submission processing, site search, password protection and non-primary locales. Export is also gated on the Workspace plan (Core, Growth, Freelancer, Agency) rather than the Site plan, so a free Starter Workspace does not have it. CMS content has to come out through the Data API or a CSV export.
- Should I use the Webflow Data API or CSV export?
- Use the API for anything past a single simple collection. CSV is a flat grid of strings, so it carries no field type information, no per image alt text, and no stable reference identity: you get a human readable value where the API gives you a unique item ID. The API also exposes isDraft and isArchived per item as real booleans. CSV is fine for a small blog with plain text and one rich text field.
- What are the Webflow API rate limits for a large CMS export?
- Webflow documents 60 requests per minute on Starter and Basic plans and 120 on CMS, Ecommerce and Business plans, applied per API key. Item pagination caps at 100 per request, so a collection of several thousand items is dozens of calls before you touch the others. Read X-RateLimit-Remaining off each response and back off on a 429 rather than guessing a fixed sleep, and cache each raw response to disk so a partial failure does not mean starting over.
- How do Webflow reference fields map into Astro content collections?
- A Reference field returns a single Webflow item ID and a MultiReference returns an array of them. Astro reference() expects the target entry ID within its own collection, which after import is normally the file slug, so every reference needs translating through a map. Because collections can reference each other in both directions, no single ordering works: read every collection first and build one global map of Webflow item ID to collection and slug, then write files in a second pass and resolve references at write time.
- What breaks when converting Webflow rich text to Markdown?
- Images are not bare img tags. Webflow wraps them in figure elements carrying w-richtext-figure-type-image classes, usually with a figcaption, and a default HTML to Markdown conversion keeps the image and silently discards the caption. Video embeds use the same figure pattern around an iframe, which Markdown cannot represent at all. Custom embeds arrive inside w-embed wrappers. Parse and rewrite the HTML structures first, then convert, rather than converting and repairing the Markdown afterwards.
- Where should migrated images live in an Astro project?
- In src/assets/, not public/. Files under src/assets/ go through astro:assets, which generates responsive formats and content hashed filenames at build time, while files in public/ are copied verbatim with no processing. Reference them from the schema with the image() helper, which is only available through the schema context callback. Do not leave production reading from cdn.prod.website-files.com, since those assets belong to a Webflow site you are about to stop paying for.
Related Resources

Webflow to Astro Migration: The Complete 2026 Guide
How to move a Webflow site to Astro: whether you should, what code export leaves behind, the rebuild step by step, what it costs, and when to stay put. Budget $5,000 to $25,000 and 4 to 8 weeks for a 50 to 100 page site.

Astro vs Webflow in 2026: An Honest Comparison
A direct comparison of Astro and Webflow for content-driven sites: performance, cost, design flexibility, CMS, SEO and maintenance. Where each one genuinely wins, and how to tell which side of the line you are on.

Astro for Marketing Sites in 2026: Should You Commit?
Astro ships zero JavaScript to the browser by default, which is a real advantage for content-led sites. It is also the wrong call for app-like products, for React-heavy teams, and for teams with no developer. Here is how to tell which side you are on.