Type something to search...
Migrating From Contentful to Sanity

Migrating From Contentful to Sanity

To migrate from Contentful to Sanity, run Sanity's open-source contentful-to-sanity CLI with your Contentful space ID, a Content Management API token and a Content Delivery API token. It exports the space, generates Sanity schema definitions from your content types, converts entries to Sanity documents (Rich Text becomes Portable Text, links become references, assets are queued for upload) and writes a dataset.ndjson file. You then import that file with npx sanity datasets import, add the generated schema to a Studio, and refine the model and front-end queries before switching over.

Contentful and Sanity are close enough in concept that a migration is very achievable: both store structured entries, both have assets, both link content together. The differences are in how the model is defined and how rich text is stored. This guide covers how the two map onto each other, a complete run of the migration tool, what to check and fix afterwards, locales, and when to write your own script instead.

How Contentful Concepts Map to Sanity

Before running anything, it helps to know where each Contentful concept ends up:

ContentfulSanity
SpaceProject
Environment (master, staging)Dataset (production, staging)
Content typeDocument type defined in code
EntryDocument
Entry IDDocument _id (kept as is)
Draft or changed entryDraft document with a drafts. prefix
AssetImage or file asset with a reference to it
Reference field (Link)reference field
Rich TextPortable Text array
Short text (Symbol)string, or slug if it used the slug editor
Long text (Text)text, or Portable Text if it used the Markdown editor
Locationgeopoint
JSON objectobject field
LocalesField-level or document-level internationalisation

The biggest conceptual change is that your content model moves from a web app into code. In Sanity, schemas live in your Studio repository and are versioned with Git. Sanity vs Contentful compares the two approaches in more detail, and how Sanity's Content Lake works explains where the documents end up.

What You Need

Gather these before you start:

  • Contentful space ID: under Settings, General settings.
  • Content Delivery API access token: under Settings, API keys. Create a new key if you don't have one.
  • Content Management API token: under Settings, CMA tokens. A personal access token works.
  • Node.js and npm: a current LTS release. Sanity's own tooling requires Node.js 22.12 or later.
  • A Sanity account: the CLI will create a project for you if needed.

Treat the management token like a password. It can modify your Contentful space, so revoke it once the migration is complete.

Step 1: Create an Empty Studio

Create a clean Studio that will hold the generated schema. This also creates the Sanity project and dataset:

npm create sanity@latest -- --template clean --create-project "Contentful migration" --dataset production --output-path ./migrate

Starting from the clean template keeps the generated schema separate from any demo types. If you already have a Sanity project, run npm create sanity@latest without --create-project and choose the existing project, ideally with a new dataset for the import. How to set up your first Sanity project explains the options.

Step 2: Run the Migration Tool

The tool's default batch command does everything in one go: export, schema generation and dataset conversion. Run it with the folder you just created as the output directory:

npx contentful-to-sanity@latest \
  -s <space-id> \
  -t <management-token> \
  -a <access-token> \
  ./migrate

When it finishes, ./migrate contains three new files:

  • contentful.json: the raw Contentful export, useful for debugging and for writing your own transforms.
  • schema.ts: Sanity schema definitions generated from your content types.
  • dataset.ndjson: your entries as Sanity documents, one per line, ready to import.

Useful options for the batch command include:

OptionWhat it does
-e, --environment-idExport a Contentful environment other than master
--keep-markdownKeep Markdown fields as plain text instead of converting them to Portable Text
--weak-refsCreate weak references, so imports don't fail on links to missing entries
--intl single or --intl multipleImport one locale (the default) or all locales
--localeChoose which locale to import in single mode
--optimize-svgsOptimise SVG assets before import
--convert-imagesConvert image formats Sanity doesn't support before import

Run npx contentful-to-sanity@latest --help for the full list. You can also run the export, schema and dataset steps separately, which is handy when you want to export once and iterate on the conversion offline.

Step 3: Import the Dataset

Import the generated file from inside the Studio folder:

cd migrate
npx sanity datasets import dataset.ndjson production

The importer creates every document and downloads each referenced Contentful asset from its CDN URL, uploading it to Sanity's asset pipeline. Large spaces can take a while, mostly because of the assets. If a few assets have gone missing in Contentful, add --allow-failing-assets to finish the import without them. If you need to re-run the import after fixing something, add --replace so documents with the same _id are overwritten rather than causing errors.

