Migrating from One CMS to Another
CMS-to-CMS migration is primarily a data migration. Differences in schemas, fields, content types and media files require transformation, not simple copying.
Typical Migration Paths
| From | To | Complexity |
|---|---|---|
| WordPress → Strapi | Medium | Similar CPT concepts |
| WordPress → Contentful | High | Different data models |
| Drupal → Craft CMS | High | PHP↔PHP, but different abstractions |
| Joomla → WordPress | Medium | Official tools exist |
| Contentful → Sanity | Medium | Both headless, API migration |
| Ghost → WordPress | Low | Ghost has XML export |
Export Tools
WordPress — built-in XML Export or WP-CLI:
wp export --post_type=post,page,product --dir=/tmp/wp-export/
Drupal — Migrate API or CSV export:
drush migrate-import --all
Contentful — CMA SDK:
contentful space export --space-id $SPACE_ID --export-dir ./backup
Ghost — Ghost Admin → Settings → Export → Export your content (JSON).
Strapi — custom script via REST API.
Transformation Script
ETL pattern (Extract → Transform → Load):
// scripts/cms-migration.ts
interface MigrationConfig {
source: 'wordpress' | 'contentful' | 'ghost';
target: 'strapi' | 'contentful' | 'sanity';
contentTypes: ContentTypeMapping[];
}
async function migrate(config: MigrationConfig) {
const extractor = getExtractor(config.source);
const transformer = getTransformer(config.source, config.target);
const loader = getLoader(config.target);
for (const mapping of config.contentTypes) {
console.log(`Migrating: ${mapping.sourceName} → ${mapping.targetName}`);
// Extract
const items = await extractor.extract(mapping.sourceName);
// Transform
const transformed = items.map(item => transformer.transform(item, mapping));
// Load (with batching)
for (const batch of chunk(transformed, 50)) {
await loader.load(mapping.targetName, batch);
await delay(500);
}
}
}
Rich Text Migration
Most complex part — transforming Rich Text formats:
// WordPress HTML → Portable Text (Sanity)
import { htmlToPortableText } from '@portabletext/html';
function transformWpContent(html: string) {
return htmlToPortableText(html, {
rules: [
{
deserialize(el, next, block) {
if (el.tagName === 'IMG') {
return block({
_type: 'image',
_key: Math.random().toString(36).slice(2),
src: el.getAttribute('src'),
alt: el.getAttribute('alt'),
});
}
},
},
],
});
}
URL Redirect Mapping
After migration, definitely set up 301 redirects:
// Generate mapping of old → new URLs
const redirects = oldPosts.map(old => ({
source: old.url,
destination: newPosts.find(n => n.slug === old.slug)?.url ?? '/blog',
permanent: true,
}));
// next.config.ts
async redirects() {
return redirects;
},
Migration between two headless CMS (1000–5000 items) — 2–4 weeks.







