Gemin

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.

Gemin Pak Migrations

Summarize with AI

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.

That last point is the part worth paying for. In Webflow a missing field is an empty element. Here it is a build error with the file name in it, which means the 500-item import either lands complete or does not land.

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));
  }
};

Resumable by offset, and it throws rather than continuing with a short page.

The Data API allows 60 a minute, or 120 on a CMS-capable plan. Source: Webflow Data API rate limits A collection of any size is a queue with a delay in it, not a loop, and the script has to be safe to run twice. Editors keep working while a migration is being built, so the export you take in week one is not the content that goes live.

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, lastPublished and isDraft sit outside it.
  • Reference fields are ids, not objects. A multi-reference is an array of ids. Resolving them means exporting the referenced collections too and joining on the way in, which is why the export order matters.
  • 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>

Four wrappers become one, and the copy becomes props.

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.

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.

Reach out and see if we are a good fit.

Start a project

Currently booking two to four weeks out.