Headless CMS migration without breaking the content model

Contents
A headless CMS migration is not a content transfer, it's a schema translation between two incompatible data models, and treating it as a copy-paste job is why so many migrations stall mid-flight or get quietly rebuilt six months in. The failures aren't exotic: they're field-type mismatches, flattened relationships that need re-modelling as references, locale structures that don't map, and asset URLs baked into rich text.
This guide sequences the migration so each of those breaks gets caught in an audit, not in production. The same is true across other platform types, e-commerce platform migrations, for instance, hinge on similar sequencing decisions about what moves, what breaks, and when to cut over.
TL;DR: The four things that break in a headless CMS migration
Schema translation, not content transfer, is the operation that breaks a headless CMS migration. The failure surfaces first as field-type mismatch: a permissive WordPress rich-text field that accepts nested HTML and inline shortcodes has no direct equivalent in a strict Contentful, Strapi, or Storyblok field, and nothing flags this until validation rejects the import.
Our engineering team has audited legacy CMS exports for field-type mismatches, orphaned shortcodes, and reference-graph depth before every headless cutover we've run. The pattern repeats: content that looks clean in a CSV export fails the moment it hits a structured schema.
Before writing a migration script, export your source content model and diff every field type against the target platform's field library. Deeply nested reference chains are the ones that need remodeling, not just remapping.
This piece works through field-type mismatches, reference and relationship modelling, locale-per-entry versus locale-per-field, asset URL migration, and the editorial workflow gap, then covers sequencing: migrate one bounded content type first, run a parallel run across both systems, and keep the source CMS readable after it stops being writable.
Why a headless CMS migration is schema translation, not content transfer
Schema translation is the actual job. A content transfer assumes the shapes on both sides already match; a headless CMS migration almost never gets that assumption for free, because the source and target model content differently at the field level, not just the platform level.
A WordPress post is a blob: title, a permissive rich-text body, some meta fields, shortcodes and embedded blocks holding structure that only renders correctly inside WordPress's own templating. Contentful, Strapi, and Storyblok all expect structured content instead: typed fields, validated formats, and explicit reference fields linking one entry to another rather than an ID buried in an inline shortcode.
Moving from one to the other means re-expressing every content type as a stricter, more explicit schema, field by field. Contentful's own content model documentation is direct about this: a field's type and validation rules are fixed at the content-type level, and content that does not conform is rejected, not coerced.
The unit of that translation work is the bounded content type, not the whole site. A team scopes one content type, maps every one of its fields to a target schema, and treats mismatches, orphaned references, and locale conflicts as translation defects to resolve before the next type.
Content modelled loosely for a traditional CMS rarely survives that translation unchanged. Our content-modelling guide covers how to design a schema from scratch; this one covers what happens when an existing schema has to move.
Pre-migration audit checklist before committing to a target platform
A pre-migration audit is a structural inventory of what the source content model will not survive, not a content audit. Run it before you sign off on a target platform, because the checklist below determines scope, not just risk.
Audit each bounded content type separately and tally these against the target schema:
- Field-type mismatch count, for every field, check whether the target has a strict equivalent. A permissive rich text field that accepts nested HTML, inline scripts, or arbitrary tables will fail validation against Contentful's structured text field or Strapi's rich text field without a conversion pass.
- Orphaned shortcode count, grep body content for shortcode patterns. The WordPress REST API returns shortcodes as raw unrendered markup in content.rendered, so every shortcode becomes a manual mapping decision, not an automatic one.
- Reference-graph depth: measure how many levels deep internal links, embeds, and related-post modules run. Contentful's linked-entry documentation notes that the Content Delivery API resolves references up to 10 levels by default; deeper graphs need explicit include parameters or a flattening pass.
- Locale model check, confirm whether existing translations live as duplicate entries or field-level overrides. This decides locale-per-entry versus locale-per-field, and getting it wrong later means a rebuild.
- Asset URL inventory, count absolute media URLs already embedded in published content.
- Editorial workflow gaps, list what editors do daily (scheduling, bulk edits, preview links) that the new platform doesn't replicate out of the box.
What breaks when migrating to a headless CMS: Field-type mismatches
Field-type mismatches are usually the first thing a headless CMS migration breaks, because the source CMS accepted content the target CMS's field validation rejects outright. The mismatch does not show up in an export file. It shows up on the first save attempt, often as a 422 error or a schema validation exception naming the exact node type it refused.
The canonical case: a permissive WordPress rich text field that stores nested HTML, inline styles, and shortcodes as raw markup. Point that field at a Contentful rich text field and the write fails, because Contentful's rich text field stores content as a structured document validated against a fixed set of node types and marks, not arbitrary HTML.
Anything outside that schema gets dropped or blocks the save entirely. In practice that means a <span> with inline CSS silently loses its styling, a shortcode like [gallery ids="12,34"] gets stored as inert text nobody rendered, and a raw iframe embed throws "unrecognized node type" until someone strips or rebuilds it as an embedded entry.
Strapi's content-type builder has the same failure mode from the other direction. A field typed as short text in the source has no equivalent for content that is actually rich text, and the reverse is just as common with long-form WordPress custom fields that were never validated at all.
Remediation is rarely one conversion pass. It's three distinct fixes depending on what broke: HTML-to-structured-document conversion for rich text, a lookup table mapping legacy shortcodes to new component references, and manual review for anything that fails both.
Modern headless systems expect that setup discipline upfront, not patched in after launch.
During audit, count field-type mismatches per bounded content type, not per site: how many fields have a direct type match, how many need transformation, and how many have no target equivalent and require a new field or a data decision before migration starts.
That count, not the page count, is what sets the real scope. Getting it right also protects speed and security downstream: unresolved mismatches are exactly the digital debt that resurfaces as broken renders or unvalidated input once the new tools go live.
Reference and relationship modelling: Turning flat pages into structured references
Reference and relationship modelling is the step where a flat WordPress page becomes a graph of linked entries, and it is usually where the original scope estimate breaks.
A shortcode gallery or a related-posts block has no field equivalent in a headless content model, it's presentational glue, not data. Rebuilding it means deciding which target content type each shortcode maps to, then wiring reference fields between them.
Complexity compounds with reference-graph depth. An article that pulls in an author bio, three related posts, and a product card can end up four or five entries deep once every shortcode becomes a link.
According to Contentful's Content Delivery API documentation, the include parameter resolves linked references up to 10 levels deep on a single query, headroom that most healthy content models never need. Strapi's content-type builder forces the same relationships into explicit oneToOne, oneToMany, or manyToMany fields, a cardinality decision WordPress never asked the team to make.
Storyblok resolves nested references at read time through its relationship-resolving parameter rather than storing them denormalized, which changes how the frontend has to request data.
Run the reference-graph audit before locking the target platform. Count average depth per content type, and treat anything crossing three or four levels as a candidate to migrate as its own bounded content type, isolated from the rest of the graph until it's proven stable in the new CMS.
Localisation as a data-model decision: Locale-per-entry vs locale-per-field
Locale-per-entry and locale-per-field are two different content models, not two settings on the same one, and picking the wrong side for your existing content forces a structural rebuild rather than a configuration change.
Strapi's internationalization documentation describes locale-per-entry: each locale gets its own entry, linked back to a default locale through a localization relation. Reference fields, then, point at a specific locale's entry, not at an abstract, language-agnostic content item.
Contentful takes the opposite approach. Its content model and field documentation treats locale as a property of the field, not the entry: one entry holds a French value and an English value side by side, and a reference field points at the entry once, resolved per locale at delivery time.
The two models produce different reference graphs. If your source CMS stores translations as separate posts, connected only by a shared slug or a plugin table, that data maps cleanly onto locale-per-entry and awkwardly onto locale-per-field, where the migration script has to merge sibling posts into one entry before it can even attempt schema translation.
Guessing wrong here does not surface for months, until an editor tries to add a locale the model cannot represent and the entire bounded content type has to be rebuilt around the other approach. Decide this before migrating a single field, using the content-modelling fundamentals guide if the target model itself is still open.
Media and asset migration: Embedded URLs and the redirects they require
Asset URL migration is the step teams treat as a formality, and the one that breaks SEO first. Every image, PDF, and video URL already embedded in published content has to resolve on launch day, or the frontend renders broken media and search engines start dropping the page from the index.
WordPress bakes media as absolute URLs directly into post content, per WordPress's REST API media endpoint documentation: a straight export carries those old paths, not the new asset locations. Contentful, Strapi, and Storyblok each rehost assets under a new domain and asset ID (images.ctfassets.net, a self-hosted uploads path, or a.storyblok.com respectively), so every embedded URL from the old CMS is now pointing at nothing.
Two separate fixes are required, not one.
First, rewrite embedded URLs inside migrated rich-text or structured content against an old-to-new asset ID map generated during migration, a find-and-replace pass, not a manual edit. Second, put 301 redirects in place from every old asset and page path to its new equivalent.
A 301 redirect is the correct status code for a permanent URL change, per Google Search Central's redirect guidance, which tells crawlers and browsers the old address has been permanently replaced rather than temporarily moved (Google Search Central).
Skip the redirect map and the content survives the migration while the URLs search engines already ranked don't, that's the difference between a migration and a traffic loss.
Getting this sequencing right is part of a broader discipline of SEO-safe website migration, where preserving rankings depends as much on redirect mapping as on content accuracy.
The editorial workflow nobody scoped
The editorial workflow nobody scoped is the one editors ran daily in the old CMS and cannot run at all in the new one, and it usually surfaces in the first week after cutover, not during testing. Traditional CMS platforms bundle drafting, preview, and publish into one screen with no API in between.
A headless CMS separates them: content sits in a management API, a draft/publish workflow gates what the delivery API serves, and preview means calling an unpublished entry against a frontend that has to be built to render it.
Teams that migrate the content model and skip this ask why editors can no longer preview a page before it goes live, or why a scheduled publish that worked in WordPress has no equivalent.
Contentful, Strapi, and Storyblok each model draft/publish differently, entry-level publish states, custom workflow plugins, and datasource-driven staging respectively, so the editorial process has to be rebuilt against the target platform's model, not carried over.
Audit the editorial workflow before migration, not after: list every action an editor takes between draft and published, then confirm each one has a target-platform equivalent. Storyblok workflow: three stages (Drafting, Reviewing, Ready to Publish); Strapi separates Draft and Published states; Contentful supports simultaneous editing with rollbacks and comments.
Sequencing and cutover: One bounded content type, then a parallel run
Sequence a headless CMS migration by moving one bounded content type first, running it alongside the source CMS, and only cutting over once the new system has served production traffic without a rollback. Migrating everything at once is how a schema translation error becomes a full outage instead of a contained one.
Pick the bounded content type carefully. It should have few reference fields, no locale complexity, and low editorial traffic, a landing-page or FAQ type rather than the article model with embedded shortcodes and cross-references that expands scope earlier in the process.
Migrate it end to end: content, assets, and the editorial workflow the content team will actually use, then leave it live in the target CMS while everything else stays in the traditional system.
That's the parallel run. Both platforms serve content at the same time, on different routes or subdomains, for long enough to catch what testing missed: broken reference resolution, asset URLs that 404, locale fallback that doesn't behave the way the source CMS did.
A parallel run of two to four weeks per bounded content type is common, though the right length is set by how much editorial activity that content type sees, not a calendar date.
Cutover criteria should be explicit before the run starts: zero unresolved references, asset delivery matching production performance, and at least one full editorial cycle (draft, review, publish) completed by the content team in the new system without support intervention.
Keep the old CMS readable, not writable, well after the last content type cuts over. Editors will need it for reference during the next few migration batches, and rollback only works if the source of truth still resolves. For teams weighing whether headless is worth this sequencing overhead against a lift-and-shift replatform, our headless CMS development work covers that tradeoff directly.
For teams already standardized on Contentful, our Contentful development services detail how we handle these migration and modeling tradeoffs in practice.
Where migration mechanics differ by platform: Contentful, strapi, Storyblok
Contentful, Strapi, and Storyblok diverge on three mechanics: reference validation, locale storage, and how datasources or draft states travel through their respective APIs. None of this is a ranking question, it is where a migrated content model actually breaks.
Contentful validates reference fields against a fixed list of content types at the schema level, enforced through the management API when you create or update entries. A reference field authored against the wrong content type fails validation outright rather than degrading gracefully, so migration scripts need the target content model mapped before the import runs, not after.
The delivery API only ever returns published entries, unpublished references migrated as drafts will resolve to nothing until publish state catches up.
Strapi's i18n plugin treats each locale as its own entry, linked back to a default locale through a localizations relation, a locale-per-entry model, not locale-per-field. Content built in a source CMS that stores translations as sibling fields on one record needs restructuring into separate linked entries, which is exactly the kind of decision that is a rebuild if made after data has already loaded.
Storyblok stores reusable option lists as datasources, separate from story content, and migrating between spaces means moving stories and datasources as distinct steps through the management API, teams that migrate only stories lose every dropdown and select field that depended on a datasource. Space migration docs cover stories; datasources need their own pass.
Platform choice itself is covered separately, this is only the mechanics of moving into whichever one a team has already picked.
WordPress and drupal to headless: What carries over, what doesn't
WordPress and Drupal content survives a headless CMS migration as raw data, not as the markup that renders it today. What breaks first is anything that depends on a rendering engine to make sense: WordPress shortcodes and Drupal's inline HTML blocks.
A WordPress shortcode like [gallery ids="12,45"] is a function call the classic theme resolves at render time. A headless target has no theme to resolve it, so the shortcode ships as literal text unless something rewrites it to a structured reference field before import.
According to the WordPress REST API, post content returns as rendered HTML alongside a raw field, but neither carries the shortcode's original arguments as usable data.
The mapping has to happen upstream, not during export.
Drupal fares better structurally: paragraphs and entity reference fields already model relationships closer to how Contentful, Strapi, and Storyblok expect content. Drupal's inline image and view-embed tokens fail the same way shortcodes do, though. Both source platforms need a pre-migration audit pass to catch every instance before cutover, not a plugin that promises to handle it automatically.
FAQ: Headless CMS migration
How do you migrate to a headless CMS?
How long does a headless CMS migration take?
What breaks when migrating to a headless CMS?
Can you migrate content automatically?
Before you write the migration script
The expensive part of a headless CMS migration is decided before any content moves: which field types have no equivalent, how references flatten, and whether locale-per-entry or locale-per-field matches how the content is actually authored. Getting those three wrong is a rebuild, not a fix.
If the target platform is chosen but the content model isn't mapped yet, that mapping is the work worth doing first. It is also what tells you whether this is a two-week job or a two-quarter one. Talk to us about your content model before the export runs.
If you are still deciding whether the move is worth making at all, the risks of staying on a legacy CMS is the better starting point.
