On this page
Migrating 500 pages from WordPress to a headless CMS should take a weekend. In reality, it takes 3-6 weeks -- because someone has to manually copy content, fix formatting, re-link images, and verify every page. Content migration is the single most dreaded task in web development, and it is almost entirely automatable.
CrawlForge extracts your entire site's content programmatically: pages, metadata, images, links, and document structure. This guide shows you how to build a migration pipeline that moves thousands of pages between any two platforms in hours, not weeks.
Table of Contents
- Why Content Migration Is Painful
- Architecture Overview
- Step 1: Inventory Your Source Site
- Step 2: Extract Content and Metadata
- Step 3: Preserve Document Structure
- Step 4: Transform for the Target Platform
- Step 5: Validate the Migration
- Credit Cost Analysis
- Results and Benefits
- Frequently Asked Questions
Why Content Migration Is Painful
Content migration fails for three reasons:
- Volume: Even a small business site has 200-500 pages. Each page needs content, metadata, images, and internal links preserved
- Format mismatch: Source and target CMS use different content models (WordPress blocks vs. MDX vs. Contentful rich text)
- Hidden complexity: Shortcodes, embedded media, custom fields, redirects -- all need handling
Manual migration costs approximately $5-15 per page in analyst time. A 500-page migration at $10/page costs $5,000 in labor alone. Automated migration with CrawlForge costs under $50 in credits.
| Migration Method | Cost (500 pages) | Time | Error Rate |
|---|---|---|---|
| Manual copy-paste | $5,000-7,500 | 3-6 weeks | 5-10% |
| Semi-automated (scripts) | $2,000-3,000 | 1-2 weeks | 2-5% |
| CrawlForge pipeline | $20-50 | 2-4 hours | <1% |
Architecture Overview
The migration pipeline uses five CrawlForge tools:
| Stage | Tool | Credits | Purpose |
|---|---|---|---|
| Inventory | map_site | 3 | Discover all pages and their structure |
| Content extraction | extract_content | 2 | Pull clean content from each page |
| Metadata capture | extract_metadata | 1 | Preserve SEO tags and Open Graph data |
| Link mapping | extract_links | 1 | Map internal links for rewriting |
| Batch processing | batch_scrape | 5 | Process hundreds of pages efficiently |
Step 1: Inventory Your Source Site
Map every page on your source site, including pages that may not be in the navigation.
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
const client = new Client({
name: 'content-migrator',
version: '1.0.0',
});
interface SiteInventory {
pages: Array<{
url: string;
path: string;
depth: number;
type: 'page' | 'post' | 'category' | 'media' | 'other';
}>;
totalPages: number;
pathGroups: Record<string, number>;
}
async function inventorySite(siteUrl: string): Promise<SiteInventory> {
const mapResult = await client.callTool({
name: 'map_site',
arguments: {
url: siteUrl,
max_urls: 2000,
include_sitemap: true,
group_by_path: true,
include_metadata: true,
},
});
const result = JSON.parse(mapResult.content[0].text);
// Classify pages by URL pattern
const pages = result.urls.map((url: string) => {
const path = new URL(url).pathname;
let type: 'page' | 'post' | 'category' | 'media' | 'other' = 'other';
if (path.match(/\/\d{4}\/\d{2}\//)) type = 'post'; // WordPress date URLs
else if (path.match(/\/blog\//)) type = 'post';
else if (path.match(/\/category\//)) type = 'category';
else if (path.match(/\.(pdf|doc|jpg|png)/)) type = 'media';
else type = 'page';
return { url, path, depth: path.split('/').length - 1, type };
});
return {
pages,
totalPages: pages.length,
pathGroups: result.groupedByPath || {},
};
}Step 2: Extract Content and Metadata
Extract clean content and all metadata from every page, preserving heading structure and formatting.
interface MigratedPage {
sourceUrl: string;
slug: string;
title: string;
content: string; // Clean markdown or HTML
excerpt: string;
metadata: {
title: string;
description: string;
ogImage: string;
canonical: string;
publishedAt: string;
author: string;
};
internalLinks: string[];
images: string[];
}
async function extractPage(url: string): Promise<MigratedPage> {
// Extract clean content (readability-optimized)
const contentResult = await client.callTool({
name: 'extract_content',
arguments: { url },
});
const content = JSON.parse(contentResult.content[0].text);
// Extract metadata separately for completeness
const metaResult = await client.callTool({
name: 'extract_metadata',
arguments: { url },
});
const meta = JSON.parse(metaResult.content[0].text);
// Extract links for internal link mapping
const linksResult = await client.callTool({
name: 'extract_links',
arguments: { url },
});
const links = JSON.parse(linksResult.content[0].text);
const internalLinks = (links.internal || []).map(
(l: { href: string }) => l.href
);
// Extract image URLs from content
const imageRegex = /!\[.*?\]\((.*?)\)/g;
const images: string[] = [];
let match;
while ((match = imageRegex.exec(content.content)) !== null) {
images.push(match[1]);
}
const slug = new URL(url).pathname
.replace(/^\//,'')
.replace(/\/$/,'')
.replace(/\//g, '-');
return {
sourceUrl: url,
slug,
title: meta.title || content.title || '',
content: content.content,
excerpt: meta.description || content.excerpt || '',
metadata: {
title: meta.title || '',
description: meta.description || '',
ogImage: meta.ogImage || '',
canonical: meta.canonical || url,
publishedAt: meta.publishedDate || '',
author: meta.author || '',
},
internalLinks,
images,
};
}Step 3: Preserve Document Structure
For large sites, use batch processing and build a complete link map for URL rewriting.
type UrlMap = Map<string, string>; // old URL -> new slug
async function batchExtract(
inventory: SiteInventory
): Promise<{ pages: MigratedPage[]; urlMap: UrlMap }> {
const pages: MigratedPage[] = [];
const urlMap: UrlMap = new Map();
// Process in batches of 20
const batchSize = 20;
const urls = inventory.pages
.filter(p => p.type === 'page' || p.type === 'post')
.map(p => p.url);
for (let i = 0; i < urls.length; i += batchSize) {
const batch = urls.slice(i, i + batchSize);
console.log(
`Processing batch ${Math.floor(i / batchSize) + 1}/${Math.ceil(urls.length / batchSize)}`
);
// Extract each page in the batch
for (const url of batch) {
try {
const page = await extractPage(url);
pages.push(page);
urlMap.set(url, page.slug);
} catch (error) {
console.error(`Failed to extract ${url}:`, error);
}
}
}
return { pages, urlMap };
}Step 4: Transform for the Target Platform
Rewrite internal links and transform content to match your target CMS format.
function transformForTarget(
page: MigratedPage,
urlMap: UrlMap,
targetFormat: 'mdx' | 'contentful' | 'sanity'
): Record<string, unknown> {
// Rewrite internal links to new URL structure
let content = page.content;
for (const [oldUrl, newSlug] of urlMap) {
content = content.replace(
new RegExp(oldUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'),
`/${newSlug}`
);
}
switch (targetFormat) {
case 'mdx':
return {
slug: page.slug,
title: page.title,
description: page.metadata.description,
date: page.metadata.publishedAt || new Date().toISOString(),
content: content, // Already markdown from extract_content
};
case 'contentful':
return {
fields: {
title: { 'en-US': page.title },
slug: { 'en-US': page.slug },
body: { 'en-US': content },
description: { 'en-US': page.metadata.description },
publishedAt: { 'en-US': page.metadata.publishedAt },
},
contentType: 'blogPost',
};
case 'sanity':
return {
_type: 'post',
title: page.title,
slug: { current: page.slug },
body: content,
excerpt: page.excerpt,
publishedAt: page.metadata.publishedAt,
};
default:
return { ...page, content };
}
}Step 5: Validate the Migration
After transforming, verify that every page was migrated correctly.
interface ValidationResult {
totalPages: number;
successCount: number;
failedCount: number;
warnings: Array<{
slug: string;
issue: string;
}>;
}
function validateMigration(
original: SiteInventory,
migrated: MigratedPage[]
): ValidationResult {
const warnings: Array<{ slug: string; issue: string }> = [];
for (const page of migrated) {
// Check for empty content
if (!page.content || page.content.trim().length < 50) {
warnings.push({
slug: page.slug,
issue: 'Content appears empty or very short',
});
}
// Check for missing metadata
if (!page.metadata.title) {
warnings.push({ slug: page.slug, issue: 'Missing title' });
}
if (!page.metadata.description) {
warnings.push({ slug: page.slug, issue: 'Missing meta description' });
}
// Check for broken internal links
for (const link of page.internalLinks) {
const isLinkMigrated = migrated.some(
p => p.sourceUrl === link || link.includes(p.slug)
);
if (!isLinkMigrated) {
warnings.push({
slug: page.slug,
issue: `Internal link may be broken: ${link}`,
});
}
}
}
const contentPages = original.pages.filter(
p => p.type === 'page' || p.type === 'post'
);
return {
totalPages: contentPages.length,
successCount: migrated.length,
failedCount: contentPages.length - migrated.length,
warnings,
};
}Credit Cost Analysis
For a 500-page website migration:
| Operation | Tool | Credits | Quantity | Subtotal |
|---|---|---|---|---|
| Site inventory | map_site | 3 | 1 | 3 |
| Content extraction | extract_content | 2 | 500 pages | 1,000 |
| Metadata extraction | extract_metadata | 1 | 500 pages | 500 |
| Link extraction | extract_links | 1 | 500 pages | 500 |
| Total | 2,003 credits |
A complete 500-page migration costs about 2,000 credits. The Hobby plan ($19/month, 5,000 credits) handles this with room to spare. For larger sites (1,000+ pages), the Professional plan ($99/month, 50,000 credits) provides plenty of headroom.
Results and Benefits
Automated content migration delivers:
- Speed: Migrate 500 pages in 2-4 hours instead of 3-6 weeks
- Accuracy: No copy-paste errors, broken formatting, or missed pages
- Completeness: Every page, every meta tag, every internal link captured
- Cost savings: $5,000+ in manual labor replaced by $19-99 in tool credits
CrawlForge is best for content migrations where you need to preserve SEO equity -- meta tags, internal links, canonical URLs, and content structure all transfer cleanly.
Frequently Asked Questions
Can CrawlForge handle WordPress shortcodes?
CrawlForge's extract_content tool processes the rendered HTML, not raw WordPress source. Shortcodes are already expanded to their output HTML when CrawlForge extracts them. You get the rendered content, which is what you want for migration.
What about images and media files?
CrawlForge extracts image URLs from content. You will need a separate step to download and re-host images on your target platform. The fetch_url tool (1 credit) can download individual media files.
How do I handle redirects after migration?
The urlMap generated in Step 3 gives you a complete old-URL-to-new-slug mapping. Export this as a redirect map for your hosting platform (Vercel vercel.json, Netlify _redirects, or nginx config).
Migrate your site this weekend. Start free with 1,000 credits -- enough to migrate 250+ pages. No credit card required.
Related resources:
Try this yourself — no signup needed
Run any of CrawlForge's 27 scraping and extraction tools in the playground, then start free with 1,000 credits.
1,000 free credits • Refills monthly • No credit card required
Tags
About the Author
Stay updated with the latest insights
Get tutorials, product updates, and web scraping tips delivered to your inbox.
No spam. Unsubscribe anytime.