Quiverblocks chooses Diggama to deliver 200+ articles every month

Guide · 30 min read

Migrate from Webflow with an AI coding agent: step-by-step guide

TL;DR

To migrate from Webflow, export each CMS collection as CSV or through the Webflow Data API, import it into a headless CMS through its API, and have your AI coding agent (Claude Code, OpenAI Codex, Cursor or another) rebuild the pages from screenshots and the exported HTML so they read content from the CMS. Re-host Webflow images before you close the project, keep the old URLs or add 301 redirects in a Cloudflare _redirects file, and switch DNS only after every old URL answers 200 or 301 on the new site.

By Diggama Team Updated Tested with Node.js 22.22csv-parse 7.0.3Wrangler 4.148 View as Markdown

This guide moves a Webflow site to a codebase that your AI coding agent writes, with the content in Diggama (a headless CMS) and the hosting on Cloudflare. The prompts work the same in Claude Code, OpenAI Codex, Cursor, Gemini CLI and GitHub Copilot agent mode; the few setup details that differ per tool are in step 6. It covers the parts that decide whether a migration goes well: what to inventory, how to get the content out, how to import it with a script you can run twice, what to do with the images, and how to keep every URL working through the DNS switch.

The running example is Northwind Studio, the company site used across this series. On Webflow it had a homepage, an About page, a blog collection served at /post/<slug>, a team collection with one page per person, and a contact form. On the new stack, posts live at /blog/<slug>/, the team is a single /team/ page, and the content model is the one from the pillar guide: homepage, posts, team-members and contact-submissions.

What does a Webflow migration with an AI coding agent involve?

A Webflow migration splits the site into three things that leave by different doors: the design (rebuilt as code by your AI coding agent), the CMS content (exported as CSV or JSON and imported into a headless CMS), and the URLs (kept or redirected with 301s on the new host). Webflow has no single export that carries all three.

flowchart LR
    wf["Webflow site"] --> design["Design: screenshots, code export"]
    wf --> content["CMS: CSV or Data API"]
    wf --> urls["URLs: sitemap, 301 list"]
    design --> agent["AI coding agent"]
    content --> script["Import script"]
    script --> cms["Diggama"]
    agent --> site["New site on Cloudflare"]
    cms -->|"REST API (build)"| site
    urls --> redirects["public/_redirects"]
    redirects --> site
Webflow pieceHow it leaves WebflowWhere it goesTool
Static pages (layout, styles)Code export ZIP, screenshotsAstro or Next.js componentsYour AI agent
Static page copyCode export HTMLA singular blueprint (e.g. homepage)Dashboard or MCP
CMS collectionsCSV per collection, or Data API v2One Diggama blueprint per collectionImport script
Images and filesURLs in the CSV, downloadedYour domain, R2, or Diggama uploadscurl loop
Forms and submissionsSubmissions CSVA read-only blueprint plus a form endpointFramework tutorial
301 redirectsCopied from site settingspublic/_redirects on CloudflareYour AI agent
DNSRecords at your registrarA Cloudflare zone and a Worker custom domainCloudflare dashboard

Leaving Webflow is not always right: if designers own the site and nobody on the team reads code, its visual canvas and bundled hosting earn their price. The Webflow vs Framer vs AI coding comparison covers that decision; this guide assumes you have made it.

Step 1: How do you audit a Webflow site before migrating?

Audit by building one file that lists every URL, every collection with its fields, every form, every redirect and every piece of custom code on the Webflow site. That file becomes the checklist for the rebuild and the test list for launch day; anything missing from it is what breaks after the switch.

Start with the URLs. If the sitemap is enabled on the Webflow site, it lists every static page and every published collection item:

mkdir -p migration
curl -s https://www.northwind-studio.example/sitemap.xml \
  | grep -o '<loc>[^<]*' | sed 's/<loc>//' > migration/old-urls.txt
wc -l migration/old-urls.txt

Then collect the rest by hand, because none of it is in an export:

  • Collections and fields. For each collection, note every field, its type, and whether it is required. You will map them in step 3.
  • 301 redirects. Webflow keeps them under Site settings > Publishing > 301 redirects. ClonePartner’s export guide lists redirects among the things no Webflow export includes, so copy them into a text file.
  • SEO settings. Page titles, meta descriptions and Open Graph images live in each page’s settings, and collection template pages build theirs from fields. Write down which field feeds which tag.
  • Forms. Every form, its fields, where notifications go, and the submissions to keep.
  • Custom code. Head and footer code in site settings and page settings: analytics, tag managers, chat widgets, verification tags.
  • Locales. If the site uses Webflow Localization, each locale exports separately (see step 2). Diggama stores translations as variants of the same record.
  • Out of scope features. Ecommerce, memberships and site search do not move with this method. List them so nobody discovers them on launch day.

Download the code export as well (ClonePartner notes it is limited to paid Workspace plans). It does not contain your CMS content, but it gives your agent the exact HTML structure, class names and copy of every static page. Unzip it into migration/webflow-export/, put the CSV files from step 2 in migration/, and let the agent assemble the inventory:

Prompt for your AI agent
Read migration/old-urls.txt, every HTML file in migration/webflow-export/, every CSV in migration/ and migration/redirects.txt.

