# Course: Migrating content from Webflow to Sanity
https://www.sanity.io/learn/course/migrating-content-from-webflow-to-sanity

Move a Webflow site into Sanity end to end. Audit the content, model it as structured data, pull it through the Webflow Data API, resolve references, migrate assets, and convert rich text to Portable Text. Built to adapt to your own project with an agent.

---

## Navigation

## Contents

1. [Introduction](https://www.sanity.io/learn/course/migrating-content-from-webflow-to-sanity/introduction-to-webflow-migration) · [markdown](https://www.sanity.io/learn/course/migrating-content-from-webflow-to-sanity/introduction-to-webflow-migration.md)
2. [Audit and migration plan](https://www.sanity.io/learn/course/migrating-content-from-webflow-to-sanity/audit-and-migration-plan) · [markdown](https://www.sanity.io/learn/course/migrating-content-from-webflow-to-sanity/audit-and-migration-plan.md)
3. [Connect to the Webflow Data API](https://www.sanity.io/learn/course/migrating-content-from-webflow-to-sanity/connect-to-the-webflow-data-api) · [markdown](https://www.sanity.io/learn/course/migrating-content-from-webflow-to-sanity/connect-to-the-webflow-data-api.md)
4. [Recognizing Webflow's fingerprints](https://www.sanity.io/learn/course/migrating-content-from-webflow-to-sanity/recognizing-webflow-fingerprints) · [markdown](https://www.sanity.io/learn/course/migrating-content-from-webflow-to-sanity/recognizing-webflow-fingerprints.md)
5. [Designing your Sanity schema](https://www.sanity.io/learn/course/migrating-content-from-webflow-to-sanity/designing-your-sanity-schema) · [markdown](https://www.sanity.io/learn/course/migrating-content-from-webflow-to-sanity/designing-your-sanity-schema.md)
6. [Preparing your migration script](https://www.sanity.io/learn/course/migrating-content-from-webflow-to-sanity/preparing-your-migration-script) · [markdown](https://www.sanity.io/learn/course/migrating-content-from-webflow-to-sanity/preparing-your-migration-script.md)
7. [Importing collections and resolving references](https://www.sanity.io/learn/course/migrating-content-from-webflow-to-sanity/importing-collections-and-references) · [markdown](https://www.sanity.io/learn/course/migrating-content-from-webflow-to-sanity/importing-collections-and-references.md)
8. [Uploading assets from the Webflow CDN](https://www.sanity.io/learn/course/migrating-content-from-webflow-to-sanity/uploading-assets-from-webflow) · [markdown](https://www.sanity.io/learn/course/migrating-content-from-webflow-to-sanity/uploading-assets-from-webflow.md)
9. [Converting Webflow rich text to Portable Text](https://www.sanity.io/learn/course/migrating-content-from-webflow-to-sanity/converting-rich-text-to-portable-text) · [markdown](https://www.sanity.io/learn/course/migrating-content-from-webflow-to-sanity/converting-rich-text-to-portable-text.md)
10. [What Webflow did automatically (now you decide)](https://www.sanity.io/learn/course/migrating-content-from-webflow-to-sanity/what-webflow-did-automatically) · [markdown](https://www.sanity.io/learn/course/migrating-content-from-webflow-to-sanity/what-webflow-did-automatically.md)
11. [Validation and cutover](https://www.sanity.io/learn/course/migrating-content-from-webflow-to-sanity/validation-and-cutover) · [markdown](https://www.sanity.io/learn/course/migrating-content-from-webflow-to-sanity/validation-and-cutover.md)
12. [Where your content goes next](https://www.sanity.io/learn/course/migrating-content-from-webflow-to-sanity/where-your-content-goes-next) · [markdown](https://www.sanity.io/learn/course/migrating-content-from-webflow-to-sanity/where-your-content-goes-next.md)

---

## Lesson 1: Introduction
https://www.sanity.io/learn/course/migrating-content-from-webflow-to-sanity/introduction-to-webflow-migration

See what moving a real Webflow site into Sanity involves, clone the sample project, and install the migration skill you'll lean on throughout.

Your Webflow site works. The Designer handles layout, editors update the CMS, and the marketing team ships pages. Then the model starts to pinch. Marketing wants a landing page in a new shape, and that needs a designer to wire up Page Slots first. The mobile team asks for the same content the website uses, and there's no clean way to hand it over. A second site shows up, and you're already dreading the copy-paste.



None of those are bugs. They're the edge of a product built to produce one website. When content and design are one thing, the content cannot go anywhere the design doesn't.



This course moves a Webflow site into Sanity, where content is structured data with a query API, and a website is one of the things it can feed. You'll do it end to end on a sample blog: audit the content, model it, pull it through the Webflow Data API, resolve references, migrate assets off the Webflow CDN, and convert rich text to Portable Text. By the last lesson the sample's content lives in Sanity, verified in the Studio.



Two things to set up front.



First, your real site will not match the sample exactly. That's the point. The sample is the common shape (a marketing site with a blog) so you can watch the whole migration once. Each lesson ends with how to adapt that step to your own collections.



Second, you'll work with an agent the whole way. Sanity ships a migration skill that encodes the method, including a Webflow reference. You'll install it now and use it to plan, scaffold, and adapt. The rule throughout is generate, then verify: let the agent write the boring parts, then check the result against your schema and in the Studio. A migration that silently drops an embed or mis-resolves a reference is the failure mode that bites at launch, so every build lesson pairs an action with a check.



- [ ] Clone the sample Webflow project into your own Webflow account so you have a CMS to export. (Link to the clone goes here once the sample is locked.)

- [ ] Install Sanity's migration skill so your agent has the method and the Webflow reference on hand.


```bash
npx skills add sanity-io/agent-toolkit --skill sanity-migration
```

This course is the Webflow-specific companion to the general patterns in [Refactoring content for migration](https://www.sanity.io/learn/course/refactoring-content). If you haven't moved content into Sanity before, that course covers the fundamentals this one builds on.



Next, you'll inventory the sample site and have your agent draft a migration plan.



---

## Lesson 2: Audit and migration plan
https://www.sanity.io/learn/course/migrating-content-from-webflow-to-sanity/audit-and-migration-plan

Inventory the sample site, flag the parts that need special handling, and have your agent turn that into a migration plan you can work against.

The most expensive mistake in a migration is writing code before you know what you have. A day of audit saves a week of rework. You're going to produce two things in this lesson: an inventory, and a plan built from it.



Walk the sample site and write down four things.



**Content inventory.** Which CMS Collections exist, and how do they reference each other? In the sample: Blog Posts reference an Author and one or more Categories. What lives only on static pages (Home, About, Contact) and never in the CMS? Which Components repeat across pages? Note the Components in the Designer, not in exported HTML. The HTML loses which parts were a Component.



**Scope flags.** Is Webflow Localization on? Is there ecommerce? More than one site? Are you migrating published items only, or drafts too? Each flag changes the work. Localization, for instance, pushes you toward the API, because a CSV export only covers the current locale.



**Integration inventory.** What's in the custom code embeds (analytics, consent, chat)? Those move to your frontend, not to Sanity. What form handlers are wired up? Forms are a replacement decision, not a migration. Any webhooks?



**Cutover requirements.** Which slugs must stay stable for SEO? Which routes change and need a 301? Who owns DNS?



Now hand that to your agent. The migration skill you installed lists the deliverables a good plan covers, so the prompt is short:



```text
Using the sanity-migration skill, draft a migration plan for this Webflow site.
Here is my content inventory and scope flags: [paste your notes].
Cover: content inventory, source-to-Sanity mapping, extraction approach,
transform and import plan, validation, and cutover. Flag anything I left blank.
```

You'll get a plan shaped like the skill's deliverables: an inventory, a source-to-Sanity mapping, an extraction approach, a transform and import plan, and a cutover plan. Read it critically. The agent will guess at things you didn't tell it. The blanks it flags are your next questions.



- [ ] Write the four-part audit for the sample site (or your own), then generate a migration plan with the skill and save it next to your project.

- [ ] In the plan's mapping, mark which Webflow Collections should become Sanity document types and which are candidates to collapse into fields. You'll confirm these in Lesson 4.


To adapt this to your project: run the same four-part audit on your real site. The categories don't change; only the answers do.



Next, you'll connect to the Webflow Data API and pull the raw content to disk.



---

## Lesson 3: Connect to the Webflow Data API
https://www.sanity.io/learn/course/migrating-content-from-webflow-to-sanity/connect-to-the-webflow-data-api

Authenticate against the Webflow Data API v2, list your collections, page through every item, and snapshot the raw export to disk.

You have two ways to get CMS content out of Webflow: export each Collection as CSV, or read it from the Data API. CSV is fine for a small, flat collection, and you can open it in a spreadsheet to audit. The API is the better spine for a real migration, because it returns reference relationships and locale variants, and you can re-run it without clicking through the Designer. This course uses the API, and shows the CSV path where it differs.



Start with a token. In Webflow, go to your site settings, Apps and integrations, then API access, and generate a site API token. Treat it like a password. Put it and your site ID in a `.env` file.



```bash
# .env
WEBFLOW_TOKEN=your-site-api-token
WEBFLOW_SITE_ID=your-site-id
```

The Data API v2 lives at `https://api.webflow.com/v2`. Every request carries the token as a bearer header. List your collections first to get their IDs.



```typescript
// scripts/webflow.ts
const BASE = "https://api.webflow.com/v2";

const headers = {
  Authorization: `Bearer ${process.env.WEBFLOW_TOKEN}`,
};

export async function listCollections(siteId: string) {
  const res = await fetch(`${BASE}/sites/${siteId}/collections`, { headers });
  if (!res.ok) throw new Error(`Webflow ${res.status}: ${await res.text()}`);
  const { collections } = await res.json();
  return collections; // [{ id, displayName, slug, ... }]
}
```

Items are paginated. The API defaults `limit` to 25 and caps it at 100, and it uses `offset`. The response tells you the total, so you loop until you've seen them all. Set `limit` explicitly, the way the snippet does, so you aren't paginating in 25s. There are two endpoints: the staged items endpoint returns everything including drafts, and the `/live` endpoint returns only published items. Each item carries `id`, `fieldData`, `isDraft`, and `lastPublished`, so you can filter drafts yourself if you'd rather pull staged.



```typescript
export async function listItems(collectionId: string) {
  const items: unknown[] = [];
  const limit = 100;
  let offset = 0;

  while (true) {
    const url = `${BASE}/collections/${collectionId}/items?limit=${limit}&offset=${offset}`;
    const res = await fetch(url, { headers });
    if (!res.ok) throw new Error(`Webflow ${res.status}: ${await res.text()}`);

    const { items: page, pagination } = await res.json();
    items.push(...page);

    offset += limit;
    if (offset >= pagination.total) break;
  }

  return items;
}
```

Before you transform anything, write the raw response to disk. A snapshot means you can develop the rest of the migration offline, re-run it without hammering the API, and diff what changed if you pull again later.



```typescript
import { mkdir, writeFile } from "node:fs/promises";

const collections = await listCollections(process.env.WEBFLOW_SITE_ID!);
await mkdir("raw", { recursive: true });

for (const collection of collections) {
  const items = await listItems(collection.id);
  await writeFile(`raw/${collection.slug}.json`, JSON.stringify(items, null, 2));
  console.log(`Saved ${items.length} items to raw/${collection.slug}.json`);
}
```

Run it with a runtime that loads your `.env`, for example `npx tsx scripts/webflow.ts` (`fetch` is a global in Node, available since 18 and stable since 21).



One thing to notice now, because it changes how you resolve references later: in the API, a reference field holds the referenced item's `id`. In a CSV export, the same field comes out as semicolon-separated slugs (or names, if the items have no custom slug). Same data, different join key. You'll use the API's IDs in Lesson 7.



- [ ] Generate a site API token, then run the snapshot script and confirm you have one JSON file per Collection in `raw/`.

- [ ] Open `raw/blog-posts.json` and find a reference field. Confirm it holds item IDs, and note which Collection those IDs point to.


For your own project, list your collections first and let the count guide you. A handful of small, flat collections might be faster to pull as CSV. Anything with references or locales is worth doing through the API.



Next, you'll look at the raw content and decide what's a document and what's really an array.



---

## Lesson 4: Recognizing Webflow's fingerprints
https://www.sanity.io/learn/course/migrating-content-from-webflow-to-sanity/recognizing-webflow-fingerprints

Spot the modeling Webflow forced on you, then decide what becomes a Sanity document and what collapses into a field.

Webflow shapes how you model content, not only how you style it. Years inside its CMS leave fingerprints: structures that exist to satisfy the CMS, not to describe the content. Migration is the moment to clear them, and the first step is seeing them.



The shift is from pages to entities. A blog post is not a page. It's a thing with fields and relationships that could feed a website, an app, an email, or all three at once. Model it as a page and you bake in the website assumption you're trying to leave.



Four fingerprints show up again and again:



- **The child-collection workaround.** Webflow has no repeating field groups, so anything that repeats (FAQ items, pricing tiers, process steps) often becomes its own Collection with a reference back to the parent. In Sanity that's usually an inline array of objects on the parent, not a document type.

- **Numbered fields.** `step_1_title`, `step_2_title`, `step_3_title`. That's an array that had nowhere to go.

- **Pipe-separated text.** `Fast | Secure | Scalable` stuffed into one plain-text field. Also an array.

- **Rich text as a catch-all.** CTAs, callouts, and specs pushed into the rich-text blob because there was no field for them. In Portable Text these become typed blocks you can query.


Here's the test for any Collection. Does it have its own page? Would any single item ever appear on its own, somewhere else? If both answers are no, it's probably a field on its parent, not a document.



Run the sample through it. Blog Posts, Authors, and Categories all pass: they're real entities with their own pages and independent reuse. The FAQ Items collection that only feeds the Pricing page fails both questions, so it collapses into an array on that page. That one move is the difference between carrying Webflow's constraint into Sanity and leaving it behind.



- [ ] Label every sample Collection as a document or a field using the two-question test. Write down the reasoning for the one you found hardest.

- [ ] Find the seeded numbered fields and the pipe-separated field on the sample's Home page, and note the array each should become.


To adapt this to your project: the fingerprints are universal, but your collections aren't. Run the two-question test on each of your own Collections before you write any schema.



Next, you'll turn these decisions into a Sanity schema.



---

## Lesson 5: Designing your Sanity schema
https://www.sanity.io/learn/course/migrating-content-from-webflow-to-sanity/designing-your-sanity-schema

Scaffold the Studio and write the schema your content maps to: documents, a page builder array, navigation, and site settings.

Your content model in Sanity is TypeScript in your repository, reviewed in pull requests like any other code. Clone the course starter and you'll have a Studio running locally in a minute. The schema is where the Lesson 4 decisions land.



Start with the entities you kept as documents. A `post` references an `author` and an array of `category` documents, and holds its body as Portable Text.



```typescript
// schemaTypes/post.ts
import { defineType, defineField, defineArrayMember } from "sanity";

export const post = defineType({
  name: "post",
  title: "Post",
  type: "document",
  fields: [
    defineField({ name: "title", type: "string" }),
    defineField({ name: "slug", type: "slug" }),
    defineField({ name: "excerpt", type: "text", rows: 3 }),
    defineField({ name: "mainImage", type: "image", options: { hotspot: true } }),
    defineField({ name: "publishedAt", type: "datetime" }),
    defineField({ name: "author", type: "reference", to: [{ type: "author" }] }),
    defineField({
      name: "categories",
      type: "array",
      of: [defineArrayMember({ type: "reference", to: [{ type: "category" }] })],
    }),
    defineField({ name: "featured", type: "boolean", initialValue: false }),
    defineField({ name: "body", type: "blockContent" }),
  ],
});
```

One trap for migrated content: leave the `slug` field without `options.source`. The `source: "title"` helper is handy for new posts, but on migrated content it invites an editor to rename a post and silently change a URL that already has inbound links. You'll set slugs explicitly from the Webflow data in Lesson 7.



The static pages are the part that earns the page builder. Instead of one rigid page document, give a `page` a `sections` array that accepts your section objects. Editors arrange them in any order, on any page.



```typescript
// schemaTypes/page.ts
export const page = defineType({
  name: "page",
  title: "Page",
  type: "document",
  fields: [
    defineField({ name: "title", type: "string" }),
    defineField({ name: "slug", type: "slug" }),
    defineField({
      name: "sections",
      type: "array",
      of: [
        defineArrayMember({ type: "heroSection" }),
        defineArrayMember({ type: "featureGrid" }),
        defineArrayMember({ type: "testimonialBlock" }),
        defineArrayMember({ type: "ctaBanner" }),
      ],
    }),
  ],
});
```

To get from Webflow Components to these section objects, inventory the Components in the Designer first (the HTML export loses which parts were a Component), then let your agent propose the shapes. The migration skill ships a prompt for exactly this:



```text
Identify all distinct content section patterns in this Webflow HTML. Group visually
similar structures together, note where variants differ, and propose semantic Sanity
fields for each section. Do not copy CSS class names into schema names.
```

Site-wide content that was hardcoded in a Webflow Component becomes two things. Global settings (site name, default social image) go in a `siteSettings` singleton with a fixed ID, so there's only ever one. Navigation becomes its own document with a recursive item type, which is the structure Webflow's CMS can't model at all.



```typescript
// schemaTypes/navItem.ts
export const navItem = defineType({
  name: "navItem",
  title: "Nav item",
  type: "object",
  fields: [
    defineField({ name: "label", type: "string" }),
    defineField({ name: "link", type: "reference", to: [{ type: "page" }, { type: "post" }] }),
    defineField({ name: "externalUrl", type: "url" }),
    defineField({
      name: "children",
      type: "array",
      of: [defineArrayMember({ type: "navItem" })], // referencing itself
    }),
  ],
});
```

The remaining types are short. `author` and `category` are the documents your posts reference, `siteSettings` is a singleton (pin it to a fixed ID in Structure Builder), and `navigationMenu` wraps the recursive `navItem`.



```typescript
// schemaTypes/author.ts, category.ts, siteSettings.ts, navigationMenu.ts (condensed)
import { defineType, defineField, defineArrayMember } from "sanity";

export const author = defineType({
  name: "author",
  type: "document",
  fields: [
    defineField({ name: "name", type: "string" }),
    defineField({ name: "slug", type: "slug" }),
    defineField({ name: "bio", type: "text" }),
    defineField({ name: "avatar", type: "image" }),
  ],
});

export const category = defineType({
  name: "category",
  type: "document",
  fields: [
    defineField({ name: "title", type: "string" }),
    defineField({ name: "slug", type: "slug" }),
  ],
});

export const siteSettings = defineType({
  name: "siteSettings",
  type: "document",
  fields: [
    defineField({ name: "siteName", type: "string" }),
    defineField({ name: "defaultOgImage", type: "image" }),
  ],
});

export const navigationMenu = defineType({
  name: "navigationMenu",
  type: "document",
  fields: [
    defineField({ name: "title", type: "string" }),
    defineField({ name: "identifier", type: "string" }),
    defineField({
      name: "items",
      type: "array",
      of: [defineArrayMember({ type: "navItem" })],
    }),
  ],
});
```

Finally, the body. Your `blockContent` is Portable Text with three additions that handle the fingerprints from Lesson 4: an `internalLink` annotation (so links between posts survive slug changes), an `htmlEmbed` block (for the YouTube and Loom embeds Webflow stores as `w-embed` markup), and a `callout` block (for the CTAs that were buried in rich text). You'll use all three in Lesson 9.



Deploy the schema and generate types so the rest of the course is type-safe.



```bash
npx sanity schema deploy
npx sanity typegen generate
```

- [ ] Clone the starter, run the Studio, and confirm `post`, `author`, `category`, `page`, `siteSettings`, and `navigationMenu` all appear.

- [ ] Deploy the schema and run TypeGen. Fix any field your audit needs that the starter doesn't have yet.


On your own project, the starter is a starting point, not a target. Add the document types your audit found, and run the section-analysis prompt on your own Components.



Next, you'll set up the import script that writes into this schema.



---

## Lesson 6: Preparing your migration script
https://www.sanity.io/learn/course/migrating-content-from-webflow-to-sanity/preparing-your-migration-script

Set up an import script with the Sanity client, deterministic IDs, and a dry run, so every run converges instead of duplicating content.

You need a way to write documents that you can run more than once without making a mess. Two clarifications before any code.



First, the right tool. Sanity's `sanity migrations` CLI is built to transform documents that are already in your dataset, which makes it the right choice for cleanup passes after the content is in. For pulling new content in from an external source like Webflow, you write an import script against the Sanity client and run it with `sanity exec`. Don't reach for the migrations CLI to do the initial import; it's solving a different problem.



Second, idempotency. A migration is never one clean run. You'll run it, find a field you mapped wrong, fix it, and run again. Two defaults make that safe: derive each document's ID from its Webflow ID, and write with `createOrReplace` instead of `create`. Same input, same `_id`, same result, no duplicates.



```typescript
// scripts/import.ts
import { getCliClient } from "sanity/cli";

const client = getCliClient({ apiVersion: "2026-02-27" });

// A given Webflow item always maps to the same Sanity document.
export const sanityId = (type: string, webflowId: string) => `${type}-${webflowId}`;

const DRY = process.argv.includes("--dry");

export async function write(doc: { _id: string; _type: string; [k: string]: unknown }) {
  if (DRY) {
    console.log("would write", doc._id);
    return;
  }
  await client.createOrReplace(doc);
}
```

Run it against your dataset with the CLI, which handles auth:



```bash
npx sanity exec scripts/import.ts --with-user-token -- --dry
```

The `--dry` flag is your own (the script reads `process.argv`). Start every change with a dry run and read what it would write before you let it write. For extra safety on a real migration, point the script at a scratch dataset first, confirm the result in the Studio, then run it against production.



Those deterministic IDs do more than prevent duplicates. Because you can compute an author's `_id` from its Webflow ID, a post can reference its author without you having to look the author up first. That's what makes the next lesson's import a single pass instead of two.



- [ ] Add the client setup and the `sanityId` helper to your project, then run the script with `--dry` and confirm it connects and logs without writing.

- [ ] Create a scratch dataset (`npx sanity dataset create`) so you have somewhere safe to run the real import in the next lessons.


To adapt this to your project: the ID scheme (`type-webflowId`) is the one decision to make consciously. Pick it once and use it everywhere, because every reference you write depends on being able to recompute it.



Next, you'll import the collections and resolve their references.



---

## Lesson 7: Importing collections and resolving references
https://www.sanity.io/learn/course/migrating-content-from-webflow-to-sanity/importing-collections-and-references

Import Authors, Categories, and Posts, and resolve single and multi references in one pass using the deterministic IDs you set up.

Import order matters because a post points at an author and at categories. Import the leaf collections first (the ones other documents reference), then the documents that depend on them. For the sample that's Authors and Categories, then Posts.



A Webflow API item has its fields under `fieldData` and its identity in `id`. Transforming one to a Sanity document is mostly mapping field names, with two moves that matter: set the slug explicitly from Webflow, and build references from the deterministic IDs.



```typescript
// scripts/import-content.ts
import { sanityId, write } from "./import";
import authors from "../raw/authors.json";
import categories from "../raw/categories.json";
import posts from "../raw/blog-posts.json";

// Leaf collections first.
for (const a of authors) {
  await write({
    _id: sanityId("author", a.id),
    _type: "author",
    name: a.fieldData.name,
    slug: { _type: "slug", current: a.fieldData.slug },
  });
}

for (const c of categories) {
  await write({
    _id: sanityId("category", c.id),
    _type: "category",
    title: c.fieldData.name,
    slug: { _type: "slug", current: c.fieldData.slug },
  });
}
```

Now the posts. Because you can compute an author's Sanity `_id` from its Webflow ID, the reference is a calculation. No lookup table, no second pass. The same goes for the multi-reference `categories`, where each array item needs a `_key`.



```typescript
for (const p of posts) {
  await write({
    _id: sanityId("post", p.id),
    _type: "post",
    title: p.fieldData.name,
    slug: { _type: "slug", current: p.fieldData.slug },
    publishedAt: p.fieldData["published-on"] ?? p.lastPublished,
    featured: Boolean(p.fieldData.featured),
    author: p.fieldData.author
      ? { _type: "reference", _ref: sanityId("author", p.fieldData.author) }
      : undefined,
    categories: (p.fieldData.categories ?? []).map((id: string) => ({
      _type: "reference",
      _ref: sanityId("category", id),
      _key: id,
    })),
    // mainImage and body come in Lessons 8 and 9
  });
}
```

This is the payoff from choosing the API in Lesson 3. If you'd gone the CSV route, the reference fields would hold slugs, not IDs, and you'd need a slug-to-ID map built from the referenced collection before you could resolve anything:



```typescript
// CSV fallback only: references come out as slugs, so map slug -> Sanity ID first.
const authorIdBySlug = new Map(
  authors.map((a) => [a.fieldData.slug, sanityId("author", a.id)]),
);
```

After the run, check that every reference actually resolves. A short GROQ query finds posts whose author reference points at nothing, which catches a bad ID mapping immediately.



```groq
*[_type == "post" && defined(author) && !defined(author->_id)]{ _id, title }
```

An empty result means every author reference resolves. Do the same for categories.



- [ ] Run the import against your scratch dataset, then open a post in the Studio and confirm its author and categories are linked, not empty.

- [ ] Run the dangling-reference query for both `author` and `categories`. If anything comes back, check the ID mapping before moving on.


For your own site, import order follows your reference graph. Map which collections reference which, and import the most-referenced ones first.



Next, you'll move the images off Webflow's CDN.



---

## Lesson 8: Uploading assets from the Webflow CDN
https://www.sanity.io/learn/course/migrating-content-from-webflow-to-sanity/uploading-assets-from-webflow

Move images off the Webflow CDN into Sanity with a pre-upload pass, and use a source cache so re-runs don't re-upload.

Your posts still point at images on Webflow's CDN (`cdn.prod.website-files.com`, or an `s3.amazonaws.com/webflow-prod-assets` URL in the API's `hostedUrl`). Leave them there and your production content breaks the day you turn the Webflow site off. Every image has to move into Sanity, and every reference has to point at the Sanity asset.



Do it as a pre-upload pass: collect every image URL, upload each one, and keep a map from the original URL to its new Sanity asset ID. Run document and rich-text imports after, so they can look up the new reference. Read the URL from the item's `fieldData` (the API gives you a clean `url` on image fields) rather than scraping HTML where you can.



Two things keep the pass safe to re-run. Sanity derives an asset's ID from the file's content, so uploading the same bytes twice gives you the same asset, not a duplicate. And if you stamp a `source` on each upload, you can query what you've already done and skip it.



```typescript
// scripts/assets.ts
import { getCliClient } from "sanity/cli";
import pLimit from "p-limit";
import { writeFile } from "node:fs/promises";

const client = getCliClient({ apiVersion: "2026-02-27" });
const limit = pLimit(4); // be kind to both APIs

async function alreadyUploaded(): Promise<Set<string>> {
  const urls: string[] = await client.fetch(
    `*[_type == "sanity.imageAsset" && defined(source.id)].source.id`,
  );
  return new Set(urls);
}

export async function uploadAll(urls: string[]) {
  const seen = await alreadyUploaded();
  const map: Record<string, string> = {};

  await Promise.all(
    urls.map((url) =>
      limit(async () => {
        if (seen.has(url)) return;
        const res = await fetch(url);
        const buffer = Buffer.from(await res.arrayBuffer());
        const asset = await client.assets.upload("image", buffer, {
          source: { name: "webflow", id: url, url },
          filename: url.split("/").pop() ?? "image",
        });
        map[url] = asset._id;
      }),
    ),
  );

  await writeFile("asset-map.json", JSON.stringify(map, null, 2));
  return map;
}
```

With `asset-map.json` written, the document import can attach images by reference. Add the `mainImage` to the post mapping from Lesson 7:



```typescript
mainImage: p.fieldData["main-image"]?.url
  ? {
      _type: "image",
      asset: { _type: "reference", _ref: assetMap[p.fieldData["main-image"].url] },
    }
  : undefined,
```

The throttle matters more than it looks. Pulling a few hundred images as fast as Node can will trip rate limits on one side or the other. Four at a time is a sane default; tune it if you need to.



- [ ] Collect the image URLs from your snapshot, run the upload pass, and confirm `asset-map.json` has an entry per unique image.

- [ ] Run the pass a second time. Confirm it uploads nothing new, because the source cache and content hashing both kick in.


On your own site, collect URLs from every place images hide: image fields, rich-text HTML, and any Open Graph or social fields. Miss a source and those images stay on Webflow's CDN.



Next, you'll convert the rich-text bodies, which is where the rest of those images live.



---

## Lesson 9: Converting Webflow rich text to Portable Text
https://www.sanity.io/learn/course/migrating-content-from-webflow-to-sanity/converting-rich-text-to-portable-text

Convert Webflow's rich-text HTML to Portable Text, mapping images, embeds, and internal links to typed blocks instead of storing raw HTML.

Webflow stores a rich-text field as one HTML string. You could drop that string into Sanity and move on, but then your body is an opaque blob again, which is the thing you left Webflow to escape. Convert it to Portable Text and every part of it becomes structured and queryable.



The tool is `htmlToBlocks` from `@portabletext/block-tools`. (If you've seen `@sanity/block-tools` in older guides, that package was renamed and its API changed slightly, so the examples here use the current package.) It needs three things: your compiled `blockContent` type, an HTML parser (JSDOM on the server), and a set of `rules` for the HTML that isn't plain text.



Two kinds of HTML need rules. Webflow wraps embeds (YouTube, Loom, Typeform) in a `<div class="w-embed">`, which becomes your `htmlEmbed` block. And inline `<img>` tags become Sanity image blocks, pointed at the assets you uploaded in Lesson 8 via `asset-map.json`.



```typescript
// scripts/rich-text.ts
import { htmlToBlocks } from "@portabletext/block-tools";
import { Schema } from "@sanity/schema";
import { JSDOM } from "jsdom";
import { schemaTypes } from "../schemaTypes";
import assetMap from "../asset-map.json";

const compiled = Schema.compile({ types: schemaTypes });
const blockContentType = compiled
  .get("post")
  .fields.find((f: { name: string }) => f.name === "body").type;

export function toPortableText(html: string) {
  return htmlToBlocks(html, blockContentType, {
    parseHtml: (html) => new JSDOM(html).window.document,
    rules: [
      {
        deserialize(node, next, block) {
          const el = node as HTMLElement;
          const tag = el.nodeName?.toLowerCase();

          // Webflow embeds: <div class="w-embed">
          if (tag === "div" && el.classList?.contains("w-embed")) {
            return block({ _type: "htmlEmbed", html: el.innerHTML });
          }

          // Images: point at the asset uploaded in Lesson 8
          if (tag === "img") {
            const src = el.getAttribute("src") ?? "";
            const ref = (assetMap as Record<string, string>)[src];
            if (!ref) {
              console.warn("No uploaded asset for", src);
              return undefined;
            }
            return block({
              _type: "image",
              asset: { _type: "reference", _ref: ref },
              alt: el.getAttribute("alt") ?? "",
            });
          }

          return undefined; // let block-tools handle everything else
        },
      },
    ],
  });
}
```

Internal links need one more step. `deserialize` runs synchronously, so links that point at other pages on the site (`/blog/some-post`) are easiest to fix in a pass over the returned blocks: rewrite each internal `href` into your `internalLink` annotation (or at least remap it to the new URL) using a URL map you build the same way you built the asset map. External links, `mailto:`, and anchors pass through untouched.



One caveat to catch in review: Portable Text images are block-level. An image that sat inline inside a Webflow paragraph comes out as a full-width block between paragraphs. Usually fine, sometimes not, so look for it.



That's why this lesson ends with eyes, not a query. Open a converted post next to the original and read it. Check a paragraph with a link, an image, a list, and an embed. The conversion is the step most likely to lose something quietly, and an agent that wrote the rules won't notice a dropped embed. You will.



- [ ] Convert the sample posts, wiring `body` into the post import from Lesson 7. Handle at least one real embed type end to end.

- [ ] Open one converted post beside its Webflow original and confirm links, images, lists, and embeds all survived. Note anything that didn't.


To adapt this to your project: inventory every embed type in your rich text before you start, and write a rule per type. The ones you don't write a rule for are dropped silently, which is exactly what the visual QA pass is there to catch.



Next, you'll handle everything Webflow did for you automatically that now needs a decision.



---

## Lesson 10: What Webflow did automatically (now you decide)
https://www.sanity.io/learn/course/migrating-content-from-webflow-to-sanity/what-webflow-did-automatically

Replace the things Webflow handled invisibly: forms, search, interactions, redirects, draft mode, and social images.

Webflow quietly did a lot for you. Going headless, each of those things becomes a decision you make on the frontend, not something the platform handles. None of these block the content migration you just finished, but skipping them is how a launch goes sideways.



**Forms.** There's no Sanity equivalent, by design. Pick a handler: Formspree or Formspark for basic forms, your existing tool's native forms if you have one, a custom API route when there's logic. Before launch, inventory every form and where its submissions go.



**Search.** Webflow's built-in search doesn't come with you. For parity, Algolia or Typesense. For an upgrade, semantic search over your now-structured content with Sanity's Agent Context.



**Interactions and animations.** These do not export. Every scroll trigger and hover effect is rebuilt on the frontend in CSS, Framer Motion, or GSAP. On a heavy marketing site this can be the largest single chunk of work, so scope it on its own.



**Redirects, social images, draft mode, image optimization.** Webflow handled all of these. In a Next.js stack they're explicit: `next.config.js` redirects, `next/og` for social images, draft mode wired to the Presentation tool, `next/image` for optimization. Set up draft mode and preview early, not as a launch-week scramble.



The redirect map is the one to automate. Export Webflow's redirect list, hand it to your agent with your new URL structure, and get a `next.config.js` redirects block back.



```text
Here is my Webflow redirect list as CSV [paste]. My new structure: blog posts live at
/blog/[slug], static pages at /[slug]. Output a Next.js next.config.js redirects() array
using permanent redirects. Map each old path to the new one, and flag any you can't map.
```

```javascript
// next.config.js
module.exports = {
  async redirects() {
    return [
      { source: "/posts/:slug", destination: "/blog/:slug", permanent: true },
      { source: "/about-us", destination: "/about", permanent: true },
    ];
  },
};
```

- [ ] Generate a `next.config.js` redirect map from the sample's old paths, and check the paths your agent flagged as unmapped.

- [ ] List every form on the sample (or your) site and write down where each one's submissions need to land after cutover.


Next, you'll validate the migration and plan the cutover.



---

## Lesson 11: Validation and cutover
https://www.sanity.io/learn/course/migrating-content-from-webflow-to-sanity/validation-and-cutover

Run the validation checklist, then cut over with redirects, a low DNS TTL, a content freeze, and a first-day 404 watch.

Every migration that goes badly goes badly at cutover. The work here is unglamorous and it's the difference between a clean launch and a week of firefighting. Validate first, then cut over.



Validation is a few concrete checks, straight from the migration skill's checklist. Start with counts: the number of documents in Sanity should match the number of items in Webflow, per collection.



```groq
count(*[_type == "post"])
```

Then references (the dangling-reference query from Lesson 7, run for every reference field). Then assets: confirm nothing in your content still points at Webflow's CDN, because those URLs die when the old site does. The fastest way to be sure is to export the dataset and search it.



```bash
npx sanity dataset export production webflow-check.tar.gz
tar -xzf webflow-check.tar.gz
grep -c "website-files.com" data.ndjson   # expect 0
```

A non-zero count means an image slipped through the asset pass. Go back to Lesson 8 and find the source you missed. Finish validation with the visual QA on rich text from Lesson 9, on a sample of real posts.



Cutover itself comes down to a handful of moves. Verify the redirect map covers every old route. Drop your DNS TTL well before launch day (300 seconds, not 86400) so propagation and rollback are both fast. Agree a content freeze with the editors, or a delta-sync plan if they'll keep publishing up to the switch. Then, in the first 24 hours, watch your 404s. A redirect you thought you had will miss some pattern, and fixing it fast is the whole game.



- [ ] Run the count check, the reference checks, and the CDN-URL grep. Resolve anything that doesn't come back clean.

- [ ] Write the cutover runbook for the sample: redirect verification, the TTL change, the freeze plan, and who watches 404s on day one.


Next, the payoff: content that isn't trapped in a website anymore.



---

## Lesson 12: Where your content goes next
https://www.sanity.io/learn/course/migrating-content-from-webflow-to-sanity/where-your-content-goes-next

Query the migrated content from a second surface to prove it's no longer tied to one website, and see what that opens up.

The migration is done. The more interesting fact is what just changed about your content. In Webflow it could feed one website. Now it's structured data behind a query API, and anything that can make an HTTP request can read it.



Here's the proof. This query doesn't run on your website. It's a plain script, an email job, a mobile app, anything.



```typescript
import { createClient } from "@sanity/client";

const client = createClient({
  projectId: "your-project-id",
  dataset: "production",
  apiVersion: "2026-02-27",
  useCdn: true,
});

const LATEST_POSTS = `*[_type == "post" && defined(publishedAt)]
  | order(publishedAt desc)[0...10]{
    title,
    "slug": slug.current,
    "author": author->name,
    publishedAt
  }`;

const posts = await client.fetch(LATEST_POSTS);
```

That single query is the thing Webflow couldn't give you. The same content now feeds an email digest on publish (a webhook triggers the send), a mobile app that updates without an App Store release, or a chatbot that answers from your real content. With Agent Context, those structured documents are also the source for semantic search and retrieval, which an HTML blob never could be.



Two paths from here, both with their own lessons in the [Replatforming track](https://www.sanity.io/learn/track/replatforming-to-sanity): if your site uses Webflow Localization, the localization workflow handles per-locale content; if you're consolidating several Webflow sites, the multi-site patterns (a single project with a site field, or cross-dataset references) are where to look.



You migrated one site. The query above runs against any content you bring into Sanity next.



- [ ] Run the query against your migrated dataset from a standalone script, outside any website. Confirm you get your posts back.


---

## Related Resources

- [All courses and lessons](https://www.sanity.io/learn/sitemap.md)
- [Complete content for LLMs](https://www.sanity.io/learn/llms-full.txt)
