1 Introduction
For many years, Contentful was our preferred solution for headless CMS—established, performant, and packed with enterprise features. But Contentful’s pricing has never really targeted small or medium projects. When a noticeable price increase was announced for our project hochzeitsplaza.de, it became clear that a migration was unavoidable.
At the same time, Strapi CMS has evolved significantly as an open-source CMS. For the average project, Strapi now offers practically the same functionality as Contentful—especially regarding modern APIs, workflows, and integrations. For us, the availability of a GraphQL API was a crucial factor.
So, the decision was clear: escape vendor lock-in, get rid of the high costs, and switch to an open, flexible, and now mature alternative. The only challenge left was migrating all content and assets as automatically, reliably, and efficiently as possible.
Since there was no off-the-shelf tool for this, we developed our own migration utility—strapi_lift—that does exactly that. In this article, we show how to migrate from Contentful to Strapi. You can largely automate your migration process, and learn what to watch out for.
Explore Strapi
Strapi is an open-source headless CMS that offers flexibility and customization. You can tailor the CMS to your project's specific needs and have full control over the codebase. Strapi allows you to self-host, giving you authority over your data and infrastructure. It supports multiple databases and provides customizable REST and GraphQL APIs. With its plugin ecosystem and active community, you can extend Strapi's functionality as needed.
Discover Contentful
Contentful is a cloud-based headless CMS that provides a fully managed service. It offers tools for content modeling, various field types, and a Content Management API. Contentful handles hosting and infrastructure, reducing the need to manage servers. Its built-in features streamline content management and delivery.
Compare Contentful and Strapi
While both platforms enable content management and delivery, they differ in control and customization. Strapi allows greater customization and self-hosting, providing full control over your data and setup. This flexibility showcases the benefits of using Strapi. Contentful offers a managed service with predefined options, simplifying maintenance. Both Strapi and Contentful are headless CMS platforms, so understanding the headless CMS advantages can help you make an informed decision. Your choice depends on your project's needs for control, customization, and hosting preferences.
2 Preparation
Before you can start the actual migration from Contentful to Strapi, several steps are necessary. Here’s an overview of the key preparations:
2.1 Export from Contentful The first step is to export all content and assets from Contentful. The most reliable way is using the official Contentful CLI Tool. This will give you a JSON export file and all the assets as downloads.
Export command:
npx contentful-cli space export --space-id <space-id> --content-only --download-assets
This command ensures that all content and related assets are stored locally. Depending on your project’s size, this process might take a while.
What you’ll get is a JSON file like contentful-export-space-id master-2025-XX-XXTXX-XX-XX.json and several folders (such as assets.ctfassets.net) containing the exported assets.
You’ll need the JSON file later as contentful_content and the folder with the *.ctfassets.net subfolders as assets_folder.
2.2 Choosing a CDN Contentful serves assets (images, videos, etc.) through a powerful CDN, including flexible image transformation (e.g., on-the-fly resizing). When migrating to Strapi, you’ll need an alternative with similar features. We chose Cloudinary so we could keep using dynamic image resizing and optimization. In principle, you can use any CDN that fits your requirements. For Cloudinary, Strapi even provides direct support and an integration guide.
You can find the Cloudinary provider here
Of course, you can also migrate without a CDN and serve assets directly from Strapi. In this case, there’s no dynamic image resizing, so you’ll need to work with the image sizes configurable in Strapi.
2.3 Creating Content Types in Strapi strapi_lift does not create content types “on the fly.” All required types must exist beforehand. You must replicate all content types present in the export in Strapi—including all fields and relations.
Tip: Stick as closely as possible to your Contentful data model. Ideally, use the same field names. If you used snake_case in Contentful and want to switch to camelCase in Strapi, that’s no problem: strapi_lift supports these changes.
Important: Every content type in Strapi must have an additional contentful_id field. This ensures that the migration tool can link documents in Strapi to the original Contentful export—even after import. Once the migration is complete and you’re sure you don’t need to import again, you can safely remove these fields.
2.4 Basic Configuration of strapi_lift Before strapi_lift can run, you’ll need some basics set up:
- Installation: Clone the repository.
- Ruby Setup: You need Ruby, at least version 2.75.
- Run bundle install.
- Adjust the .env file:
STRAPI_API_TOKEN=your_strapi_token
STRAPI_URL=your_strapi_url
- Representers and Intermediate Models:
For every content type from Contentful, you’ll need a custom representer and a matching Ruby model (more on this in the next section).
3 Adjusting the Migration Tool
A simple Contentful export and the setup of content types in Strapi aren’t enough for strapi_lift to run smoothly. The most important part of preparation is adapting the migration tool to your specific data structures and relationships. This is mostly about the so-called “mapping logic”: Contentful data must be correctly mapped to Strapi structures.
3.1 Contentful Representers For every content type in Contentful, create a representer in Ruby. This describes how fields and relations from the Contentful JSON export are read. The representer ensures that fields are assigned correctly and that relationships, links, and assets are properly handled.
Example (abridged):
1module Contentful
2 class ArticleRepresenter < Representable::Decorator
3 include Representable::JSON
4
5 nested :fields do
6 %w(title slug content).each do |property_name|
7 nested property_name do
8 property property_name, as: :de_de
9 end
10 end
11 nested :category do
12 property :category_link, decorator: Contentful::EntryLinkRepresenter, class: Contentful::CategoryLink, as: :de_de
13 end
14 # ... other relations and assets
15 end
16
17 nested :sys do
18 property :contentful_id, as: :id
19 end
20 end
21end
Note:
Every field or relation you use in Contentful must be mapped in the representer. Especially relations and assets are only stored as links here, and must be specially handled.
3.2 Intermediate Models
Next, you’ll create an intermediate model for each content type—a Ruby class that temporarily holds and prepares the data from the Contentful export during migration.
Example:
1module Contentful
2 class Article
3 include StrapiDocumentConnected
4 attr_accessor :title, :slug, :category
5 rich_text source: :content, target: :content
6 link_object source: :category_link, target: :category
7 api_path "/api/articles"
8 contentful_content_type "5duKiNPsR20mgISegMYmwK"
9 end
10end
Key methods/features:
rich_text source: , target:: Use for text fields that are Markdown and may contain images/assets inline. strapi_lift uploads these assets to Strapi and replaces the links in the text before saving the content.
link_object source: ..., target: ...: Defines a relation to another entry (turning a link into an object). For single links (e.g., top_article).
link_asset source: ..., target: ...: Handles asset associations (e.g., images), uploads them, and links them.
link_objects: For multi-relations, such as related_articles.
api_path: The endpoint in Strapi where the data should land.
contentful_content_type: The unique content type ID in Contentful for this model. Used to identify entries of this type in the Contentful export.
Explanation:
You might wonder why the Contentful representer is written to extract links, even though you want to create a relation. Contentful always stores relations as links—the intermediate model loads these, and during migration, they’re resolved into real objects and transferred to Strapi.
Mapping Links as Intermediate Models
To resolve relationships between different content types correctly during migration, you need to define a “link class” as an intermediate model for every kind of relation. These classes encapsulate a reference (link) from the Contentful export as a Ruby object and resolve it during import.
For example, here’s a Contentful::ArticleLink class:
1require_relative 'entry_link'
2
3module Contentful
4 class ArticleLink < EntryLink
5 attr_accessor :id
6
7 def representer_class
8 Contentful::ArticleRepresenter
9 end
10
11 def target_class
12 Contentful::Article
13 end
14 end
15end
Important:
You need such link classes for all objects that can appear as relations/links in Contentful (categories, authors, galleries, etc.). That’s how the importer knows how to resolve these links during mapping.
For assets, you can reuse the generic Contentful::AssetLink class. For all other content types (like CategoryLink, AuthorLink), create similar link classes.
In summary:
- Every link/relation needs a matching intermediate model.
- These link objects store only the ID and contain methods to resolve them during import.
- This is the only way to have relations and associations created automatically and correctly during import.
3.3 Handling “Single Content Types” (e.g., Homepage)
One important difference: Contentful has no real concept to distinguish between single-entry and multi-entry content types. In Strapi, you can choose this (e.g., for homepages, settings, etc.).
With strapi_lift, you can map this via the single_content_type! method in your intermediate model.
Example:
single_content_type!
This ensures a Contentful type (even if multi-entry in Contentful) is treated as a single type in Strapi.
Note:
If you have more than one entry for a content type in Contentful, but treat it as a single type in Strapi, only the “last” entry from your export will be used. Make sure you only have one entry for content types you want to import as a single type.
3.4 Strapi Representers For every content type, you also need a Strapi representer. This ensures data from the intermediate model is formatted for the Strapi API.
Example:
1module Strapi
2 class ArticleRepresenter < Strapi::BaseRepresenter
3 property :title
4 property :slug
5 property :content
6 property :category
7 # other fields
8 end
9endÏ
Only list fields that aren’t relations (like assets or other classes). This is because strapi_lift handles these relations in a separate step, using the links you configured in 3.2.
3.5 Adjusting the EntriesImporter To let strapi_lift know which content types to import, you’ll need to adjust the EntriesImporter. In lib/importer/entries_importer.rb, there’s a list of all models to be imported. Only those models are considered and processed during import.
Example (excerpt):
1[
2 Contentful::Author,
3 Contentful::Category,
4 Contentful::Article,
5 Contentful::Homepage
6].each do |model|
7 # ...
8end
What to do:
- Add each new content type you want to import to this list (e.g., Contentful::Product).
- Only models in this list are actually processed.
Note: This approach lets you control exactly which models and entries are handled—very handy for large projects or test migrations.
3.6 Adjusting the Reset Function To ensure all imported content can be reliably deleted from Strapi after test runs or failed migrations, the reset function in /bin/strapi_lift must also be updated. Only the content types explicitly listed in the reset section will actually be cleared with the reset command.
What to do:
- For every new content type, add a reset call:
Contentful::Product.reset_strapi!
- The reset_strapi! method is automatically present if the model includes StrapiDocumentConnected.
Example (excerpt from bin/strapi_lift):
1def reset
2 logger = SemanticLogger['strapi_lift']
3 logger.info("Starting reset process...")
4 Contentful::Article.reset_strapi!
5 Contentful::Category.reset_strapi!
6 Contentful::Product.reset_strapi! # <- newly added content type
7 logger.info("Reset process completed successfully.")
8end
4. Running the Migration
With the preparations and models in place, you’re ready for migration! Here’s how the process works, how strapi_lift functions, and what our performance/issue experience was like.
4.1 Starting the Import: The Basic Command Start the import from the command line like this:
./bin/strapi_lift import --contentful_content contentful_export.json --assets_folder assets
Parameters:
- --contentful_content: Path to your Contentful export file (the JSON generated by the CLI tool).
- --assets_folder: Path to the local folder where the downloaded assets (images, PDFs, videos, etc.) are stored.
You can restart the import at any time—strapi_lift will check if entries already exist and will update rather than duplicate them.
During import, strapi_lift outputs progress info:
2025-05-17 20:42:39.296377 I [58556:740] strapi_lift -- Starting import process...
2025-05-17 20:42:39.299585 I [58556:740] EntriesImporter -- Processing 1/1011 -- {:id=>"4dukPV8tjWOGGKEoeAuOUm", :model=>"articles"}
...
All log entries are also written to log.jsonl and can be analyzed in detail using tools like jq and grep.
4.2 How the Tool Works strapi_lift works as follows:
- All entries are read from the Contentful export.
- The tool creates a new document in Strapi for each entry—or updates it if it already exists.
- It then resolves and creates all assets and 1-level-deep relations (relations are only resolved one level deep).
This approach prevents infinite loops during import, as relations aren’t recursively imported, but handled sequentially. Every entry must be imported at least once so that all its relations are set correctly.
Migration steps:
- Assets used in entries (e.g., in rich text fields) are uploaded.
- Entries are created with all values.
- Assets are processed, uploaded, and linked to entries.
- Relations to other entries are resolved and set once the target objects exist.
4.3 Duration and Performance In our project, migration involved about 2,000 entries and over 11GB of assets. The entire migration took about 7 hours.
The duration mainly depends on:
- Number of entries
- Number and size of assets
- Performance of your Strapi instance and chosen CDN
4.4 Error Handling and Repeatability One of strapi_lift’s major advantages is that migrations can be repeated and resumed at any time:
- Interruptions or errors during import are no problem. On restart, the tool checks if each entry already exists (using contentful_id) and updates as needed.
- This also means that after improving your data model or exporting new content, you can simply run another import—existing objects are updated, new entries are added.
- Reset: With the reset command (bin/strapi_lift reset), you can cleanly remove imported data for a fresh start if needed.
4.5 Testing with Subsets To test migrations (e.g., just certain content types or specific entries), use:
- --content-types: Only import certain types, optionally with a limit (e.g., articles:10,categories)
- --ids: Only import specific entry IDs
- --skip: Start at a certain point if an import was interrupted or you want to skip entries This makes it easy to run targeted tests before a full migration.
4.6 Logging & Troubleshooting A key feature of strapi_lift is extensive logging—in both the console and as a structured log.jsonl file. For larger migrations, this is extremely helpful for quickly spotting and fixing errors.
Example log entry in log.jsonl:
1{
2 "host": "MacBook-Pro.localdomain",
3 "application": "Semantic Logger",
4 "timestamp": "2025-05-12T11:00:28.145047Z",
5 "level": "info",
6 "level_index": 2,
7 "pid": 20720,
8 "thread": "740",
9 "name": "Contentful::ImageGalleryLink",
10 "message": "Resolving",
11 "payload": {
12 "id": "4qEQIjYBkImcY4EaQSe8MK"
13 }
14}
Finding errors/warnings with jq: To search for errors (level: "error") or warnings (level: "warn"), use the jq tool:
cat log.jsonl | jq 'select(.level == "error")'
cat log.jsonl | jq 'select(.level == "warn")'
Pro tip: When an error occurs, you’ll often see an error about an asset or link first. But the actual problem might be in one of the preceding entries—for example, a missing, broken, or wrongly linked asset. Search for the relevant object (by contentful_id or asset ID) in the log and check the previous lines to trace the real cause.
This lets you track down exactly which entry caused the problem and fix it before restarting the import.
5. Common Pitfalls and Best Practices
Migration from Contentful to Strapi with strapi_lift rarely works perfectly on the first try—especially for larger or older projects. Here are the main pitfalls and proven tips from experience:
5.1 Common Errors
- Missing contentful_id field in Strapi: The tool can’t map old and new entries without this field. Make sure every relevant content type has it before importing.
- Non-matching field names and structures: Strapi and Contentful handle fields differently (required fields, data types, relations, etc.). Adjust your content types in Strapi to be compatible with Contentful data—especially for complex or nested fields.
- Missing or broken assets: Contentful exports sometimes include assets with 0 bytes if something failed during download. Use the fix-assets command from strapi_lift to automatically re-download missing or broken assets.
- Complex or multi-level relations: Relations in Contentful are only stored as links. All linked entries must be imported at least once for relations to be resolved in Strapi. Watch out for “loops” (circular relations) to avoid endless cycles.
5.2 Tips for a Smooth Migration
- Start with small subsets: Use --content-types and --ids to test with single content types or selected entries first. This lets you catch mapping issues early and saves debugging time.
- Plan for multiple imports: Since you can repeat the import, fix mapping issues or add missing logic and simply re-import—without risking duplicates.
- Make use of logs: Always check log.jsonl after each run. Tools like jq help you quickly find errors (e.g., cat log.jsonl | jq 'select(.level == "error")').
- Use the reset function: If things go wrong or you need to adjust the data model, use the reset command (bin/strapi_lift reset) to start clean.
- Be patient with large datasets: Importing (especially uploading assets to a new CDN) takes longer with large asset counts. Allow plenty of time and disk space.
5.3 Lessons Learned
- It’s worth spending more time preparing and mapping rather than fixing things by hand later.
- Migrations almost always work better iteratively, not as a big bang: Start with a test run, then roll out step by step.
- Align Contentful and Strapi structures as much as possible beforehand—it saves hassle and simplifies representer logic.
33 6. Conclusion and Lessons Learned Migrating from Contentful to Strapi isn’t plug-and-play—but with the right preparation and the help of strapi_lift, it’s absolutely doable for technical teams. Especially for projects looking to save costs, gain independence, and control over their content, making the switch is a solid choice.
As of mid-2025, Contentful seems to be pushing many old plans onto much more expensive ones for existing customers. In those cases, Strapi is a much more affordable alternative—even if you pay for Cloudinary on top.
strapi_lift was developed by Tim Adler, and the project would benefit from more polish. For example, reducing configuration overhead (perhaps via a central YML file instead of multiple Ruby classes) would be great.
Hope your migration from Contentful to Strapi goes smoothly.
If you’d like to contribute or need help with your Contentful to Strapi migration, feel free to get in touch!
LinkedIn Toadle Mastodon X/Twitter
7. FAQ & Troubleshooting
Q: I’m getting errors during import. How do I find the cause? A: Check log.jsonl. All errors are logged in a structured format. Use jq or grep to filter for errors.
Q: The import was interrupted—do I have to start from scratch? A: No. Just restart the import. The tool checks each entry by contentful_id and only updates as needed.
Q: Some relations or assets are missing after migration. What can I do? A: Make sure all linked content types exist in Strapi and that your representers/models are configured correctly. You can repair 0-byte assets with fix-assets.
Q: How can I migrate only certain content types or specific entries? A: Use the --content-types and --ids options to target what you want to import.
Q: Anything special to consider for single content types? A: In the intermediate model, set the single_content_type! flag. This way, even Contentful types with multiple entries can be mapped as single types in Strapi.
Q: How do I deal with very large amounts of assets? A: Allow for sufficient time and disk space, and check after import to confirm all files are present in your target CDN.
Q: Is strapi_lift suitable for every Contentful project? A: The tool is very flexible, but complex edge cases or heavy localization are (still) not fully automated. Some technical customization is always required.