Back to Blog
Blog

How to Migrate from Contentful to Cosmic in 30 Minutes

Tony Spiro's avatar

Tony Spiro

June 8, 2026

Hero image

Since Salesforce completed its acquisition of Contentful, teams across the industry have been re-evaluating their CMS stack. Pricing changes, roadmap uncertainty, and enterprise-first repositioning are pushing developers and content teams to look for a more focused alternative. If you've already decided to move on, this guide covers the practical how-to. For the "why," see our posts on the best Contentful alternative and what the Salesforce acquisition means for your team.

This walkthrough takes roughly 30 minutes for a typical project. Larger spaces with thousands of entries or complex localization setups may take longer, but the steps are the same.


What You'll Need

  • Node.js 18+ installed
  • A Contentful account with space access and a Management API token
  • A Cosmic account (free plan works — sign up here, no credit card required)
  • The @cosmicjs/sdk package
  • Basic familiarity with the command line

Step 1: Export Your Content from Contentful

Contentful provides a first-party CLI that handles the full export to JSON. Install it globally:

npm install -g contentful-cli

Authenticate with your Management API token:

contentful login

Then run the export:

contentful space export \ --space-id YOUR_SPACE_ID \ --management-token YOUR_MANAGEMENT_TOKEN \ --include-drafts \ --download-assets \ --content-file contentful-export.json

This produces a single contentful-export.json file containing your contentTypes, entries, assets, and locales. The --download-assets flag pulls the actual media files to your local machine alongside the JSON. You'll need them in Step 4.

What the export file looks like:

{ "contentTypes": [], "entries": [], "assets": [], "locales": [] }

Keep this file. Every subsequent step reads from it.


Step 2: Map Contentful Content Types to Cosmic Object Types

This is the most important step and the one that takes the most thought. The concepts map closely but are not identical.

ContentfulCosmic
SpaceBucket
Content TypeObject Type
FieldMetafield
EntryObject
AssetMedia (imgix CDN)
EnvironmentBucket (separate)

Opening your export and reading content types:

import fs from 'fs'; const exportData = JSON.parse(fs.readFileSync('./contentful-export.json', 'utf-8')); // Inspect your content types exportData.contentTypes.forEach((ct: any) => { console.log(`Type: ${ct.sys.id}`); ct.fields.forEach((field: any) => { console.log(` - ${field.id} (${field.type})`); }); });

Field type mapping reference:

Contentful Field TypeCosmic Metafield Type
Symbol (short text)text
Text (long text)textarea
RichTextrich-text or markdown
Integer / Numbernumber
Booleanswitch
Datedate
Link (Asset)file
Link (Entry)object
Array of Links (Entries)objects
Array of Symbolsmulti-select
JSONjson
Colorcolor

Cosmic supports over 20 metafield types in total, including repeater (for nested arrays of fields), parent (for grouped fields), and emoji. If a Contentful field has no direct equivalent, the json type is a reliable fallback.

A key difference worth noting: Cosmic requires no schema migrations. You define Object Types and their metafields once in the dashboard or via the SDK, and you can modify them at any time without downtime or a migration script. Fields are added or removed instantly.


Step 3: Create Your Object Types in Cosmic

You can create Object Types in the Cosmic dashboard under Bucket Settings > Object Types, or programmatically using the @cosmicjs/sdk. Here is a TypeScript script that reads your Contentful content types and creates the corresponding Cosmic Object Types:

import { createBucketClient } from '@cosmicjs/sdk'; import fs from 'fs'; const cosmic = createBucketClient({ bucketSlug: 'YOUR_BUCKET_SLUG', readKey: 'YOUR_READ_KEY', writeKey: 'YOUR_WRITE_KEY', }); const exportData = JSON.parse(fs.readFileSync('./contentful-export.json', 'utf-8')); // Map Contentful field types to Cosmic metafield types function mapFieldType(contentfulType: string, linkType?: string): string { const typeMap: Record<string, string> = { Symbol: 'text', Text: 'textarea', RichText: 'rich-text', Integer: 'number', Number: 'number', Boolean: 'switch', Date: 'date', Object: 'json', }; if (contentfulType === 'Link') { return linkType === 'Asset' ? 'file' : 'object'; } if (contentfulType === 'Array') { return linkType === 'Entry' ? 'objects' : 'multi-select'; } return typeMap[contentfulType] ?? 'text'; } for (const ct of exportData.contentTypes) { const metafields = ct.fields.map((field: any) => ({ key: field.id, title: field.name, type: mapFieldType(field.type, field.linkType ?? field.items?.linkType), required: field.required ?? false, })); console.log(`Creating Object Type: ${ct.sys.id}`); await cosmic.objectTypes.insertOne({ title: ct.name, slug: ct.sys.id.toLowerCase().replace(/_/g, '-'), metafields, }); } console.log('Object Types created.');

