Is Contentful Migration the Right Choice for You?
While it has long been a go-to option for teams adopting headless CMS architectures, many organizations have now decided to migrate Contentful to Sanity or other setups.
Companies find Contentful's structure and pricing more and more restrictive as their projects scale.
Limits on content types, rigid field definitions, and the absence of a real pay-as-you-go model often make it difficult to stay flexible without moving into costly enterprise plans.
For teams focused on speed, content variety, and developer experience, these constraints are a major bottleneck. This is why they choose a shift toward more customizable and scalable alternatives like Sanity.
This guide covers two ways of migrating a setup from Contentful to Sanity — the same SEO-safe migration techniques Pagepro uses in its own projects.
Why Teams Migrate from Contentful to Sanity
Contentful Limitations
Contentful's Starter and Lite plans are enough for most small-scale projects. However, once content structures start to grow, so do the constraints. The 25-content-type limit can be a serious obstacle for mid-sized or content-heavy applications. There's no pay-as-you-go option, so scaling up means jumping to a much higher plan instead of paying for incremental usage. Even then, the 50-content-type limit might not be enough in some cases.
On the development side, content models are bound by predefined field types, fixed validations, and a uniform UI that can't be customized for specific workflows. Rich text editing is also rigid. You can remove formatting options from the toolbar, but not extend or tailor the editor to support things like custom blocks, lists, or reference elements.
Why Sanity Is a Better Fit
Sanity addresses these pain points with a developer-first approach. Its schema system offers full control over content types, relationships, and validation rules. PortableText replaces Contentful's static rich text editor with a flexible structure that supports custom components and dynamic content.
Real-time collaboration, customizable editing interfaces, and an API built around GROQ queries make it easier to build, query, and scale complex data models. Combined with a more accommodating pricing structure and strong integration with frameworks like Next.js, Sanity offers the agility that growing teams need without forcing trade-offs.
We recommend Sanity because it gives both developers and content teams real freedom. You can shape the CMS around your product, not the other way around.
— Rafał Dąbrowski, Developer at Pagepro
For a deeper comparison, see Headless CMS Guide: Sanity vs Contentful.
Pricing Comparison
Pricing as of October 2025.
Sanity stands out for its gradual, transparent pricing. It includes a forever-free tier and paid plans starting at around $15 per user/month for the "Growth" tier.
Contentful does offer a free plan as well, but as soon as you require any meaningful team collaboration or commercial usage, the first paid tier jumps to around $300/month (or higher) for the next level up. Additionally, if you cross 25 content types or have more than 10k of data, you might have to buy the Lite Space, which costs $850.
That sharp leap between the free plan and the next level means the decision to "go live" with Contentful comes with a significant budget step that doesn't scale gently. Sanity allows a smoother transition from free to paid, whereas with Contentful, you may face a big budget move early on.
Planning a Safe Migration
Migrating from Contentful to Sanity is a structural change that can impact how your content, SEO, and integrations behave. Before running any scripts, decide which migration strategy makes the most sense for your project and learn about its risks.
Understand the Risks
Feature parity. Sanity may not have direct replacements for every Contentful plugin, so some marketplace extensions might need to be rebuilt or replaced with custom solutions. In many cases, this becomes an opportunity to clean up unused or outdated features. If a plugin supports a critical part of your workflow, plan for a rebuild.
SEO and content integrity risks. Losing URL structures, internal links, or metadata during transfer can cause ranking drops and broken pages. Redirects, sitemaps, and structured data should be verified early in the process.
Data corruption or loss. Complex content models, broken relationships, and rich text formatting can all suffer during conversion, especially with localized data or large asset libraries.
Migration testing should happen at multiple stages: first with automated checks (to confirm record counts, field types, and assets) and then with manual visual reviews to confirm the content looks identical in Sanity Studio and on the frontend.
Choose the Right Migration Path
Once you've assessed the risks, decide how much of the existing structure should carry over. There are two approaches:
- Keep your existing structure — a fast and low-risk path where you move the current setup to Sanity without major refactoring. Ideal if your main goal is to reduce costs or overcome Contentful's limitations without redesigning your data model.
- Rebuild and migrate — a more strategic option where you use a custom script to redesign your content structure for long-term flexibility. This approach takes more time but gives you complete control over how your CMS evolves.
If you're unsure, start by migrating the current setup using the official tool, confirm stability, and then iterate with schema improvements later. For small to mid-sized projects, this hybrid approach often completes in just a few days, as long as there are no missing dependencies or complex plugin integrations.
Option 1: Migrate Using the Official Contentful-to-Sanity CLI
The fastest way to transfer your content from Contentful to Sanity is to use the official CLI tool, contentful-to-sanity. It exports all your data, converts it into Sanity's format, and even generates matching schema files. You can have a working Sanity Studio in minutes.
Step 1: Get Your Contentful Credentials
You'll need three API keys from your Contentful account:
-
Space ID — easiest to copy from your project URL (e.g.
https://app.contentful.com/spaces/[projectURL]/views/entries) or find it under Settings → Space Settings → General Settings. - Content Delivery API token — create one in Settings → API Keys.
- Content Management API (CMA) token — generate it under Account Settings → CMA Tokens, then click the top-right button to create a new personal access token.
Tip: Set your token to expire in 1–30 days. One day is fine for testing, thirty for production.
Step 2: Run the Migration Script
Now that you have your keys, run the CLI command:
npx contentful-to-sanity@latest -s <space-id> -t <cma-token> -a <Content Delivery API - access token> ./output-path
If tokens are correct, you'll see logs of the export. A 401 error means one of the keys is invalid.
After the script finishes, you'll find these files in your output directory:
- contentful.json and contentful.published.json — raw exports from Contentful
- dataset.ndjson — data formatted for Sanity import
- schema.ts — auto-generated schema matching your Contentful models
At this stage, you're about halfway done. The data is ready, and the schema is mapped.
Step 3: Create Your Sanity Project
If you don't already have a Sanity account, sign up at sanity.io. Then create a new, clean project:
npm create sanity@latest \
--template clean \
--create-project "Your Project Name" \
--dataset production \
--output-path my-sanity-project
Log in when prompted, select "Create new project," choose your organization, and follow the prompts. You should have a clean Sanity project.
Open your sanity.config and sanity.cli files to check your project ID and dataset — you'll need them soon.
Copy the generated schema.ts into your schemaTypes directory with an updated naming, and update your index.ts:
import { types } from "./schema-contentful";
export const schemaTypes = [...types];
That will allow you to add new schemas later without impacting the auto-generated file. Run npm run dev to preview your new Studio. On the left, you'll see all the content types, empty for now. Remember their names before moving on to data migration.
Step 4: Import Your Content
Copy dataset.ndjson into your project and run:
npx sanity dataset import ./dataset.ndjson
Select your dataset (default: production). After the import completes, your entries will appear inside Sanity Studio.
Next, update your fetching methods in the Next.js app to use the new data source.
Step 5: Update Your Queries
Replace your Contentful GraphQL API with Sanity's GROQ-based client:
// Sanity client configuration
const client = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID || 'your-project-id',
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET || 'production',
apiVersion: '2024-01-01',
useCdn: process.env.NODE_ENV === 'production',
token: process.env.SANITY_API_TOKEN,
});
Convert your GraphQL queries into GROQ queries. If you're struggling with this part, consider using AI to help.
const PAGE_GROQ_FIELDS = `
_id,
_type,
"slug": slug.current,
internalName,
pageName,
seo {
_id,
_type,
name,
title,
description,
"ogImage": image.asset->url,
noIndex,
noFollow
},
topSection[] {
_id,
_type,
_ref,
internalName,
// Component CTA
...(_type == "componentCta" => {
headline,
subline,
ctaText,
targetPage-> {
"slug": slug.current
},
urlParameters,
colorPalette
}),
// Component Duplex
...(_type == "componentDuplex" => {
containerLayout,
headline,
bodyText,
ctaText,
targetPage-> {
"slug": slug.current
},
image {
asset-> {
url,
metadata {
dimensions
}
},
alt
},
imageStyle,
colorPalette
}),
`
The -> sign is very important here. When a field is a reference to another document, by default you only get the reference id. To resolve it and get real values, you need to use the -> syntax.
Step 6: Update the Code
After updating the queries, focus on updating the code where you fetch data. Right now, your setup fetches all posts using GraphQL, the Contentful URL, and access tokens:
export async function getAllPosts(isDraftMode: boolean): Promise<any[]> {
const entries = await fetchGraphQL(
`query {
pageCollection(where: { slug_exists: true }, preview: ${
isDraftMode ? "true" : "false"
}) {
items {
${POST_GRAPHQL_FIELDS}
}
}
}`,
isDraftMode,
);
return extractPostEntries(entries);
}
Update it to use your new Sanity client and query:
// Get all pages
export async function getAllPosts(isDraftMode: boolean): Promise<any[]> {
const query = groq`*[_type == "page" && defined(slug.current)] | order(_createdAt desc) {
${PAGE_GROQ_FIELDS}
}`
const entries = await client.fetch(query)
return extractPageEntries(entries)
}
Update all the places where you fetch data from Contentful to use Sanity instead. You can still use GraphQL in this scenario — see the official Sanity docs for setup (it requires deploying the API on every schema change).
Step 7: Update the Richtext
Contentful uses its own logic to store and render rich text markdown. Sanity uses its own pattern via the PortableText component.
Define the components for each block type and replace the old Markdown renderer:
import Image from "next/image";
import { PortableText } from "@portabletext/react";
import { PortableTextBlock } from "@portabletext/types";
interface SanityImage {
_id: string;
url: string;
alt?: string;
caption?: string;
asset: {
_ref: string;
_type: string;
};
}
interface SanityContent {
_type: string;
_key: string;
children?: Array<{
_type: string;
_key: string;
text: string;
marks?: string[];
}>;
markDefs?: Array<{
_key: string;
_type: string;
href?: string;
}>;
asset?: SanityImage;
url?: string;
alt?: string;
}
interface PortableTextContent {
_type: "block" | "image" | "code" | "table" | "list";
_key: string;
children?: SanityContent[];
markDefs?: SanityContent[];
asset?: SanityImage;
url?: string;
alt?: string;
code?: string;
language?: string;
}
function SanityImageComponent({ value }: { value: SanityImage }) {
return (
<div className="my-6">
<Image
src={value.url}
alt={value.alt || ""}
width={800}
height={600}
className="rounded-lg"
style={{ width: "100%", height: "auto" }}
/>
{value.caption && (
<p className="text-sm text-gray-600 mt-2 text-center italic">
{value.caption}
</p>
)}
</div>
);
}
function CodeBlock({ value }: { value: { code: string; language?: string } }) {
return (
<pre className="bg-gray-100 p-4 rounded-lg overflow-x-auto my-6">
<code className={`language-${value.language || "text"}`}>
{value.code}
</code>
</pre>
);
}
const components = {
types: {
image: SanityImageComponent,
code: CodeBlock,
},
marks: {
link: ({ children, value }: { children: React.ReactNode; value: { href: string } }) => (
<a
href={value.href}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 underline"
>
{children}
</a>
),
strong: ({ children }: { children: React.ReactNode }) => (
<strong className="font-bold">{children}</strong>
),
em: ({ children }: { children: React.ReactNode }) => (
<em className="italic">{children}</em>
),
code: ({ children }: { children: React.ReactNode }) => (
<code className="bg-gray-100 px-1 py-0.5 rounded text-sm font-mono">
{children}
</code>
),
},
block: {
h1: ({ children }: { children: React.ReactNode }) => (
<h1 className="text-3xl font-bold mb-4 mt-8">{children}</h1>
),
h2: ({ children }: { children: React.ReactNode }) => (
<h2 className="text-2xl font-bold mb-3 mt-6">{children}</h2>
),
h3: ({ children }: { children: React.ReactNode }) => (
<h3 className="text-xl font-bold mb-2 mt-4">{children}</h3>
),
h4: ({ children }: { children: React.ReactNode }) => (
<h4 className="text-lg font-bold mb-2 mt-4">{children}</h4>
),
normal: ({ children }: { children: React.ReactNode }) => (
<p className="mb-4 leading-relaxed">{children}</p>
),
blockquote: ({ children }: { children: React.ReactNode }) => (
<blockquote className="border-l-4 border-gray-300 pl-4 my-4 italic text-gray-700">
{children}
</blockquote>
),
},
list: {
bullet: ({ children }: { children: React.ReactNode }) => (
<ul className="list-disc list-inside mb-4 space-y-1">{children}</ul>
),
number: ({ children }: { children: React.ReactNode }) => (
<ol className="list-decimal list-inside mb-4 space-y-1">{children}</ol>
),
},
listItem: {
bullet: ({ children }: { children: React.ReactNode }) => (
<li className="ml-4">{children}</li>
),
number: ({ children }: { children: React.ReactNode }) => (
<li className="ml-4">{children}</li>
),
},
};
export function MarkdownSanity({ content }: { content: PortableTextBlock[] }) {
return (
<div className="prose prose-lg max-w-none">
<PortableText value={content} components={components} />
</div>
);
}
Replace all usages of the old Markdown component:
// Before
<div className="prose">
<Markdown content={post.content} />
</div>
// After
<div className="prose">
<MarkdownSanity content={post.content} />
</div>
That's enough to transfer the CMS and convert your UI to use Sanity data and rich text. From here, focus on extra features like preview plugins (a must-have for editors) — see the Presentation Tool guide. Also plan out data revalidation using either Webhooks + Revalidate API or the Sanity Live API.
Option 2: Create Your Own CMS Migration Script
If the official CLI tool doesn't work for your project — for example, because you use nonstandard plugins or highly customized content types — you can create your own migration script.
This path takes longer, but it gives you full control over field mapping, schema design, and data validation.
The custom-script path takes longer, but it gives full control. Once your schemas are right, mapping is easy and the output clean and predictable.
— Rafał Dąbrowski, Developer at Pagepro
Step 1: Export Data from Contentful
Install the official CLI:
npm install -g contentful-cli
Log in to your Contentful account:
contentful login
Export your space data:
contentful space export --space-id <your-space-id>
This generates a .json file containing your full space data, including contentTypes, entries, assets, and localization information. For custom migrations, ignore contentTypes and focus on entries.
Each entry's sys object tells you what content type it belongs to, along with its ID and relationships — use this to identify how fields map to your new schema.
Step 2: Define Your Sanity Schemas
In a fresh Sanity project, create schemas that match your data model. Here's an example defining SEO metadata and Page document types:
// ts-nocheck
import { defineField, defineType } from "sanity";
export const seoType = defineType({
type: "document",
name: "seo",
title: "SEO meta tags",
fields: [
defineField({
name: "name",
type: "string",
title: "Internal name",
hidden: false,
validation: (Rule) => Rule.required(),
}),
defineField({
name: "title",
type: "string",
title: "SEO title",
hidden: false,
description: "This will override the page title in search engine results",
}),
defineField({
name: "description",
type: "string",
title: "Description",
hidden: false,
description: "This will be displayed in search engine results",
}),
defineField({
name: "image",
type: "image",
title: "Image",
hidden: false,
}),
defineField({
name: "noIndex",
type: "boolean",
title: "Hide page from search engines (noindex)",
hidden: false,
}),
defineField({
name: "noFollow",
type: "boolean",
title: "Exclude links from search rankings (nofollow)",
hidden: false,
}),
],
});
export const pageType = defineType({
type: "document",
name: "page",
title: "Page",
description: "Our landing pages",
fields: [
defineField({
name: "title",
type: "string",
title: "Title",
validation: (Rule) => Rule.required(),
}),
defineField({
name: "slug",
type: "slug",
title: "Slug",
validation: (Rule) => Rule.required(),
}),
defineField({
name: "seo",
type: "reference",
title: "SEO metadata",
to: [{ type: "seo" }],
}),
defineField({
name: "content",
type: "array",
title: "Content",
of: [
{ type: "topicBusinessInfo" },
{ type: "topicProduct" },
{ type: "componentProductTable" },
],
}),
],
});
Register these schemas in your main configuration file:
import { pageType, seoType } from "./schema.fresh";
export const schemaTypes = [pageType, seoType];
Step 3: Write Your Migration Script
The script maps data from the old system into Sanity schemas and generates an .ndjson file for import. First, read the input file, extract the required data, map it to the new schema, then generate the NDJSON output:
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const CONFIG = {
inputFile: 'contentful-export-8g9vl6174izn-master-2025-10-15T10-17-31.json',
outputFile: 'pages-migrated.ndjson',
primaryLocale: 'en-US'
};
let contentfulData;
try {
const filePath = path.join(__dirname, CONFIG.inputFile);
console.log(`📖 Reading Contentful export from: ${filePath}`);
contentfulData = JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch (error) {
console.error('❌ Error reading Contentful export file:', error.message);
process.exit(1);
}
// Filter for page entries
const pageEntries = contentfulData.entries.filter(entry =>
entry.sys?.contentType?.sys?.id === 'page'
);
console.log(`📄 Found ${pageEntries.length} page entries`);
function convertPageToSanity(contentfulPage) {
const fields = contentfulPage.fields;
const primaryLocale = CONFIG.primaryLocale;
const sanityPage = {
_type: 'page',
_id: contentfulPage.sys.id,
title: fields.pageName?.[primaryLocale] || fields.internalName?.[primaryLocale] || 'Untitled',
slug: {
_type: 'slug',
current: fields.slug?.[primaryLocale] || 'untitled'
}
};
if (fields.seo?.[primaryLocale]) {
sanityPage.seo = {
_type: 'reference',
_ref: fields.seo[primaryLocale].sys.id
};
}
// TODO: Handle content later / add sections etc
sanityPage.content = [];
return sanityPage;
}
const sanityPages = pageEntries.map(convertPageToSanity);
const ndjsonContent = sanityPages
.map(page => JSON.stringify(page))
.join('\n');
const outputPath = path.join(__dirname, CONFIG.outputFile);
try {
fs.writeFileSync(outputPath, ndjsonContent);
console.log(`✅ Migration completed!`);
console.log(`📄 Converted ${sanityPages.length} pages`);
console.log(`📁 Output file: ${outputPath}`);
} catch (error) {
console.error('❌ Error writing output file:', error.message);
process.exit(1);
}
if (sanityPages.length > 0) {
console.log('\n📋 Sample converted page:');
console.log(JSON.stringify(sanityPages[0], null, 2));
console.log('\n📊 Field mapping summary:');
console.log('Contentful → Sanity');
console.log('pageName → title');
console.log('slug → slug.current');
console.log('seo → seo (reference)');
}
Once you have a working base, focus on creating and refining your schemas. Getting the schemas right is the most time-consuming — and important — part of this migration. Once that's done, mapping the data is straightforward.
When your output looks correct, import it into Sanity and continue with Step 4 from Option 1: updating rich text rendering and adding any plugins you need.
Some teams use alternative methods, like web scraping, to extract content, if that approach fits their project better.
Conclusion
Migration is a process that rewards preparation. With a clear plan, backups, and a careful approach, anybody can migrate Contentful to Sanity.
How long it takes depends entirely on your goals. If you're simply moving to Sanity for better flexibility and are happy with your current data model, the process can be very quick. But if you're rebuilding schemas or adding new features, treat it as an opportunity to refine your CMS structure for the long term.
Above all, never skip testing. Validate your redirects, metadata, and content relationships before the final switch. A few extra rounds of automated and manual checks can save you from downtime, broken links, and SEO drops.
FAQ
Why Do Companies Migrate Contentful to Sanity?
Teams move from Contentful to Sanity for greater flexibility, customization, and predictable costs. Sanity lets developers define their own content models, add custom input components, and collaborate in real time. It works better for growing teams that need control without the limits of a closed ecosystem.
Why is Contentful So Expensive?
The biggest issue with Contentful's pricing is the steep jump between the free and paid tiers. The free plan is generous for individual developers, but for teams only a few content types and users are allowed. The next available plan costs roughly $300/month, plus additional purchases like different types of spaces.
Is Sanity CMS Free?
Yes. Sanity offers a free forever plan suitable for small projects, MVPs, and personal sites. Paid plans start at around $15 per user/month, adding collaboration features, increased API limits, and additional datasets.
How Long Does it Take to Migrate Contentful to Sanity?
Migration time depends on your setup. Using the official CLI tool, a simple project can be moved in a day or two. For larger projects with custom content models, the process can take a few weeks.
What's the Difference Between Using the CLI Tool and Writing a Custom Script for Migration?
The CLI tool is fast and automated — it exports data from Contentful, converts it into Sanity's format, and generates schema files. Writing a custom migration script takes longer but gives you total control: you can redesign schemas, fine-tune data mapping, and decide exactly how relationships and fields are structured.
Can I Migrate Assets, Such as Images and PDFs, from Contentful to Sanity?
Yes. The official CLI handles assets automatically, while a custom script requires you to include them manually using the assets array and Sanity's upload API.
Top comments (0)