Write migration/INVENTORY.md with four tables:
1. Static pages: old URL, page title, meta description, main sections in order, forms on the page.
2. Collections: CSV file, column, inferred Webflow field type, example value, how many rows are empty.
3. Redirects: every line of redirects.txt, rewritten as "old path, new path".
4. Custom code: every <script> and <link> in the exported <head> and before </body> that is not webflow.js or the Webflow CSS, with what it does.

Then list any URL in old-urls.txt that matches neither a static page nor a CSV slug. Do not create any code yet.

Step 2: How do you export Webflow CMS collections?

Export each collection as a CSV from the Designer: open the CMS panel, select the collection, and click Export. According to ClonePartner, you choose between all items (archived ones included) and selected items. For relations, several locales or very large collections, use the Webflow Data API v2 instead, which returns JSON.

What the CSV export contains

Four properties of the export shape the import. The first two come from Webflow’s help center, the last two from ClonePartner’s export guide:

  • Images and files are URLs pointing to where Webflow hosts them, and those links break permanently if the original project is deleted. Step 4 deals with that.
  • Only the current locale is exported. Switch locale in the Designer and export again for each one.
  • Rich text is raw HTML in a single cell, with line breaks and quotes inside it. Open the file with a real CSV parser, never with split(',').
  • References are not IDs. Reference cells hold the display value of the target item, not its Webflow item ID, so you cannot re-link them by ID. Check a few rows of your own file before you map them.

The script below expects the system columns Name, Slug, Archived, Draft and Published On next to your custom fields. Column names can differ between exports, so open your file first: the script checks that every column it needs exists and prints the real header when one is missing.

When to use the Webflow Data API instead

The API is the better source when you need item IDs for relations, all locales in one pass, or drafts flagged as booleans. Create a site token under Site settings > Apps & integrations > API access (Webflow docs) with the cms:read scope, then list the collections and page through the items:

export WEBFLOW_TOKEN="your-site-token"
export SITE_ID="your-site-id"

# Collections of the site, with their IDs
curl -s "https://api.webflow.com/v2/sites/$SITE_ID/collections" \
  -H "Authorization: Bearer $WEBFLOW_TOKEN" > migration/collections.json

# Items of one collection, 100 per page (the maximum)
curl -s "https://api.webflow.com/v2/collections/$COLLECTION_ID/items?limit=100&offset=0" \
  -H "Authorization: Bearer $WEBFLOW_TOKEN" > migration/posts-0.json

Each item comes back with id, isDraft, isArchived, lastPublished and a fieldData object keyed by field slug, and the response has a pagination object with total, limit and offset (list items reference). Increase offset by 100 until you reach total. The rate limit is 60 requests per minute on Starter and Basic site plans and 120 on CMS, eCommerce and Business, which is plenty for an export.

The import script below reads CSV. If you export through the API, ask your agent to convert the JSON pages into the same CSV shape, or to replace the parse() call with a loop over fieldData.

Step 3: How do Webflow field types map to Diggama field types?

Most Webflow field types have a direct Diggama equivalent: plain text becomes Text, rich text becomes Rich text, switch becomes Boolean, option becomes Choice, reference becomes Relation. The exceptions are Phone (no dedicated type, use Text), Multi-image (no equivalent, model it differently) and images that you import through the API (see step 4).

Webflow fieldDiggama typeAPI keyValue the API returns
Name (system)Texttextstring
Slug (system)Slugslugstring
Plain textTexttextstring
Rich textRich textrich-textHTML string, sanitized
ImageImage, or Link for imported URLsimage / linkURL string
Multi-imageRelation (multiple) to an images blueprint, or Rich textreferencesarray of IDs
Video linkLinklinkURL string
LinkLinklinkURL string
EmailEmailemailstring, validated
PhoneTexttextstring
NumberNumbernumbernumber
Date/TimeDate or Date and timedate / datetimeYYYY-MM-DD or ISO 8601
SwitchBooleanbooleanboolean
ColorColorcolorHEX string
OptionChoiceenumstring
FileFilefileURL string
ReferenceRelation (single)referenceresource ID
Multi-referenceRelation (multiple)referencesarray of resource IDs

The types and API values come from the Diggama field reference. Four rules matter during a migration:

  1. Field keys come from the field name and use hyphens: a field named “Cover URL” is served as cover-url, and the key cannot be changed once the field exists. Name fields before you import.
  2. Rich text is cleaned on save, whether it comes from the editor, the API or MCP. Diggama keeps what its editor can produce: paragraphs, H2 to H4, lists, quotes, code blocks, tables, links, images with captions, and YouTube or Vimeo embeds. Webflow classes are stripped, custom code embeds lose their scripts and any iframe other than YouTube or Vimeo, and H1, H5 and H6 tags lose their heading markup. The script remaps those headings first.
  3. Relations take Diggama resource IDs. Import the target collection first (for example authors), build a map from Webflow slug to Diggama ID, then import the collection that points to it. A reference to a resource that does not exist is rejected with 422.
  4. Publication is not a field. Webflow’s Draft and Published On columns become Diggama’s system published_at: null for a draft, a date once published. Do not create a “published” field.

For Northwind Studio, the Webflow blog collection had Name, Slug, Post Body, Post Summary and Main Image. The posts blueprint keeps the series’ fields and adds one Link field for the migrated covers:

Webflow columnDiggama fieldTypeKey
NameTitleTexttitle
SlugSlugSlugslug
Post SummaryExcerptTextexcerpt
Post BodyContentRich textcontent
Main ImageCover URLLinkcover-url
(none)CoverImagecover

