DEV Community

Cover image for Contentful to Sanity Migration: How I Move Content, Assets and Locales
Nayan Kyada
Nayan Kyada

Posted on Originally published at nayankyada.com

Contentful to Sanity Migration: How I Move Content, Assets and Locales

A contentful to sanity migration is mostly a data-mapping exercise with a few sharp edges: rich text, assets, and locales. Done as a script you can re-run, it is low drama. Done by hand in a CMS UI, it becomes a month of copy-pasting. This is the order I work in.

When the Contentful bill is the trigger

Most migrations I see start with an invoice, not a technical complaint. A site outgrows the free tier, hits the jump to a paid plan, or needs a second environment or more locales, and the number stops making sense. If that is your situation, read Contentful pricing in 2026 first so you know exactly which limit you hit, and then Sanity vs Contentful for Next.js to check cost is not the only reason you are moving. Cost alone is a fine reason, but it should not be the only input into how you remodel content.

Step 1: Export and audit what you have

Use the Contentful CLI to export a space to JSON. That file contains content types, entries, assets and locales. Before writing any code, list every content type and mark each one: keep, merge, or drop. Migrations are the one moment you can delete the dead types nobody has touched in two years.

I produce a simple spreadsheet: content type, entry count, fields, which pages use it. Anything with zero entries or no front-end consumer gets dropped.

Step 2: Map the content model

Contentful models map onto Sanity schemas fairly directly, but a few concepts change shape.

Contentful Sanity
Content type Document type or object type
Short text / long text string / text
Rich text array of block (Portable Text)
Media (asset) image or file field
Reference (link to entry) reference
Many references array of references
Locale per field Field-level or document-level i18n
Entry ID Document _id you control

The main decision: which Contentful types become standalone documents and which become inline objects. A Contentful "Hero" entry referenced by one page is almost always better as an object inside the page. Reusable things like authors and categories stay as documents. Structure this with query cost in mind; avoiding query waterfalls covers the reasoning.

Step 3: Use deterministic IDs

The single most useful trick is generating Sanity document IDs from Contentful IDs. That makes the import idempotent, so you can re-run it as often as you like, and references can be written before the target document exists.

// scripts/migrate/ids.ts
export const docId = (type: string, contentfulId: string) =>
  `${type}-${contentfulId}`

export const refTo = (type: string, contentfulId: string) => ({
  _type: 'reference',
  _ref: docId(type, contentfulId),
  _weak: true,
})
Enter fullscreen mode Exit fullscreen mode

I mark references weak during import so ordering does not matter, then strengthen them once everything has landed.

Step 4: Convert rich text to Portable Text

Contentful rich text is a tree: a document node with block children (paragraph, headings, lists, quotes, embedded entries), and text nodes carrying marks like bold or italic. Portable Text is a flat array of blocks with spans. The conversion is a recursive walk.

// scripts/migrate/richText.ts
import { randomUUID } from 'node:crypto'

type CfNode = {
  nodeType: string
  value?: string
  marks?: { type: string }[]
  data?: any
  content?: CfNode[]
}

const markMap: Record<string, string> = {
  bold: 'strong',
  italic: 'em',
  underline: 'underline',
  code: 'code',
}

const styleMap: Record<string, string> = {
  paragraph: 'normal',
  'heading-2': 'h2',
  'heading-3': 'h3',
  'heading-4': 'h4',
  blockquote: 'blockquote',
}

function spans(nodes: CfNode[] = [], markDefs: any[]) {
  return nodes.flatMap((n) => {
    if (n.nodeType === 'text') {
      return [{
        _type: 'span',
        _key: randomUUID().slice(0, 8),
        text: n.value ?? '',
        marks: (n.marks ?? []).map((m) => markMap[m.type]).filter(Boolean),
      }]
    }
    if (n.nodeType === 'hyperlink') {
      const key = randomUUID().slice(0, 8)
      markDefs.push({ _type: 'link', _key: key, href: n.data.uri })
      return spans(n.content, markDefs).map((s: any) => ({
        ...s,
        marks: [...s.marks, key],
      }))
    }
    return spans(n.content, markDefs)
  })
}

export function toPortableText(doc: CfNode) {
  return (doc.content ?? []).flatMap((node) => {
    const style = styleMap[node.nodeType]
    if (!style) return []
    const markDefs: any[] = []
    return [{
      _type: 'block',
      _key: randomUUID().slice(0, 8),
      style,
      markDefs,
      children: spans(node.content, markDefs),
    }]
  })
}
Enter fullscreen mode Exit fullscreen mode

This handles paragraphs, headings, quotes, bold, italic and links. Lists, tables, embedded entries and embedded assets need their own branches: lists become blocks with listItem and level, embedded entries become custom block objects. I deliberately skip unknown node types in this sketch, but in a real run you should log them, because silent drops are how content goes missing. Rendering the result is covered in Portable Text custom components.

Step 5: Move assets

Contentful assets have a URL per locale. Download each file once, upload it to Sanity with the client, and keep a map from Contentful asset ID to Sanity asset ID. Then, when converting entries, replace each asset link with an image field pointing at that asset ID.

// scripts/migrate/assets.ts
import { createClient } from '@sanity/client'

const client = createClient({
  projectId: process.env.SANITY_PROJECT_ID!,
  dataset: 'production',
  token: process.env.SANITY_WRITE_TOKEN!,
  apiVersion: '2026-01-01',
  useCdn: false,
})

export async function uploadAsset(url: string, filename: string) {
  const res = await fetch(url.startsWith('//') ? `https:${url}` : url)
  const buffer = Buffer.from(await res.arrayBuffer())
  const asset = await client.assets.upload('image', buffer, { filename })
  return asset._id
}
Enter fullscreen mode Exit fullscreen mode

Throttle uploads and cache the ID map to disk so a failed run resumes instead of starting over. Add alt text to the image fields in the new schema; Contentful asset descriptions are a good source for it.

Step 6: Handle locales

Contentful stores every locale inside each field. Sanity gives you two sensible options: field-level translations for small sites, or one document per language using the document internationalization plugin for larger ones. For editorial sites with divergent content per market I prefer one document per language. Write a script that splits each Contentful entry into one document per locale, with a shared language field and deterministic IDs like page-abc123-en. The front-end side is covered in multilingual Sanity content with next-intl.

Step 7: Run the import as a transaction batch

Build an array of documents, then write them in batches using createOrReplace so reruns overwrite instead of duplicating. Import into a separate dataset first, such as staging, and let editors review there. Only copy to production when the checks pass.

Step 8: Build the redirect map

Export every live URL from your sitemap and analytics, then map each to its new path. If you keep slugs identical, most of the map is empty, which is the ideal outcome. Where paths change, add permanent redirects in the Next.js config or in middleware. Crawl the old URL list against the new site after launch and confirm every one returns either a 200 or a single 301 hop.

Step 9: Plan the cutover

My cutover order is: freeze edits in Contentful, run the final import, run the redirect and link crawl, switch DNS or the production deployment, then keep Contentful on its plan for a couple of weeks as a fallback before cancelling. Configure webhook revalidation before launch so editors see changes immediately.

If you want this handled end to end, my fixed-price migration to Sanity starts from $3,000, with details at services and packages.

Top comments (0)