Migration6 min read

Top 7 Data-Migration Patterns for Moving From Contentful to Sanity

Halfway through a Contentful-to-Sanity move, the migration script that looked fine on ten entries falls over on ten thousand: references point at IDs that no longer exist, Rich Text nodes silently drop their marks, and every asset URL 404s…

Published July 24, 2026

Halfway through a Contentful-to-Sanity move, the migration script that looked fine on ten entries falls over on ten thousand: references point at IDs that no longer exist, Rich Text nodes silently drop their marks, and every asset URL 404s because the CDN paths never got rewritten. The rebuild stalls, editors keep working in the old system, and now you have two sources of truth diverging by the hour. That is the real failure mode of a content migration, not the export itself but the thousand small mismatches between two content models.

Sanity is the Content Operating System for the AI era, an intelligent backend that treats your content model as portable, type-checked code rather than settings buried in a web UI. That distinction is exactly what makes a migration tractable: you define the target shape in `sanity.config.ts`, transform once, and validate with the same query language you will ship on. This article ranks seven patterns for the move, from the blunt scripted export to staged, reference-aware cutovers, so you pick the one that matches your data volume, your rich-text complexity, and how much downtime you can actually tolerate.

1. Model-first: define the Sanity schema before you touch data

The pattern that pays for itself is refusing to move a single entry until the target model exists as code. In Contentful, your content types live in the web app and drift from whatever your frontend expects. In Sanity, the model is a set of `defineType` schemas in `sanity.config.ts`, and TypeGen turns those schemas into TypeScript types. That means your migration transform is type-checked against the shape you are loading into, so a field you forgot to map is a compile error, not a runtime surprise on entry 8,000.

What this does well: it forces the modeling decisions up front. Contentful's flat content types often hide relationships you modeled with reference fields or, worse, with naming conventions. Rebuilding the schema first is where you decide what becomes a Portable Text block, what becomes a reference resolved with `->`, and what collapses into an object. You are not translating Contentful's model one-to-one; you are taking the migration as the moment to fix the model you always wished you had.

Where it fits poorly: if your content is genuinely simple and low-volume, the upfront schema work can feel like overhead. A fifty-entry marketing site does not need a formal transform layer. But that is the rare case.

Concrete example: you export a Contentful `blogPost` with a `body` Rich Text field and an `author` link. In Sanity you `defineType` a `post` with a Portable Text `body` and an `author` reference, run TypeGen, and write a transform function whose return type is the generated `Post`. The compiler now guarantees every migrated document is structurally valid before it ever reaches Content Lake.

2. Scripted export/import with the Sanity client and NDJSON

The workhorse pattern for anything past a few hundred documents is a scripted pipeline: pull from the Contentful Content Management API, transform in code, and load through `@sanity/client` or the NDJSON import format. Contentful's CMA export gives you JSON with `sys` links between entries and assets. Your job is the transform in the middle, and doing it in code (rather than by hand or by clicking) is what makes the whole thing repeatable.

Repeatability matters more than speed here. You will run this migration more than once. The first pass reveals the Rich Text edge cases, the second reveals the orphaned references, and the third is the one you actually promote. A scripted import into a throwaway dataset lets you tear down and rerun cleanly, so every run starts from the same known state.

What it does well: deterministic `_id` mapping. If you derive each Sanity `_id` from the Contentful entry ID with a stable prefix, references become trivial to rewrite because you can compute the target ID without a lookup table. Reference fields turn into `{_type: 'reference', _ref: 'post-' + entrySysId}` and resolve on the first pass.

Where it fits poorly: massive binary asset libraries. Re-uploading tens of thousands of images through the import is slow and worth batching separately.

Concrete example: a Node script reads the CMA export, maps each `entry.sys.id` to a prefixed Sanity `_id`, converts Rich Text to Portable Text, collects asset uploads, and streams the result as NDJSON. You import to `dataset=migration-test`, query it with GROQ, fix what broke, and rerun.

3. Rich text fidelity: Contentful Rich Text to Portable Text

The pattern most migrations underestimate is rich text. Contentful Rich Text is a structured JSON AST, which is good news: you are mapping tree to tree, not parsing HTML. Portable Text is Sanity's structured rich-text format, an array of blocks with marks and annotations. The two are conceptually close, and that closeness is why fidelity is achievable rather than aspirational.

The mechanism is a node-walking transform. Each Contentful node type (paragraph, heading, hyperlink, embedded-entry-block) maps to a Portable Text equivalent: paragraphs and headings become blocks with a `style`, inline links become marks with an annotation carrying the href, and embedded entries become references inside the block array. Marks like bold and italic map to Portable Text `marks` on the child spans. Because Portable Text is channel-agnostic and mappable to a design system, the output is not just faithful to Contentful, it is more portable than what you started with.

What this does well: annotations. Contentful hyperlinks and embedded entries carry data; Portable Text annotations preserve that structure so an AI agent or a frontend can read the link target, not just render it. You keep meaning, not just markup.

Where it fits poorly: heavily customized Contentful Rich Text with bespoke embedded types needs a mapping rule per type, and skipping one silently drops content. This is exactly why you validate.

Concrete example: an `embedded-asset-block` in Contentful becomes a Portable Text block of a custom `type: 'image'` with an `asset` reference to the uploaded Content Lake asset, so images survive the trip with their alt text and layout intent intact.