To draft the blueprints for every collection, give your agent the CSV headers and this table, or use the content model generator. If you have many collections, the agent can also write a file for Diggama’s JSON import, which creates several blueprints and their fields in one step with the Import JSON button on the Blueprints page. Field keys are generated from the field names, so check them against your import script afterwards.

Step 4: What should you do with Webflow images?

Download every Webflow-hosted image and re-host it before you close the Webflow project, because the URLs in your export point to Webflow’s CDN and stop working once the project is deleted. Then decide, per field, whether editors will manage the image in Diggama or whether the migrated URL can stay a plain link.

Here is what Diggama supports, per its documentation:

  • Image fields are filled by uploading in the dashboard: JPEG, PNG, GIF, WebP or AVIF, up to 10 MB. The API returns them as an absolute URL on Diggama’s storage. The REST API has no file upload endpoint, and the MCP server cannot upload files either.
  • Rich text keeps <img> tags whose src is an http or https URL, so inline images can point to wherever you re-host them. Editors can later replace one with the editor’s From a URL option, which copies the image into Diggama.
  • Link fields store any URL as a string. That is what the script writes migrated covers to.

That gives two workable options for image fields:

OptionHowGood for
Re-upload in DiggamaImport text with the script, then upload each cover in the dashboardSmall collections, images editors will change
Re-host and linkServe files from your domain or R2, store the URL in a Link fieldLarge archives that will not change

Northwind uses both: the migrated posts keep their cover in cover-url, and new posts get an uploaded cover. The template shows cover when it is set and falls back to cover-url.

Download the assets

When ASSET_BASE_URL is set, the import script rewrites every Webflow asset URL it finds in Link fields and in rich text <img> tags, and writes the list to webflow-assets.tsv (original URL, then new file name). Download them into the site’s public/ folder:

mkdir -p public/media/webflow
while IFS=$'\t' read -r url name; do
  curl -sSfL "$url" -o "public/media/webflow/$name" || echo "failed: $url"
done < webflow-assets.tsv

Files in public/ are deployed as Cloudflare static assets, which allow 20,000 files per Worker version on the Free plan and 100,000 on Paid, each up to 25 MiB (Workers limits). If you would rather keep binaries out of Git, upload them to an R2 bucket with a public custom domain instead. Wrangler uploads one object per command, so loop, and pass --remote so the files go to Cloudflare rather than to local storage (R2 commands):

for f in public/media/webflow/*; do
  npx wrangler r2 object put "northwind-media/webflow/$(basename "$f")" --file "$f" --remote
done

Step 5: How do you import the CSV into Diggama with the bulk API?

Import with a script that reads the CSV, converts each row to the blueprint’s field keys, and sends batches of up to 100 records to Diggama’s bulk create endpoint, POST /v2/resources/{blueprint}/bulk. The script below also skips slugs that already exist, so you can run it again after fixing a failed row.

Create a migration token

In the Diggama dashboard, create a token under Configuration › API Tokens with three abilities on posts only: preview (to list existing records, drafts included), create (bulk create) and update (to set each post’s publication date). When the migration is done, revoke it. Starter includes one API key (Growth has three, Enterprise five, see pricing). On Starter, add these abilities to your only token for the import, then edit it back: editing a token changes its abilities and keeps its value, so the build keeps working.

Create your rebuild Workflows after the import, not before. Workflow events fire for changes made through the API too, so a Workflow that calls the Cloudflare deploy hook would receive one event per imported record.

The import script

npm install csv-parse
scripts/import-webflow-csv.mjs
// Import a Webflow CMS collection (CSV export) into a Diggama blueprint.
//
//   DIGGAMA_TOKEN=... node scripts/import-webflow-csv.mjs migration/posts.csv
//
// Environment:
//   DIGGAMA_TOKEN      project token with preview, create and update on the blueprint
//   DIGGAMA_API_URL    defaults to https://api.diggama.com/v2
//   DIGGAMA_BLUEPRINT  defaults to posts
//   ASSET_BASE_URL     where you will re-host Webflow images, e.g. https://www.northwind-studio.example/media/webflow
//   DRY_RUN=1          parse and map only, call nothing
import { readFile, writeFile } from 'node:fs/promises';
import { parse } from 'csv-parse/sync';

const API = (process.env.DIGGAMA_API_URL ?? 'https://api.diggama.com/v2').replace(/\/+$/, '');
const TOKEN = process.env.DIGGAMA_TOKEN;
const BLUEPRINT = process.env.DIGGAMA_BLUEPRINT ?? 'posts';
const ASSET_BASE_URL = process.env.ASSET_BASE_URL?.replace(/\/+$/, '');
const DRY_RUN = process.env.DRY_RUN === '1';
const BATCH_SIZE = 100; // Diggama bulk create accepts at most 100 items per request

// Diggama field key -> Webflow CSV column and Diggama field type.
// Edit this block for each collection you migrate.
const FIELDS = {
  title: { column: 'Name', type: 'text' },
  slug: { column: 'Slug', type: 'slug' },
  excerpt: { column: 'Post Summary', type: 'text' },
  content: { column: 'Post Body', type: 'rich-text' },
  'cover-url': { column: 'Main Image', type: 'link' },
};
const DATE_COLUMN = 'Published On'; // the date the post should carry once published
const ARCHIVED_COLUMN = 'Archived'; // rows with "true" are skipped
const DRAFT_COLUMN = 'Draft'; // rows with "true" are imported as drafts

const WEBFLOW_ASSET = /^https?:\/\/[^/]*(website-files\.com|webflow\.com)\//i;
const assets = new Map(); // original Webflow URL -> file name under ASSET_BASE_URL

function rehost(url) {
  if (!ASSET_BASE_URL || !WEBFLOW_ASSET.test(url)) return url;
  if (!assets.has(url)) {
    let name = new URL(url).pathname.split('/').pop() || 'asset';
    try { name = decodeURIComponent(name); } catch { /* keep it encoded */ }
    assets.set(url, name.replace(/[^A-Za-z0-9._-]+/g, '-'));
  }
  return `${ASSET_BASE_URL}/${assets.get(url)}`;
}