The import also adds a small contentful.migration document recording when the migration ran. You can delete it once you're done.

Step 4: Wire Up the Generated Schema

The generated schema.ts exports a types array of defineType() definitions. Use it in sanity.config.ts:

// migrate/sanity.config.ts
import { defineConfig } from 'sanity';
import { structureTool } from 'sanity/structure';
import { visionTool } from '@sanity/vision';
import { types } from './schema';

export default defineConfig({
  name: 'default',
  title: 'Contentful migration',
  projectId: '<project-id>',
  dataset: 'production',
  plugins: [structureTool(), visionTool()],
  schema: {
    types,
  },
});

Some older instructions for the tool show deskTool from sanity/desk. That plugin has been replaced by structureTool from sanity/structure, so use the version above. Start the Studio with npm run dev and open a few documents. You'll see your Contentful content types as document types, with field titles, help text and many validations carried over.

Step 5: Review and Refine the Model

The generated schema is a faithful translation of your Contentful model, not necessarily a good Sanity model. Now is the best time to improve it, before front-end code depends on it. Things worth reviewing:

  • Field names: Contentful field IDs are kept, which is usually fine. Rename anything awkward now, while you can still re-import from scratch.
  • Wrapper content types: Contentful projects often use small content types, like "CTA" or "Feature item", purely to compose pages. In Sanity these are usually better as reusable object types embedded in the document, rather than separate documents with references.
  • Rich Text embeds: embedded entries become references inside Portable Text. Decide which should stay references and which should become inline custom blocks.
  • Validation: unique constraints and some custom validations don't translate directly. Add them back with validation rules.
  • Studio structure: group and order document types with the Structure tool instead of the default flat list.

Split the generated file into one file per type as you work through it. It makes the schema far easier to maintain.

If you restructure fields, you have two options. While you're still testing, change the conversion and re-import into a fresh dataset. Once editors are working in Sanity, use a content migration script instead, which writing content migration scripts in Sanity covers.

How Rich Text Is Converted

Contentful's Rich Text is a JSON tree of nodes. The tool converts it with @portabletext/contentful-rich-text-to-portable-text, which maps:

  • Paragraphs and headings to blocks with normal and h1 to h6 styles.
  • Bold, italic, underline and code to decorators.
  • Hyperlinks to link annotations.
  • Ordered and unordered lists to list blocks with number and bullet.
  • Embedded entries and assets to references and image or file objects.
  • Horizontal rules to a break block.

Tables are skipped, with a warning printed for each entry that contains one, because standard Portable Text has no table type. If you use tables, add a table block, for example from a table plugin, and write a small post-import script that rebuilds them from contentful.json.

Your front end will need a new renderer as well. Instead of @contentful/rich-text-react-renderer, you'll use @portabletext/react. How to render Portable Text in React shows how to handle the default blocks and your custom ones.

Handling Locales

By default the tool imports a single locale, your space's default. That's the right choice for single-language sites, and you can pick another locale with --locale.

For multilingual spaces, --intl multiple imports every locale as separate documents, one per language, linked using the conventions of the @sanity/document-internationalization plugin. The --intl-id-structure option controls whether translated document IDs use a delimiter (the default) or a subpath. Install and configure that plugin in your Studio to get language switching and translation links.

Document-level translation suits content where each language differs, such as articles. If your Contentful space used field-level localisation for things like product names, you may prefer field-level translations in Sanity. Modelling multilingual content in Sanity compares the two approaches.

Writing Your Own Migration Instead

The tool covers most spaces, but you might want your own script when you're making large model changes during the move, merging several content types, or migrating only part of a space. In that case:

  1. Export the space with the Contentful CLI (contentful space export) or keep using the contentful.json produced by contentful-to-sanity export.
  2. Transform each entry into a Sanity document, keeping the Contentful entry ID as the _id so references can be resolved without a lookup table.
  3. Convert Rich Text fields with toPortableText() from @portabletext/contentful-rich-text-to-portable-text.
  4. Reference assets with _sanityAsset, so the CLI importer uploads them for you.
  5. Import the NDJSON with npx sanity datasets import.

A transform for a simple blog post entry looks like this:

// scripts/transform.ts
import { readFileSync, writeFileSync } from 'node:fs';
import { toPortableText } from '@portabletext/contentful-rich-text-to-portable-text';

const data = JSON.parse(readFileSync('contentful.json', 'utf8'));
const locale = 'en-US';

