"Execute major Webflow migrations \u2014 from other CMS platforms to\
Scanned 5/29/2026
Install via CLI
openskills install jeremylongshore/claude-code-plugins-plus-skills---
name: webflow-migration-deep-dive
description: "Execute major Webflow migrations \u2014 from other CMS platforms to\
\ Webflow CMS,\nbetween Webflow sites, or large-scale content re-architecture using\
\ the Data API v2\nbulk endpoints, strangler fig pattern, and data validation.\n\
Trigger with phrases like \"migrate to webflow\", \"webflow migration\",\n\"import\
\ into webflow\", \"webflow replatform\", \"move content to webflow\",\n\"webflow\
\ bulk import\", \"wordpress to webflow\".\n"
allowed-tools: Read, Write, Edit, Bash(npm:*), Bash(npx:*), Bash(node:*)
version: 1.0.0
license: MIT
author: Jeremy Longshore <jeremy@intentsolutions.io>
tags:
- saas
- design
- no-code
- webflow
compatibility: Designed for Claude Code
---
# Webflow Migration Deep Dive
## Overview
Comprehensive guide for migrating content to Webflow CMS via the Data API v2.
Covers assessment, data mapping, bulk import (100 items/batch), validation,
and rollback. Handles WordPress, Contentful, Strapi, CSV, and JSON source formats.
## Prerequisites
- `webflow-api` SDK installed
- API token with `cms:read` and `cms:write` scopes
- Target Webflow site with CMS collections created in the Designer
- Source data exported (JSON, CSV, or API access)
## Migration Types
| Migration | Source | Complexity | Duration |
|-----------|--------|-----------|----------|
| CSV/JSON import | Static files | Low | Hours |
| WordPress | WP REST API | Medium | Days |
| Contentful/Strapi | Headless CMS API | Medium | Days |
| Site-to-site | Another Webflow site | Low | Hours |
| Full replatform | Custom CMS | High | Weeks |
## Instructions
### Step 1: Assess Target Collection Schema
Before importing, understand exactly what fields the target collection expects:
```typescript
import { WebflowClient } from "webflow-api";
const webflow = new WebflowClient({
accessToken: process.env.WEBFLOW_API_TOKEN!,
});
async function assessTarget(siteId: string) {
const { collections } = await webflow.collections.list(siteId);
const schema: Record<string, any> = {};
for (const col of collections!) {
schema[col.slug!] = {
id: col.id,
displayName: col.displayName,
itemCount: col.itemCount,
fields: col.fields?.map(f => ({
slug: f.slug,
displayName: f.displayName,
type: f.type,
required: f.isRequired,
// Types: PlainText, RichText, Image, MultiImage, Video,
// Link, Email, Phone, Number, DateTime, Switch,
// Color, Option, File, Reference, MultiReference
})),
};
}
console.log(JSON.stringify(schema, null, 2));
return schema;
}
```
### Step 2: Build Data Transformer
Map source data format to Webflow's `fieldData` structure:
```typescript
interface SourcePost {
title: string;
content: string; // HTML content
excerpt: string;
author: string;
date: string; // ISO 8601
categories: string[];
featured_image?: string;
status: "published" | "draft";
}
interface WebflowFieldData {
name: string; // Required system field
slug: string; // Required system field
[key: string]: any; // Custom fields use slug format
}
function transformPost(source: SourcePost): {
fieldData: WebflowFieldData;
isDraft: boolean;
} {
return {
isDraft: source.status === "draft",
fieldData: {
// System fields (always required)
name: source.title,
slug: slugify(source.title),
// Custom fields (must match collection schema slugs)
"post-body": source.content,
"excerpt": source.excerpt,
"author-name": source.author,
"publish-date": source.date,
// Image fields use Webflow asset URLs
// You must upload images to Webflow first, or use external URLs
...(source.featured_image && {
"hero-image": {
url: source.featured_image,
alt: source.title,
},
}),
},
};
}
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "")
.substring(0, 256); // Webflow slug max length
}
```
### Step 3: WordPress Migration
```typescript
// Export from WordPress REST API
async function fetchWordPressPosts(wpUrl: string): Promise<SourcePost[]> {
const posts: SourcePost[] = [];
let page = 1;
while (true) {
const res = await fetch(`${wpUrl}/wp-json/wp/v2/posts?per_page=100&page=${page}`);
if (!res.ok) break;
const wpPosts = await res.json();
if (wpPosts.length === 0) break;
for (const wp of wpPosts) {
posts.push({
title: wp.title.rendered,
content: wp.content.rendered,
excerpt: wp.excerpt.rendered,
author: wp.author_name || "Unknown",
date: wp.date,
categories: wp.categories || [],
featured_image: wp.featured_media_url || undefined,
status: wp.status === "publish" ? "published" : "draft",
});
}
page++;
}
return posts;
}
```
### Step 4: CSV Import
```typescript
import { parse } from "csv-parse/sync";
import { readFileSync } from "fs";
function importFromCSV(filePath: string): SourcePost[] {
const content = readFileSync(filePath, "utf-8");
const records = parse(content, {
columns: true,
skip_empty_lines: true,
});
return records.map((row: any) => ({
title: row.title || row.Title || row.name,
content: row.content || row.body || row.description || "",
excerpt: row.excerpt || row.summary || "",
author: row.author || "Imported",
date: row.date || row.published_at || new Date().toISOString(),
categories: (row.categories || row.tags || "").split(",").map((s: string) => s.trim()),
featured_image: row.image || row.featured_image || undefined,
status: "published" as const,
}));
}
```
### Step 5: Bulk Import Engine
```typescript
interface MigrationResult {
total: number;
created: number;
skipped: number;
failed: number;
errors: Array<{ slug: string; error: string }>;
duration: number;
}
async function bulkImport(
collectionId: string,
sourceItems: SourcePost[],
options = { batchSize: 100, delayMs: 1000, dryRun: false }
): Promise<MigrationResult> {
const start = Date.now();
const result: MigrationResult = {
total: sourceItems.length,
created: 0,
skipped: 0,
failed: 0,
errors: [],
duration: 0,
};
// Get existing items to avoid duplicates
const existing = await fetchAllExistingItems(collectionId);
const existingSlugs = new Set(existing.map(i => i.fieldData?.slug));
// Transform and filter
const newItems = sourceItems
.map(transformPost)
.filter(item => {
if (existingSlugs.has(item.fieldData.slug)) {
result.skipped++;
return false;
}
return true;
});
console.log(`Migration plan: ${newItems.length} new, ${result.skipped} skipped (duplicates)`);
if (options.dryRun) {
console.log("DRY RUN — no items will be created");
result.duration = Date.now() - start;
return result;
}
// Batch import
for (let i = 0; i < newItems.length; i += options.batchSize) {
const batch = newItems.slice(i, i + options.batchSize);
const batchNum = Math.floor(i / options.batchSize) + 1;
const totalBatches = Math.ceil(newItems.length / options.batchSize);
try {
await webflow.collections.items.createItemsBulk(collectionId, {
items: batch,
});
result.created += batch.length;
console.log(`Batch ${batchNum}/${totalBatches}: ${batch.length} items created`);
} catch (error: any) {
result.failed += batch.length;
result.errors.push({
slug: `batch-${batchNum}`,
error: error.message,
});
console.error(`Batch ${batchNum} failed:`, error.message);
}
// Delay between batches to respect rate limits
if (i + options.batchSize < newItems.length) {
await new Promise(r => setTimeout(r, options.delayMs));
}
}
result.duration = Date.now() - start;
return result;
}
async function fetchAllExistingItems(collectionId: string) {
const allItems = [];
let offset = 0;
while (true) {
const { items, pagination } = await webflow.collections.items.listItems(
collectionId,
{ offset, limit: 100 }
);
allItems.push(...(items || []));
if (allItems.length >= (pagination?.total || 0)) break;
offset += 100;
}
return allItems;
}
```
### Step 6: Post-Migration Validation
```typescript
async function validateMigration(
collectionId: string,
sourceCount: number
): Promise<{ valid: boolean; checks: Array<{ name: string; passed: boolean; detail: string }> }> {
const checks = [];
// 1. Item count check
const { items, pagination } = await webflow.collections.items.listItems(
collectionId, { limit: 1 }
);
const webflowCount = pagination?.total || 0;
checks.push({
name: "Item count",
passed: webflowCount >= sourceCount,
detail: `Webflow: ${webflowCount}, Source: ${sourceCount}`,
});
// 2. Required fields check (sample first 10 items)
const { items: sample } = await webflow.collections.items.listItems(
collectionId, { limit: 10 }
);
const missingFields = (sample || []).filter(
i => !i.fieldData?.name || !i.fieldData?.slug
);
checks.push({
name: "Required fields",
passed: missingFields.length === 0,
detail: `${missingFields.length} items missing name/slug`,
});
// 3. No duplicate slugs
const allItems = await fetchAllExistingItems(collectionId);
const slugs = allItems.map(i => i.fieldData?.slug);
const uniqueSlugs = new Set(slugs);
checks.push({
name: "Unique slugs",
passed: slugs.length === uniqueSlugs.size,
detail: `${slugs.length - uniqueSlugs.size} duplicate slugs`,
});
// 4. Draft status check
const draftCount = allItems.filter(i => i.isDraft).length;
checks.push({
name: "Published items",
passed: true,
detail: `${allItems.length - draftCount} published, ${draftCount} drafts`,
});
const valid = checks.every(c => c.passed);
return { valid, checks };
}
```
### Step 7: Publish Migrated Content
```typescript
async function publishMigratedContent(collectionId: string) {
const allItems = await fetchAllExistingItems(collectionId);
const unpublished = allItems.filter(i => !i.isDraft).map(i => i.id!);
// Publish in batches (publish endpoint accepts multiple IDs)
for (let i = 0; i < unpublished.length; i += 100) {
const batch = unpublished.slice(i, i + 100);
await webflow.collections.items.publishItem(collectionId, {
itemIds: batch,
});
console.log(`Published ${Math.min(i + 100, unpublished.length)}/${unpublished.length}`);
if (i + 100 < unpublished.length) {
await new Promise(r => setTimeout(r, 1000));
}
}
}
```
### Step 8: Rollback Plan
```typescript
async function rollbackMigration(collectionId: string, createdAfter: Date) {
const allItems = await fetchAllExistingItems(collectionId);
const migratedItems = allItems.filter(
i => new Date(i.createdOn!) >= createdAfter
);
console.log(`Rolling back ${migratedItems.length} migrated items`);
// Delete in batches of 100
for (let i = 0; i < migratedItems.length; i += 100) {
const batch = migratedItems.slice(i, i + 100).map(item => item.id!);
await webflow.collections.items.deleteItemsBulk(collectionId, {
itemIds: batch,
});
console.log(`Deleted batch ${Math.floor(i / 100) + 1}`);
await new Promise(r => setTimeout(r, 500));
}
}
```
## Complete Migration Script
```bash
# 1. Dry run first
npx tsx migrate.ts --source wordpress --wp-url https://myblog.com --dry-run
# 2. Execute migration
npx tsx migrate.ts --source wordpress --wp-url https://myblog.com
# 3. Validate
npx tsx migrate.ts --validate --collection-id col-xxx
# 4. Publish
npx tsx migrate.ts --publish --collection-id col-xxx
# 5. If something goes wrong:
npx tsx migrate.ts --rollback --collection-id col-xxx --after 2026-03-22
```
## Output
- Source data assessment and schema mapping
- Data transformer (WordPress, CSV, JSON, headless CMS)
- Bulk import engine (100 items/batch with rate limit handling)
- Post-migration validation (count, fields, duplicates)
- Content publishing automation
- Rollback procedure with time-based filtering
## Error Handling
| Error | Cause | Solution |
|-------|-------|----------|
| `400 Bad Request` | Field name mismatch | Compare transformer output to collection schema |
| `409 Conflict` | Duplicate slugs | Add suffix or use `createOrUpdate` pattern |
| `429 Rate Limited` | Too fast between batches | Increase `delayMs` |
| Missing images | External image URLs blocked | Upload to Webflow assets first |
| Truncated HTML | Content too long | Check Webflow field length limits |
## Resources
- [CMS API Reference](https://developers.webflow.com/data/reference/cms)
- [Bulk CMS Endpoints](https://developers.webflow.com/data/changelog/10232024)
- [Managing Collections](https://developers.webflow.com/data/docs/working-with-the-cms/manage-collections-and-items)
- [Migrating to API v2](https://developers.webflow.com/data/docs/migrating-to-v2)
## Next Steps
This is the final skill in the Webflow pack. For foundational setup, start with
`webflow-install-auth`.
No comments yet. Be the first to comment!