// Diggama keeps h2 to h4 in rich text and unwraps other headings,
// so remap them instead of losing the structure.
function cleanRichText(html, slug) {
  if (/<(iframe|script)\b/i.test(html)) {
    console.warn(`  [${slug}] contains an embed or script: only YouTube and Vimeo iframes survive, check this post by hand`);
  }
  return html
    .replace(/<(\/?)h1\b/gi, '<$1h2')
    .replace(/<(\/?)h[56]\b/gi, '<$1h4')
    .replace(/(<img\b[^>]*?\bsrc=")([^"]+)(")/gi, (_, before, src, after) => before + rehost(src) + after);
}

function toDate(value) {
  const date = new Date(value);
  if (Number.isNaN(date.getTime())) throw new Error(`not a date: "${value}"`);
  return date;
}

function convert(raw, type, slug) {
  const value = (raw ?? '').trim();
  if (value === '') return null;
  switch (type) {
    case 'text':
    case 'slug':
    case 'email':
    case 'color':
    case 'enum':
      return value;
    case 'rich-text':
      return cleanRichText(value, slug);
    case 'link':
    case 'file':
      return rehost(value);
    case 'boolean':
      return value.toLowerCase() === 'true';
    case 'number':
      return Number(value);
    case 'multi-enum':
      return value.split(';').map((v) => v.trim()).filter(Boolean);
    case 'date':
      return toDate(value).toISOString().slice(0, 10);
    case 'datetime':
      return toDate(value).toISOString();
    default:
      throw new Error(`no converter for field type "${type}"`);
  }
}

async function api(method, path, body) {
  for (let attempt = 1; ; attempt++) {
    const res = await fetch(`${API}${path}`, {
      method,
      headers: {
        Accept: 'application/json',
        Authorization: `Bearer ${TOKEN}`,
        ...(body ? { 'Content-Type': 'application/json' } : {}),
      },
      body: body ? JSON.stringify(body) : undefined,
    });
    const json = res.status === 204 ? null : await res.json().catch(() => null);
    if (res.status === 429 && attempt <= 5) {
      const wait = Number(json?.error?.details?.retry_after ?? 10);
      console.warn(`  rate limited, retrying in ${wait}s`);
      await new Promise((resolve) => setTimeout(resolve, wait * 1000));
      continue;
    }
    if (!res.ok) {
      const e = json?.error ?? {};
      throw new Error(`${method} ${path} -> ${res.status} ${e.code ?? ''} ${e.message ?? ''} ${JSON.stringify(e.details ?? '')}`);
    }
    return { status: res.status, json };
  }
}

// Slugs already in Diggama (drafts included, hence the preview ability),
// so running the script twice does not create duplicates.
async function existingSlugs() {
  const slugs = new Set();
  for (let page = 1; ; page++) {
    const { json } = await api('GET', `/resources/${BLUEPRINT}?fields=id,attributes&per_page=100&page=${page}`);
    for (const resource of json.data) slugs.add(resource.attributes.slug);
    if (page >= json.meta.last_page) return slugs;
  }
}

async function main() {
  const file = process.argv[2];
  if (!file) throw new Error('usage: node scripts/import-webflow-csv.mjs <export.csv>');
  if (!TOKEN && !DRY_RUN) throw new Error('DIGGAMA_TOKEN is not set');

  const records = parse(await readFile(file), { columns: true, bom: true, skip_empty_lines: true, relax_column_count: true });
  const header = Object.keys(records[0] ?? {});
  const missing = Object.values(FIELDS).map((f) => f.column).concat(DATE_COLUMN).filter((c) => !header.includes(c));
  if (missing.length) throw new Error(`columns not found in CSV: ${missing.join(', ')}\nCSV columns: ${header.join(', ')}`);

  const items = [];
  const skipped = [];
  records.forEach((record, i) => {
    const row = i + 2; // spreadsheet row number, the header is row 1
    const slug = record[FIELDS.slug.column]?.trim();
    if (record[ARCHIVED_COLUMN] === 'true') return skipped.push({ row, slug, reason: 'archived in Webflow' });
    if (!slug) return skipped.push({ row, slug, reason: 'no slug' });
    try {
      const attributes = {};
      for (const [key, { column, type }] of Object.entries(FIELDS)) {
        attributes[key] = convert(record[column], type, slug);
      }
      const isDraft = record[DRAFT_COLUMN] === 'true' || !record[DATE_COLUMN]?.trim();
      const publishedAt = isDraft ? null : toDate(record[DATE_COLUMN].trim()).toISOString();
      items.push({ row, slug, attributes, publishedAt });
    } catch (err) {
      skipped.push({ row, slug, reason: err.message });
    }
  });

  console.log(`${records.length} rows, ${items.length} to import, ${skipped.length} skipped`);

  if (assets.size) {
    const manifest = [...assets].map(([url, name]) => `${url}\t${name}`).join('\n') + '\n';
    await writeFile('webflow-assets.tsv', manifest);
    console.log(`${assets.size} Webflow assets listed in webflow-assets.tsv`);
  }

  if (DRY_RUN) {
    console.log(JSON.stringify(items.slice(0, 2), null, 2));
    return;
  }

  const existing = await existingSlugs();
  const todo = items.filter((item) => {
    if (!existing.has(item.slug)) return true;
    skipped.push({ row: item.row, slug: item.slug, reason: 'already in Diggama' });
    return false;
  });

  const created = [];
  const failed = [];
  for (let start = 0; start < todo.length; start += BATCH_SIZE) {
    const batch = todo.slice(start, start + BATCH_SIZE);
    // No published_at: bulk create applies one date to the whole request,
    // so everything is created as a draft and dated below.
    const { status, json } = await api('POST', `/resources/${BLUEPRINT}/bulk`, {
      resources: batch.map((item) => ({ attributes: item.attributes })),
    });
    for (const { id, index } of json.data.created) created.push({ ...batch[index], id });
    for (const { index, errors } of json.data.failed) failed.push({ row: batch[index].row, slug: batch[index].slug, errors });
    console.log(`batch ${start / BATCH_SIZE + 1}: ${status}, ${json.meta.created} created, ${json.meta.failed} failed`);
  }

  // Bulk update accepts a published_at per item, so each post gets its own date.
  const toPublish = created.filter((item) => item.publishedAt);
  let published = 0;
  for (let start = 0; start < toPublish.length; start += BATCH_SIZE) {
    const batch = toPublish.slice(start, start + BATCH_SIZE);
    const { json } = await api('PATCH', `/resources/${BLUEPRINT}/bulk`, {
      updates: batch.map((item) => ({ id: item.id, published_at: item.publishedAt })),
    });
    published += json.meta.updated;
    for (const { index, error, errors } of json.data.failed) {
      failed.push({ row: batch[index].row, slug: batch[index].slug, errors: errors ?? error, note: 'created as a draft, not published' });
    }
  }
  console.log(`${created.length} created, ${published} published with their original date, ${failed.length} failed`);

  const report = {
    created: created.map(({ row, slug, id, publishedAt }) => ({ row, slug, id, publishedAt })),
    failed,
    skipped,
  };
  await writeFile('import-report.json', JSON.stringify(report, null, 2));
  console.log('details in import-report.json');
  if (failed.length) process.exitCode = 1;
}

main().catch((err) => {
  console.error(err.message);
  process.exit(1);
});

Why the script works this way

Each choice follows a documented behavior of the Diggama API:

  • Batches of 100. Bulk create accepts at most 100 items per request; more is rejected with 422 VALIDATION_ERROR before anything is written.
  • Drafts first, dates second. Bulk create takes a single published_at at the top level of the request, applied to every record in it. Blog posts each need their own date, so the script creates drafts, then calls bulk update (PATCH /v2/resources/{blueprint}/bulk), which accepts a published_at per item (publishing). Setting it only needs the update ability. If you do not care about dates, add published_at to the bulk create body and drop the second loop.
  • Partial failures are normal. Valid items are written, failed ones come back in data.failed with their index. Bulk create answers 207 Multi-Status in that case, with field errors per item. Bulk update answers 200 even when items fail (for example "error": "forbidden" when the token lacks update), so the script reads data.failed, not the status code. It maps each index back to its spreadsheet row in import-report.json and exits with code 1.
  • Rate limits. A project may make 1,000 requests per minute across all its tokens. On 429, the script waits for error.details.retry_after seconds, as documented in errors.
  • Re-runnable. Existing slugs are read first, 100 per page (pagination), and skipped. Fix the failed rows in the CSV and run the same command again. A row marked created as a draft, not published in the report already exists in Diggama, so a second run skips it: fix the token, then publish those posts in the dashboard.
  • Which date. Published On in a Webflow export is when the item was last published, not necessarily when the post first appeared. If your collection has its own date field, set DATE_COLUMN to it.

Run it

Dry-run first. It calls nothing, prints the first two mapped records, and writes the asset list:

DRY_RUN=1 ASSET_BASE_URL=https://www.northwind-studio.example/media/webflow \
  node scripts/import-webflow-csv.mjs migration/posts.csv

Then the real run:

DIGGAMA_TOKEN="your-migration-token" \
ASSET_BASE_URL=https://www.northwind-studio.example/media/webflow \
  node scripts/import-webflow-csv.mjs migration/posts.csv

We tested the script with Node.js 22 against a local mock of the list, bulk create and bulk update endpoints that enforces the documented payload format, the 100-item limit and 429 responses. With a 235-row Webflow-style CSV (one archived row, one draft, one row without a slug, rich text with quotes and line breaks) and the mock forcing one 429, the output was:

  [content-model-first] contains an embed or script: only YouTube and Vimeo iframes survive, check this post by hand
235 rows, 233 to import, 2 skipped
233 Webflow assets listed in webflow-assets.tsv
batch 1: 201, 100 created, 0 failed
batch 2: 201, 100 created, 0 failed
batch 3: 201, 33 created, 0 failed
  rate limited, retrying in 1s
233 created, 232 published with their original date, 0 failed
details in import-report.json

A second run created nothing and listed all 233 slugs as already in Diggama. Point DIGGAMA_API_URL at your own mock if you want to rehearse before touching the real project.

Small collections: import through MCP

For a collection of a few dozen items, your agent can do the import itself through Diggama’s MCP server, whose bulk_create_resources tool creates up to 25 records per call. Create the connection under Configuration › Connect to AI with create on the target blueprint only, and add it to your agent as shown in step 6:

Prompt for your AI agent
Read migration/team.csv (a Webflow export of our team collection). Use the diggama MCP server: call describe_blueprint for team-members and map the columns Name, Role, Photo and Bio to its fields. Show me the mapped records as a table first and wait for my go.

After I confirm, create them with bulk_create_resources in batches of 25, as drafts. Leave photo empty: I will upload photos in the dashboard. Report the created IDs and any validation errors.

Two differences from the REST script matter. bulk_create_resources is all-or-nothing: if one record fails validation, nothing is written and the error lists the failing records by index. And bulk_create_resources always creates drafts, which a view build token does not see, so publish the records in the dashboard afterwards, or give the connection the publish ability and ask the agent to call publish_resource for each one (MCP guide).

Step 6: How do you rebuild the pages with an AI coding agent?

Rebuild each page by giving your agent three inputs at once: a screenshot of the Webflow page, the exported HTML of that page, and the Diggama blueprint it should read from. The screenshot gives the layout, the HTML gives the exact structure and copy, and the blueprint tells the agent which text must come from the CMS instead of being typed into a component.

Set up the project as described in the Astro tutorial first: the project rules that forbid hardcoded copy in AGENTS.md (start from the AGENTS.md template), the typed Diggama client in src/lib/diggama.ts with its cms instance in src/lib/cms.ts, and the Diggama MCP connection so the agent reads real field keys. Only two things depend on the agent you use:

AgentReads the project rules fromAdd the Diggama MCP server with
Claude CodeCLAUDE.md, a symbolic link to AGENTS.md (AGENTS.md alone is read only when no CLAUDE.md exists)claude mcp add --transport http diggama https://api.diggama.com/mcp --header "Authorization: Bearer $DIGGAMA_MCP_TOKEN" (docs)
OpenAI CodexAGENTS.md (native)codex mcp add diggama --url https://api.diggama.com/mcp --bearer-token-env-var DIGGAMA_MCP_TOKEN (docs)
CursorAGENTS.md (native)~/.cursor/mcp.json or .cursor/mcp.json, with "Authorization": "Bearer ${env:DIGGAMA_MCP_TOKEN}" under headers (docs)
GitHub Copilot agent modeAGENTS.md (chat.useAgentsMdFile, on by default).vscode/mcp.json, with a password inputs prompt and "Authorization": "Bearer ${input:diggama-token}" under headers (docs)
Gemini CLIGEMINI.md, unless you add { "context": { "fileName": ["AGENTS.md", "GEMINI.md"] } } to its settings (docs)gemini mcp add --transport http --scope user --header "Authorization: Bearer $DIGGAMA_MCP_TOKEN" diggama https://api.diggama.com/mcp (docs)

DIGGAMA_MCP_TOKEN holds the token of your Diggama MCP connection, kept apart from DIGGAMA_TOKEN, which the import script and the site build use. Export it in your shell before you run these commands. Codex and Cursor read the variable each time they connect. The Claude Code and Gemini CLI commands expand it once, when you run them, and store the value in your home directory (~/.claude.json, ~/.gemini/settings.json), outside the repository.

The MCP server guide has the complete config for each tool, including how to keep the token out of files you commit.

Then save full-page screenshots of each Webflow page at desktop and mobile widths in migration/screenshots/. The prompt below points the agent at those files. If your tool does not open images from disk, attach them to the conversation instead.

Prompt for your AI agent
Rebuild the Webflow homepage as src/pages/index.astro.

Inputs:
- migration/screenshots/home-desktop.png and home-mobile.png: the target design.
- migration/webflow-export/index.html: the structure and copy of the old page.
- The homepage blueprint in Diggama (use the diggama MCP server, describe_blueprint homepage).

Rules:
- Every headline, paragraph and image comes from cms.homepage() (src/lib/cms.ts). Do not copy text from the HTML into the component.
- Do not reuse Webflow class names, webflow.js or the exported CSS. Write the styles with the project's own CSS, mobile first.
- Rebuild Webflow interactions only if they matter in the screenshot, with CSS transitions and no animation library.
- If the old page has text that no homepage field can hold, list it and stop. Do not invent a field or a fallback.

When done, run npm run build and compare the result with the desktop screenshot section by section.

Before the homepage component can read anything, its copy has to be in Diggama. For a singular blueprint like homepage that is one record: paste the values in the dashboard, or ask your agent to read them from the exported HTML and propose an update_resource call you approve.

For the blog template, the only migration-specific change is the cover fallback:

Prompt for your AI agent
Update the post template and the blog index so the cover image is attributes.cover when it is set, otherwise attributes['cover-url'], otherwise no image. Add cover-url to the Post type in src/lib/diggama.ts as string | null. Keep the explicit width and height on the img. Then run npm run build and confirm that posts with only cover-url still show their image.

When every page is rebuilt, check that nothing slipped into the code:

Prompt for your AI agent
Compare the visible text of every page in dist/ with the copy in migration/webflow-export/. List every sentence that appears in a component file under src/ instead of coming from Diggama, with the file and line. Navigation labels, form labels and the legal footer are allowed.

Step 7: How do you keep your Webflow URLs and set up 301 redirects on Cloudflare?

Keep every URL that you can, and send a 301 from every URL you change to its new address with a _redirects file in your site’s public/ folder. Cloudflare’s static assets apply it before serving pages, with up to 2,000 static and 100 dynamic rules per file (Cloudflare docs).

Keeping URLs is the cheapest option: if the Webflow blog lived at /post/<slug>, you can ask your agent to put the Astro route at src/pages/post/[slug].astro and you need no redirect at all. Northwind chose to move posts to /blog/<slug>/, so it needs rules:

public/_redirects
# Static pages: Webflow URLs have no trailing slash, the Astro build uses one
/about /about/ 301
/contact /contact/ 301

# Team member pages became one /team/ page
/team/ana-ruiz /team/ 301
/team/tom-becker /team/ 301

# Copied from Webflow (Site settings > Publishing > 301 redirects)
/our-story /about/ 301

# Rules with placeholders or splats go after the static ones
/post/:slug /blog/:slug/ 301
/post/:slug/ /blog/:slug/ 301
/old-news/* /blog/ 301

We ran this exact file under a local wrangler dev (Wrangler 4.148), on a static build and on a Worker with a main script and static assets, as the Astro adapter generates. Every rule answered as commented, and three details stood out:

  • Trailing slashes. Without a rule, Cloudflare’s default auto-trailing-slash handling answers /about with a 307 to /about/ (HTML handling). A 307 is temporary. An explicit /about /about/ 301 line returned a 301 and did not loop.
  • Placeholders match one form only. /post/:slug matched /post/hello but not /post/hello/, which returned 404 without the second line.
  • Order matters. For the same source path the top-most rule wins, and Cloudflare asks for static rules before dynamic ones. Wrangler logs a note for each static rule placed below a placeholder or splat.

Webflow wildcard redirects use regular expression groups, /old-folder/(.*) to /new-folder/%1 (Webflow help), and Webflow paths may contain a % before special characters such as hyphens. Drop those escapes when you copy a rule. The Cloudflare equivalent is /old-folder/* /new-folder/:splat 301. A placeholder or splat counts toward the 100 dynamic rules.

Cloudflare does not apply _redirects to requests served by your Worker code. With the default configuration, the static assets layer sees each request first, so in our test the old URLs redirected even though they matched no file and would otherwise have reached the Worker. With "run_worker_first": true in the assets block of wrangler.jsonc, the same requests went straight to the Worker and no rule applied: in that setup, put the redirects in your framework or Worker code instead.

If you need more than 2,100 rules, use Bulk Redirects at the account level: 10,000 URL redirects across lists on the Free plan, 25,000 on Pro, 50,000 on Business.

Let your agent write the file from the inventory:

Prompt for your AI agent
Using migration/old-urls.txt, migration/INVENTORY.md and the routes in src/pages, write public/_redirects. For every old URL that does not exist in the new build, add a 301 to its closest new page. Use one placeholder rule per collection instead of one line per item when the slug is unchanged. Add an explicit 301 for every static page whose only change is the trailing slash. Put static rules above rules with placeholders or splats. Then run npm run build and list any old URL that still has no target.

Step 8: How do you replace Webflow forms?

Replace each Webflow form with a form that posts to a server endpoint on your site, which writes the submission to Diggama’s contact-submissions blueprint using a token that can only create records there. Webflow forms submit to Webflow, so they stop working the moment the site leaves Webflow hosting.

The Astro tutorial and the Next.js tutorial build that endpoint file by file. Two migration tasks remain:

  • Export old submissions. In Webflow, export them as CSV from the Forms tab of site settings, and archive the file. Importing them into contact-submissions is optional; the script above dedupes on slug, which submissions do not have, so adapt it or use MCP.
  • Notifications. Webflow emailed you on each submission. In Diggama, create a Workflow with an Event type condition (Resource Created), a Resource type condition (contact-submissions) and a Send email action. The body can include {{name}}, {{email}} and {{message}}.

Step 9: How do you switch DNS from Webflow to Cloudflare?

Switch DNS last, after the new site has passed every check on its workers.dev URL: move the domain’s zone to Cloudflare, delete the records that point to Webflow, and attach the domain to your Worker as a custom domain. Keep the Webflow project untouched until the new site has run cleanly.

  1. Test on workers.dev. Rewrite the old URL list to the Worker’s host with sed 's#https://www.northwind-studio.example#https://northwind-studio.YOUR-SUBDOMAIN.workers.dev#' and run the check from the launch section below. Every URL must end on a 200.
  2. Lower the TTL of the records that point to Webflow a day ahead, so a rollback propagates quickly.
  3. Add the domain to Cloudflare if it is not there yet. Cloudflare tries to import the existing records during setup; check that every MX, TXT (SPF, DKIM, DMARC) and verification record came across before you change the nameservers at your registrar, or email breaks.
  4. Delete the Webflow records for the apex and www. Cloudflare will not create a Worker custom domain on a hostname that already has a CNAME record (custom domains).
  5. Attach the domain in the Worker: Settings > Domains & Routes > Add > Custom Domain. Cloudflare creates the DNS record and the certificate. Add both www.northwind-studio.example and the apex, and redirect one to the other.
  6. Remove the domain from Webflow once traffic reaches Cloudflare, then downgrade the site plan when you are confident. Delete the project only after every asset is re-hosted.

Post-launch SEO checklist for a Webflow migration

Run the URL check the day you switch DNS, then watch Search Console for the following weeks. Every item below takes minutes and catches the problems that lose traffic after a platform change.

Check every old URL against the live domain. Anything that is not a 200 needs a look; a 301 should land on a 200:

while read -r url; do
  curl -s -o /dev/null -L -w "%{http_code} $url -> %{url_effective}\n" "$url"
done < migration/old-urls.txt | grep -v '^200'
  • No 404 on old URLs. The loop above prints nothing when every URL resolves.
  • Titles and descriptions carried over. Compare the new <title> and meta description of your top pages with the inventory.
  • Canonical URLs use the final domain and the trailing slash form you chose.
  • Sitemap submitted in Google Search Console and Bing Webmaster Tools, at the new path if it changed (Astro’s sitemap integration writes sitemap-index.xml).
  • robots.txt allows crawling and points to the sitemap.
  • Open Graph images resolve on the new domain, not on Webflow’s CDN.
  • No Webflow asset URLs left. Search dist/ for website-files.com and webflow.com, and query Diggama with GET /v2/resources/posts?filter[content][contains]=website-files.com (filtering).
  • Analytics and tags from the custom code inventory are installed.
  • The old webflow.io subdomain is unpublished or kept out of search, so it does not compete with the real domain.
  • Search Console coverage for two to four weeks: watch the “Not found (404)” and “Page with redirect” reports.

Then run the live homepage and a post through the website grader.

What does the migrated stack look like day to day?

After the migration, editors change content in Diggama and click Publish, a Diggama Workflow calls the Cloudflare deploy hook, and the site rebuilds. Developers change the code with their AI coding agent and push to Git. Nobody needs a Webflow seat, and nobody needs to open the code to fix a typo.

The pillar guide explains that loop, the token setup and the handover to the marketing team. Diggama plans start at 29.90 EUR per month with a three-month free trial and no credit card, and MCP, the AI Assistant and Workflows are on every plan (pricing). To compare the running costs with your Webflow plan, use the website cost calculator.

FAQ

Frequently Asked Questions

Something else? Get in touch.

Can I export a Webflow site together with its CMS content?

Not in one file. Webflow's code export, available on paid Workspace plans, produces the HTML, CSS and JavaScript of your static pages, but collection lists come out empty and collection items get no pages. CMS content leaves Webflow separately, one collection at a time, as a CSV file from the Designer or as JSON through the Data API v2.

Will I lose my Google rankings if I leave Webflow?

Rankings are tied to URLs and content, not to the platform. If every old URL either still exists or answers a 301 to its new address, and titles, meta descriptions and page content carry over, search engines treat the move as a site update. What loses traffic is URLs that start returning 404, so test the full list from your old sitemap before and after the DNS switch.

What happens to my Webflow images after I cancel?

CSV exports give image fields as URLs on Webflow's asset CDN, and Webflow's help center warns that those links break permanently if the original project is deleted. Download every asset and re-host it before you delete anything. Downgrading the site plan and deleting the project are separate decisions, so keep the project until the new site has run without errors for a few weeks.

Can an AI coding agent do the Webflow migration on its own?

An agent such as Claude Code, OpenAI Codex or Cursor can do most of the mechanical work: build the URL inventory, propose the content model, write and run the import script, rebuild each page from a screenshot and the exported HTML, and generate the redirects file. It works on your files and terminal, not in your Webflow account, so you export the CSV files, the code ZIP and the screenshots yourself. You also review every diff and decide the final URL structure.

Which AI coding agent should I use to migrate from Webflow?

Any agent that edits files, runs terminal commands and connects to a remote MCP server with an Authorization header works: Claude Code, OpenAI Codex, Cursor, Gemini CLI and GitHub Copilot agent mode all do. The prompts in this guide are the same for each. Only the instructions file the agent reads and the place where you configure the Diggama MCP server differ, and step 6 lists both per tool.

How do I replace Webflow forms on the new site?

The form posts to a small server endpoint on your new site, which writes each submission to a read-only blueprint in the CMS with a token that can only create records there. In Diggama, a Workflow with a Send email action replaces Webflow's notification email. Export your past submissions from Webflow as CSV first, because they stay behind when you close the site.

How much content can I import into Diggama at once?

The bulk create and bulk update endpoints accept up to 100 records per request, and a project may make 1,000 API requests per minute. The script in this guide creates records in batches of 100, then dates them in batches of 100, so 3,000 posts take about 60 write requests. It waits and retries when the API answers 429, so nothing needs to be split by hand.

Keep reading

Related guides.

Your site, written by AI. Your content, managed in Diggama.

Model your content once, fetch it from Astro, Next.js or plain HTML, and let your team edit it without touching the code.

No credit card required · 3 months free trial · Cancel anytime