Verify in your Cosmic dashboard that each Object Type was created with the right metafields before moving to the next step.


Step 4: Import Your Entries via the TypeScript SDK

With Object Types in place, you can now write entries into Cosmic. This script reads contentful-export.json and creates a Cosmic Object for each entry:

import { createBucketClient } from '@cosmicjs/sdk'; import fs from 'fs'; const cosmic = createBucketClient({ bucketSlug: 'YOUR_BUCKET_SLUG', readKey: 'YOUR_READ_KEY', writeKey: 'YOUR_WRITE_KEY', }); const exportData = JSON.parse(fs.readFileSync('./contentful-export.json', 'utf-8')); // Build a lookup map: Contentful entry ID -> Cosmic slug (for relationship fields) const entrySlugMap: Record<string, string> = {}; for (const entry of exportData.entries) { const contentTypeId = entry.sys.contentType.sys.id; const fields = entry.fields; // Use the default locale value for each field const locale = exportData.locales.find((l: any) => l.default)?.code ?? 'en-US'; const title = fields.title?.[locale] ?? fields.name?.[locale] ?? fields.heading?.[locale] ?? entry.sys.id; const slug = title .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/(^-|-$)/g, ''); // Build the metadata object from entry fields const metadata: Record<string, any> = {}; for (const [key, value] of Object.entries(fields)) { const fieldValue = (value as any)[locale]; if (fieldValue !== undefined) { if (fieldValue?.sys?.type === 'Link') { metadata[key] = fieldValue.sys.id; } else { metadata[key] = fieldValue; } } } try { const result = await cosmic.objects.insertOne({ title, slug, type: contentTypeId.toLowerCase().replace(/_/g, '-'), status: entry.sys.publishedAt ? 'published' : 'draft', metadata, }); entrySlugMap[entry.sys.id] = slug; console.log(`Created: ${title}`); } catch (err: any) { console.error(`Failed to create "${title}": ${err.message}`); } } console.log(`Import complete. ${Object.keys(entrySlugMap).length} entries created.`);

A note on RichText fields: Contentful RichText is stored as a deeply nested JSON document. You'll want to convert it to HTML or Markdown before storing it in Cosmic. The @contentful/rich-text-html-renderer package handles this cleanly:

npm install @contentful/rich-text-html-renderer
import { documentToHtmlString } from '@contentful/rich-text-html-renderer'; // In your field mapping loop: if (typeof fieldValue === 'object' && fieldValue?.nodeType === 'document') { metadata[key] = documentToHtmlString(fieldValue); }

Step 5: Migrate Assets to the imgix CDN

Cosmic serves all media through imgix, which means every asset gets automatic image optimization, resizing, and format conversion with zero configuration.

Upload assets to your Cosmic bucket using the @cosmicjs/sdk. Fetch each Contentful asset as a buffer and upload it via cosmic.media.insertOne():

