TL;DR
Contentful is being acquired by Salesforce. As I write this, Contentful has signed a definitive agreement to be acquired by Salesforce. What that means, in my opinion:
- Distribution win for Contentful. It rides Salesforce's enterprise machine: no separate procurement cycle, and instant reach into big accounts.
- Less of the developer-first "cool" Contentful that people fell for. Acquired products tend to drift toward the parent company's priorities.
- More expensive, and likely the end of self-serve. Salesforce sells to enterprises, so expect pricing and focus to follow. Don't rule out sunsetting Contentful entirely down the road. They did, after all, gut Heroku's beloved free tier after acquiring it.
This is my opinion, not a prediction. But if any of it rings true, the safe move is to make sure your content isn't locked into someone else's platform. That's exactly what this guide is about.
Contentful is a hosted headless CMS. Teams usually start looking elsewhere for three reasons:
Strapi is the most popular open-source alternative. You self-host it (or use Strapi Cloud), you own the database, and the REST and GraphQL APIs are yours to customize.
The Salesforce acquisition only sharpens these reasons. Co-founder Sascha Konietzke pitches Contentful's structured content as the layer for Salesforce's Agentforce, writing that "AI agents now outnumber humans on the Web." Whichever way that goes, owning your content keeps the choice yours.
The difference shows up the moment you sit down to build:
contentful login.npm run develop, the data lives in your own database, and no account or login to anyone's servers is required.That ownership is the whole point of the move, and you'll feel it in this tutorial: the source side needs a hosted account, and the destination side is just a process on localhost.
The two systems model content differently, which means a direct database copy does not work. Schema translation, asset re-upload, and relationship reconnection are all required. That's what the migration script handles.
We'll walk through a real migration of a small blog, and you get a project you can run end-to-end.
Here's the shape of what we're building:
Goal of this section: get everything installed and authenticated so the later steps just work. None of it assumes you've used Contentful or Strapi before. Four steps.
1. Install Node.js (version 18.18 or newer) from nodejs.org, then confirm:
node -v # should print v18.18 or higher2. Clone the example code. Clone the companion repo and move into it:
git clone https://github.com/PaulBratslavsky/contentfull-to-strapi-migration-post.git contentful-to-strapi
cd contentful-to-strapiInside you'll find playground/contentful-seed/ (scripts to spin up a sample Contentful space) and .claude/skills/contentful-to-strapi-migration/ (the skill that does the migration). That's the whole project.
3. Create a free Contentful account. Because Contentful is hosted, there's no local option: you need an account on their servers even to define a content model. Go to contentful.com, click Sign up (the free tier is plenty for this tutorial; no credit card required), and verify your email. That's all you need here.
Step 4 handles the space and token from the CLI.
Why an account is unavoidable here. Contentful is software-as-a-service: you can't spin it up locally, so reading or exporting your own content always goes through their hosted API and requires logging in. Strapi (the destination) is the mirror image: fully self-hosted, runs on
localhost, and needs no account at all. It's the same reason teams migrate in the first place.
4. Install the Contentful CLI Part 1 (creating the content model and exporting your space) is driven by the Contentful CLI, so set it up now.
Install the CLI (skip if you already have it; confirm with contentful --version):
npm install -g contentful-cli
contentful --version # confirm it's on your PATH5. Log in to Contentful
contentful loginA browser window will open where you will log in (or sign up if you don’t have an account), authorize this CLI tool, and paste your CMA token here:
? Continue login on the browser? Yes
? Paste your token here: *******************************************
Great! You've successfully logged in!6. Set your active space. Contentful's Free plan includes 1 Starter Space. List your spaces and set that one active:
# shows your space(s) and their IDs
contentful space list
┌────────────┬──────────────┐
│ Space name │ Space id │
├────────────┼──────────────┤
│ Blank │ pwomps5x1tsi │
└────────────┴──────────────┘Use your ID in the next step.
1# make it the active space
2contentful space use --space-id <id-from-list>Heads up on
contentful space create. If you already have your one included space, creating another is a paid action. The CLI warns "adding new spaces … will result in extra monthly charges" and asks you to confirm. On the Free plan, answernand justspace usethe space you already have. (If that space already holds content you care about, note that the seed step adds the sample blog's content types and entries to it. Use a throwaway space if you'd rather keep them separate.)No global install? Prefix every
contentfulcommand withnpx -y contentful-cli(for example,npx -y contentful-cli space list) and skip step 4a.
With Node, the code, and the CLI logged in against your space, you're ready to seed your Contentful content. That's Part 1. (We create the Strapi destination later, in Part 2.)
Before writing any code, map the concepts. Most of a migration is just deciding what becomes what.
| Contentful | Strapi v5 | Notes |
|---|---|---|
| Content Type | Collection Type | A type with many entries (blog posts, authors). |
| A single special entry | Single Type | One-of-a-kind content like the landing page. |
| Entry | Document | In v5, the stable identity is the documentId. |
| Asset (on the CDN) | Media (Upload library) | Must be re-uploaded; URLs change. |
| Reference (Link) | Relation | Reconnected after entries exist. |
| Rich Text (JSON AST) | Rich text (Blocks) | The skill converts the tree into Strapi's native block format. |
| Array of strings (tags) | Collection + relation | Free-text lists are promoted to their own type so they're reusable. |
For our sample, that means: blog-post, author, category, and product as collection
types; landing-page as a single type; and a tag collection the skill promotes from
the products' free-text tags.
contentfulIdAdd a plain string field called contentfulId to every destination type. We store each record's original Contentful ID there.
It costs nothing and buys two things. First, the migration becomes idempotent: re-running it updates the same record instead of creating a duplicate. Second, you keep a paper trail back to the source if you need to debug later. It's the same trick the community strapi_lift guide relies on. Once the migration is done and you're sure you won't need to re-import, you can safely drop the field.
Goal of this section: end up with a Contentful space holding a small sample site, then
export it to a file we can migrate. The sample is a blog (three posts, two authors, three
categories, a landing page, images) and a product catalog (a couple of products with
price, SKU, image, and tags). The product collection is there on purpose: it shows the skill
reads your model and isn't limited to a blog. (It even promotes the products' free-text
tags into their own tag collection on the Strapi side.)
The seed project already lives in playground/contentful-seed/. Run these four commands in
order:
cd playground/contentful-seed
npm install # 1. install the seed project's dependencies
npm run model # 2. create the content types in your Contentful space
npm run seed # 3. add and publish the sample posts, authors, categories, and images
npm run export # 4. download it all to ./export/export.json (the file you'll migrate)The commands reuse the credentials from your contentful login in setup, so there's nothing
else to configure. Open your space in the Contentful web app, and you'll see the seeded
blog; playground/contentful-seed/export/export.json is what you hand to the skill in Part 2.
Goal of this section: stand up a Strapi v5 project and hand the migration to the
contentful-to-strapi-migration skill. You point it at the export from Part 1; it reads
your content model, creates matching content types in Strapi (rich text as the native
Blocks editor), builds the migration for your data, and runs it.
Step 2.1: Create a Strapi project. Use Strapi's official generator:
npx create-strapi-app@latest my-strapi-blog --non-interactive
cd my-strapi-blog
npm run develop Create your first admin user.
Once you log in, you will be greeted with the dashboard.
Leave it running; the admin panel is at http://localhost:1337/admin.
Step 2.2: Run the skill. With the Strapi dev server still running, open
Claude Code at the repo root. From there, you can see both your export and the Strapi project, and the skill (shipped at.claude/skills/contentful-to-strapi-migration/) is picked up automatically. Then give it a prompt with the two paths:
1Use the contentful-to-strapi-migration skill to migrate my Contentful export into Strapi. The export is at playground/contentful-seed/export/export.json and its downloaded images are in playground/contentful-seed/export/. My Strapi v5 project is at ./my-strapi-blog, running at http://localhost:1337. Create the content types, set up a write API token, and create a migration script that I can run. Here's what the skill does for you, start to finish:
contentfulId on every type so the migration can re-run safely. (For our sample, that's blog-post, author, category, and product collections, a landing-page single type, and a tag collection the skill promotes from the products' free-text tags.)curl.Because it works from your model, the skill isn't limited to this blog. Point it at any Contentful space, and it shapes both the Strapi content types and the migration to match.
That's the whole idea: the skill builds the migration script for you to review and run, you don't hand-write one.
The skill follows a fixed pipeline. Every stage runs the same way each time, with one human checkpoint in the middle where you review the plan before anything is written to Strapi. Here's the run that produced the blog and product catalog above.
1. It reads your export and your Strapi project. It detects whether the project is TypeScript or JavaScript, so the files it generates match, and it confirms the dev server is up.
2. It analyzes the export and prints a plan of every content type, every field, and the Strapi type it proposes for each:
1Migration plan (locale: en-US)
25 content types · 11 entries · 16 assetsIt also flags the judgment calls, like a free-text list of tags that could become its own collection.
3. You review the plan. This is the checkpoint. It's the one place a real decision gets
made. In this run, both blogPost.tags and product.tags held plain strings, so the skill
promoted them into a single shared tag collection with relations, instead of leaving them
as raw JSON. (Collections beat JSON for anything reusable.)
4. It generates the Strapi schema. It writes the content-type files (TypeScript here), and Strapi's dev server hot-reloads them. Six types appear and their routes go live:
1 + author (collection)
2 + category (collection)
3 + blog-post (collection)
4 + product (collection)
5 + landing-page (single)
6 + tag (collection, promoted)5. It sets up read access and a write token. A bootstrap grants the public role read
access so you can check the result with curl, and you supply a Full access API token for
the migration to write with.
6. It hands you the migration script to run. The skill stops here. You run the script yourself, and it works in four passes, then prints a summary:
1[1/4] Uploading assets to the media library...
2[2/4] Promoting tag-like fields to collections...
3[3/4] Creating entries (no cross-entry relations yet)...
4[4/4] Linking relations...
5
6Migration complete:
7 tags 12
8 authors 2
9 categories 3
10 blog-posts 3
11 landing-page 1
12 products 2That's the whole pipeline: analyze, review, generate, then run. The only step that needs your judgment is the review in the middle. Everything else is identical on every run, which is what makes the result predictable.
You don't have to write any of this. But it's worth seeing what the skill does behind the scenes, because it's the same three things every migration comes down to. Seeing them turns "magic" into "I get it."
It works in order, so nothing ever points at something that doesn't exist yet: bring the images over, create the entries, then connect them up.
Contentful keeps rich text as a structured tree: a heading here, a bold word there, an image in the middle. Strapi's native Rich text (Blocks) editor has its own structure, and the skill translates one into the other: headings, lists, links, quotes, bold/italic, and inline images all carry over. Images that were sitting inside the body get repointed to their new home in Strapi's media library, so nothing breaks after the move. This is the fiddliest part of any migration, which is exactly why it's built into the skill instead of being something you write.
Your images live on Contentful's CDN; Strapi keeps its own copy. The skill brings each image into Strapi's media library and then attaches it to the entry that uses it. Those are two quick steps, and it does both for you.
One thing worth knowing: Contentful's CDN can resize and optimize images on the fly, while Strapi's media library serves fixed sizes. If on-the-fly resizing matters to you, point Strapi at a CDN that does it. There's an official. Cloudinary provider for exactly that.
In Strapi, you can try Strapi AI, which automatically generate alt-text, captions, and tags to save hours of manual work, boost accessibility, and improve SEO — while keeping full editorial control.
In Contentful, a post points at its author and category, and the home page points at its
featured posts. The skill creates every entry first, remembers where each one landed, then reconnects those relationships, so a post always finds its author and the home page finds
its featured posts. The product catalog works the same way: each product reconnects to the tag records the skill promoted from its free-text tags, so a list of strings becomes a real, reusable relation. And because every migrated entry remembers where it came from, you
can re-run the whole migration as often as you like without ever creating duplicates.
When the skill finishes, it prints a summary of what was moved. Check the result over the public API:
curl http://localhost:1337/api/blog-posts # each post has its author, category, and cover image
curl http://localhost:1337/api/landing-page # hero image + featured posts
curl http://localhost:1337/api/products # each product has price, SKU, image, and tag relationsOpen a post's body and you'll see Strapi Blocks, with any in-body image pointing at a /uploads/... URL on your own server. Run the migration again, and the counts stay the same: every entry remembers where it came from, so a re-run updates rather than duplicates.
The complete sequence in one place. Do the tool setup
first (Node, the repo, contentful login, space use), then:
# Source — seed a sample Contentful space and export it (Part 1)
cd playground/contentful-seed
npm install
npm run model # create the content model in your space
npm run seed # create + publish the sample entries and images
npm run export # writes ./export/export.json + downloaded images
# Destination — create a Strapi project (Part 2)
cd ../..
npx create-strapi-app@latest my-strapi-blog --non-interactive
cd my-strapi-blog
npm run develop # create your admin user, then leave it runningThen, in Claude Code, run the skill:
1Use the contentful-to-strapi-migration skill to migrate my Contentful export at
2playground/contentful-seed/export/export.json into the Strapi project at ./my-strapi-blog
3(running at http://localhost:1337). Create the content types, set up a write API token,
4and generate the migration script for me to review and run.The skill stops once the script is ready. Review migrate/migrate.js and the generated
migration.config.json, then run it yourself:
cd migrate
node migrate.js --export ../playground/contentful-seed/export/export.json --config migration.config.json# Verify
curl http://localhost:1337/api/blog-posts # posts with author, category, cover image
curl http://localhost:1337/api/landing-page # hero + featured posts
curl http://localhost:1337/api/products # products with price, SKU, image, tag relationsThat's it. Your whole site now lives in Strapi: the blog (posts, authors, categories, images, and the landing page) and the product catalog (with tags as a real relation), with formatted text as Strapi Blocks and every relationship reconnected.
The skill ships in this repo at .claude/skills/contentful-to-strapi-migration/,
so Claude Code finds it automatically when you open the project. It's deliberately a starting point, not a one-size-fits-all migrator. It knows this sample blog's shape, and it reads your Contentful export to adapt. Point it at a different space, and it builds the matching content types and migration for that model. Want to change how something maps? Just
tell Claude; the skill is yours to adapt.
Strapi 5.47 and later ships a built-in MCP server that lets an AI client create, update, and publish entries and wire up relations natively.
This skill creates entries over the REST API, which requires only a write token.
To use the Strapi MCP for the entry step instead, enable it in config/server.js (mcp: { enabled: true }), connect via /mcp with an admin token, and tell Claude to use it for that step. Note that the MCP server does not create content types or upload media — those steps still use the REST API regardless.
That's exactly the kind of thing you'd adjust in your own copy of the skill.
New to agent skills and want to build your own? Strapi has a friendly primer: What are agent skills and how to use them.
The script in this post is deliberately small and readable so you can see exactly what each step does. It's perfect for a blog with hundreds of entries and a solid base to extend. For a large production migration (thousands of entries, gigabytes of assets, resumable runs, structured logging), it's worth knowing about purpose-built tooling.
A community guide on the Strapi blog, Migrate from Contentful to Strapi by Tim Adler, is built around strapi_lift, his open-source migration tool. It documents a real migration of roughly 2,000 entries and 11 GB of assets that took about seven hours, and adds things you'd eventually want at scale:
contentfulId and updates rather than duplicates.--content-types articles:10,categories, --ids, and --skip let you rehearse on a slice before the full run.fix-assets command re-downloads the 0-byte assets a flaky export leaves behind.reset command to wipe imported content and start clean.log.jsonl you can slice with jq (jq 'select(.level=="error")') to trace which entry actually caused a failure.The trade-off is configuration: you describe each content type with a per-type mapper plus an "intermediate model," and you write a small "link" class for every kind of relation so the tool knows how to resolve it. That's more upfront setup than our single mapping file: the cost of being general. Same three hard parts under the hood, just industrialized.
A rough rule of thumb:
strapi_lift when the volume is large, you need restartable runs and audit logs out of the box, and you'd rather configure than write the plumbing.Either way, the hard parts are the same three we covered here: rich text, assets, and relations. Once you understand them, the tool you pick is just a delivery mechanism.
Citations
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.