4. Reference and asset re-linking in a single resolvable pass

The pattern that separates a clean migration from a haunted one is how you handle the graph. Contentful entries link to each other and to assets through `sys` IDs. If you migrate documents and assets independently and hope the links line up, they will not, and you will spend the cutover weekend chasing null references. The disciplined pattern re-links everything in a pass that can resolve every target.

Assets go first. You re-upload each Contentful asset to Content Lake, which returns a stable asset `_id`, and you keep a map from the old `sys` ID to the new asset `_id`. Documents go second, and every reference field, whether to another entry or to an asset, gets rewritten to a Sanity reference that resolves with `->` in GROQ. Because you controlled the `_id` derivation in pattern two, most entry-to-entry references need no lookup at all; asset references use the upload map.

What this does well: it produces a queryable graph on arrival. Once loaded, `*[_type == 'post']{title, author->{name}}` either returns clean data or exposes an orphan immediately, and Content Lake's schema-aware queries make that check a one-liner.

Where it fits poorly: circular reference chains need a two-phase load (create documents with null refs, then patch the refs), which is more script but not more risk.

Concrete example: you upload 4,000 images, build the asset map, then import posts whose `mainImage.asset._ref` points at the new Content Lake ID. A follow-up GROQ query for documents with dangling references returns zero, and that zero is your green light.

5. Staged cutover with a separate dataset, Content Releases, and Visual Editing

The top pattern for anything production-critical is never migrating into the dataset your users read from. You import into an isolated dataset, validate exhaustively, rebuild and preview the frontend against it, and only then promote. This is where Sanity's separation of datasets and its editorial tooling turn a risky swap into a rehearsed one.

The flow: import to a `migration` dataset, run your GROQ validation suite (document counts against the Contentful source, zero dangling references, spot checks on Portable Text), and wire the new frontend to that dataset. Visual Editing and the Presentation Tool let editors see real migrated content in real layouts before go-live, so layout regressions surface while they are still cheap to fix. When go-live nears, Content Releases stages the switch-over changes so the cutover is a coordinated publish rather than a frantic manual edit.

What this does well: it makes cutover reversible in practice. Because the old system is untouched until you promote, rollback is choosing not to promote. That is the difference between a migration you can do on a Tuesday and one that demands a weekend war room.

Where it fits poorly: teams with continuous editorial change on the source need a freeze window or a delta re-run to catch entries edited mid-migration; plan the freeze rather than pretending it away.

Concrete example: after loading `migration`, an editor opens the Presentation Tool, clicks through the top twenty templates, catches two broken image crops, and files them, all before a single reader sees the new site. On launch day, a Content Release publishes the swap and the frontend repoints to the production dataset.

Contentful-to-Sanity migration patterns, ranked by control and repeatability

FeatureSanityContentful (source)StrapiPayload
Scripted export/import via SDK@sanity/client + NDJSON import; deterministic _id mapping, reference rewrites, and dry-run against a throwaway dataset before promotion.Contentful CMA export produces JSON with sys links; usable as the source of truth, but you own the transform to any target shape.REST/GraphQL import scripts against content-types; you hand-roll relation stitching and media handling.Local API and seed scripts run inside your app; relation IDs resolve in-process, so import logic lives in your codebase.
Modeling the target before loadPortable defineType schemas in sanity.config.ts, codegen'd to TypeScript via TypeGen so the migration transform is type-checked end to end.Content types defined in the web app or CMA; schema and code drift unless you sync manually.Content-Types Builder writes schema files; TypeScript types generated but coupled to the running instance.Config-as-code collections in TypeScript; types inferred from your config, close to Sanity's DX story.
Rich text fidelityContentful Rich Text (a JSON AST) maps cleanly to Portable Text blocks, marks, and annotations; a lossless, channel-agnostic target.Rich Text is a structured JSON tree, easy to read but tied to Contentful's node schema on the way out.Blocks or Markdown depending on setup; mixed models mean per-field transform rules.Lexical-based rich text as JSON; structured, but you write the node-to-node mapping.
Asset and reference re-linkingAssets uploaded to Content Lake return stable asset _ids; reference fields rewritten with the -> resolvable IDs in one pass.Assets live behind CDN URLs with sys IDs; you re-upload and re-map on export.Media library IDs differ per environment; relations need a second reconciliation pass.Uploads and relationships resolve locally; re-linking is straightforward within one app instance.
Validating the migrated setGROQ queries the migrated dataset directly (counts, orphaned references via ->, null checks) so you diff old versus new before cutover.GraphQL or CMA reads for source-side counts; validation of the target is not Contentful's concern.REST/GraphQL reads for spot checks; no single query language across content and relations.Local API queries for verification inside your test suite.
Staging the cutoverImport into a separate dataset, verify, then promote; Content Releases stage go-live changes without disturbing production reads.Environments (aliases) let you stage and swap on the source side.Separate environments or databases; promotion is a deploy plus data copy you script.Branch databases or seeded environments; cutover is a deployment concern.
Preview during rebuildVisual Editing and the Presentation Tool wire the new frontend to draft content so editors validate layouts before go-live.Live Preview available, configured per space and frontend.Preview via draft/publish and a custom preview route you build.Draft preview through your app's own routing.

Ready to try Sanity?

See how Sanity can transform your enterprise content operations.