import { createBucketClient } from '@cosmicjs/sdk'; import fs from 'fs'; const cosmic = createBucketClient({ bucketSlug: 'YOUR_BUCKET_SLUG', readKey: 'YOUR_READ_KEY', writeKey: 'YOUR_WRITE_KEY', }); const exportData = JSON.parse(fs.readFileSync('./contentful-export.json', 'utf-8')); const assetUrlMap: Record<string, string> = {}; for (const asset of exportData.assets) { const locale = exportData.locales.find((l: any) => l.default)?.code ?? 'en-US'; const file = asset.fields.file?.[locale]; if (!file?.url) continue; const originalUrl = `https:${file.url}`; const filename = file.fileName ?? asset.sys.id; try { // Fetch the asset as a buffer const response = await fetch(originalUrl); const arrayBuffer = await response.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); // Upload to Cosmic using the SDK const data = await cosmic.media.insertOne({ media: { originalname: filename, buffer, }, }); if (data.media?.imgix_url) { assetUrlMap[asset.sys.id] = data.media.imgix_url; console.log(`Uploaded: ${filename}`); } } catch (err: any) { console.error(`Failed to upload "${filename}": ${err.message}`); } } fs.writeFileSync('./asset-url-map.json', JSON.stringify(assetUrlMap, null, 2)); console.log('Asset migration complete.');

Save the asset-url-map.json file. You can use it to do a second pass over your imported Objects and update any file metafield values to point to the new imgix URLs.

imgix advantage: Once assets are in Cosmic, you get URL-based transformations for free. For example:

// Original https://imgix.cosmicjs.com/your-image.jpg // 800px wide, WebP, 80% quality https://imgix.cosmicjs.com/your-image.jpg?w=800&fm=webp&q=80 // Square crop, 400px https://imgix.cosmicjs.com/your-image.jpg?w=400&h=400&fit=crop

No additional CDN configuration required.


Step 6: Set Up URL Redirects

If your Contentful-backed site had URLs tied to Contentful entry IDs or specific slug patterns, you'll want redirects in place before you flip DNS.

The exact approach depends on your frontend framework and hosting. Common options:

Next.js (next.config.js):

module.exports = { async redirects() { return [ { source: '/blog/:slug', // Your old Contentful-era path destination: '/articles/:slug', // Your new Cosmic-backed path permanent: true, // 301 redirect }, ]; }, };

Vercel (vercel.json):

{ "redirects": [ { "source": "/old-path/:slug", "destination": "/new-path/:slug", "permanent": true } ] }

Netlify (_redirects file):

/old-path/* /new-path/:splat 301

If you maintained the same slug structure in your Cosmic import (recommended), you may need zero redirects at all. Check your slug mapping from Step 4.


Step 7: Validate with the Cosmic SDK

Before cutting over traffic, run a quick validation to confirm your content landed correctly.

import { createBucketClient } from '@cosmicjs/sdk'; const cosmic = createBucketClient({ bucketSlug: 'YOUR_BUCKET_SLUG', readKey: 'YOUR_READ_KEY', }); // Check total object count per type const objectTypes = ['blog-post', 'author', 'category']; // Replace with your types for (const type of objectTypes) { const { total } = await cosmic.objects .find({ type }) .props('id,title,slug') .limit(1); console.log(`${type}: ${total} objects in Cosmic`); } // Spot-check a specific object by slug const { object } = await cosmic.objects .findOne({ type: 'blog-post', slug: 'your-test-slug' }) .props('id,title,slug,metadata'); console.log('Spot check:', JSON.stringify(object, null, 2));

Cross-reference the object counts against your Contentful export file:

const exportData = JSON.parse(fs.readFileSync('./contentful-export.json', 'utf-8')); const contentfulCounts: Record<string, number> = {}; for (const entry of exportData.entries) { const type = entry.sys.contentType.sys.id; contentfulCounts[type] = (contentfulCounts[type] ?? 0) + 1; } console.log('Contentful entry counts:', contentfulCounts);

If the counts match, you're ready to update your frontend's environment variables to point at your Cosmic bucket and go live.


Realistic Time Estimate

TaskEstimated Time
Install CLI + export from Contentful5 minutes
Review export, map content types5-10 minutes
Create Object Types via SDK5 minutes
Import entries via SDK script5-10 minutes
Upload assets via SDK3-5 minutes
Set up redirects2-5 minutes
Validate with SDK5 minutes
Total~25-40 minutes

Larger spaces (10,000+ entries, complex localization, or many content types) should plan for a longer scripted run and a testing window. The migration logic is the same; it just takes more time to execute.


Let Cosmic AI Agents Help

If you'd rather not write the migration scripts by hand, Cosmic AI Agents can help. From inside your Cosmic dashboard, you can prompt an agent to inspect your export file, generate a schema mapping, write the import scripts, and validate the results, all from a natural language interface.

This is especially useful for complex content models with nested relationships, multi-locale content, or large entry volumes where manual mapping would be tedious.


You're Live on Cosmic

Once validation passes, update your frontend's environment variables:

# Replace your Contentful env vars with these COSMIC_BUCKET_SLUG=your-bucket-slug COSMIC_READ_KEY=your-read-key

Then redeploy. Your content is now served from Cosmic's global CDN, with assets on imgix, and you have a free plan that includes unlimited API requests with no credit card required.

Pricing starts at $0/month (Free plan: 1 Bucket, 2 team members, 1,000 Objects). Paid plans start at $99/month (Builder) and scale to $499/month (Business, 50,000 Objects, 10 team members). Additional users are $29/user/month on any paid plan.


Next Steps


Hero image