const assetUrl = (id: string) => {
  const asset = data.assets.find((a: any) => a.sys.id === id);
  const url = asset?.fields.file?.[locale]?.url;
  return url ? `https:${url}` : undefined;
};

const lines = data.entries
  .filter((entry: any) => entry.sys.contentType.sys.id === 'blogPost')
  .map((entry: any) => {
    const f = entry.fields;
    const imageUrl = f.heroImage && assetUrl(f.heroImage[locale].sys.id);

    return JSON.stringify({
      _id: entry.sys.id,
      _type: 'post',
      title: f.title?.[locale],
      slug: { _type: 'slug', current: f.slug?.[locale] },
      publishedAt: f.publishDate?.[locale],
      author: f.author && { _type: 'reference', _ref: f.author[locale].sys.id },
      mainImage: imageUrl && { _type: 'image', _sanityAsset: `image@${imageUrl}` },
      body: f.body ? toPortableText(f.body[locale]) : [],
    });
  });

writeFileSync('posts.ndjson', lines.join('\n'));

Every value in a Contentful export is keyed by locale, which is why each field is read with [locale]. If your Rich Text contains embedded entries, pass a referenceResolver option to toPortableText() that returns a Sanity reference for each linked entry, as the official tool does.

Moving the Front End

The import is only half the job. Your front end needs to switch from Contentful's SDK or GraphQL API to Sanity:

  • Queries: replace getEntries() calls or GraphQL queries with GROQ. A query like *[_type == "post"] | order(publishedAt desc){ title, "slug": slug.current, author->{ name } } replaces both the request and the include depth setting. Start with what is GROQ.
  • Images: replace Contentful image URLs and their query parameters with Sanity's image URL builder.
  • Previews: replace Contentful's preview API with drafts and Sanity's Presentation tool for visual editing.
  • Webhooks: recreate build or revalidation webhooks in Sanity's project settings.

Plan a short content freeze for the cut-over. Run the migration one final time just before launch so nothing written in Contentful in the meantime is lost.


FAQ: Migrating From Contentful to Sanity

Yes. It's an open-source CLI published by Sanity on npm and GitHub. It exports a Contentful space, generates Sanity schema definitions and produces an NDJSON dataset you import with the Sanity CLI.

Yes. Each entry's Contentful ID becomes the Sanity document _id, so references between entries resolve directly. Entries that are drafts or have unpublished changes are imported as drafts with a drafts. prefix.

Yes. Assets are written into the dataset file with their Contentful CDN URLs, and the Sanity CLI downloads and uploads each one during import. Use --allow-failing-assets if some assets no longer exist.

Rich Text is converted to Portable Text. Paragraphs, headings, lists, marks, links and embedded entries are supported. Tables are skipped with a warning, so you'll need a table block and a custom step if you use them.

Yes. Pass --intl multiple to import every locale as separate translated documents that follow the conventions of the @sanity/document-internationalization plugin. Without it, only the default locale, or the one you choose with --locale, is imported.

No. Treat it as a starting point. Rename fields, turn small content types into object types and add validation while you can still re-import from scratch, before editors start working in Sanity.


Conclusion

Migrating from Contentful to Sanity is mostly automated. The contentful-to-sanity CLI exports your space, generates schemas and converts entries, Rich Text and assets into a dataset file you import with one command. Entry IDs are preserved, so references keep working, and drafts come across as drafts.

The real work is what comes after: shaping the generated schema into a model that suits Sanity, rebuilding tables or other unsupported content, and moving your front end to GROQ, Portable Text and Sanity's image pipeline. Import into a test dataset, iterate until the model feels right, then run the migration one last time and switch over.

Tags :
Share :

Related Posts

Building a Static Site With Astro and Sanity

Building a Static Site With Astro and Sanity

To build a static site with Astro and Sanity, keep Astro's default output: 'static', configure @sanity/astro with useCdn: false, and fetch

Dive Deeper
Common Sanity Mistakes and How to Fix Them

Common Sanity Mistakes and How to Fix Them

The most common Sanity mistakes are using the CDN where you need fresh data, fetching whole documents with ..., leaving slugs unvalidated, ship

Dive Deeper
How to Create a Custom Sanity Plugin

How to Create a Custom Sanity Plugin

To create a custom Sanity plugin, wrap a piece of Studio configuration in definePlugin from the sanity package and add it to the plugins ar

Dive Deeper