This is a preview of the Storyblok Website with Draft Content

From content strategy to code, JoyConf brings the Storyblok community together - Register Now!

Migrating a blog site from Contentful to Storyblok

Migrating from one headless CMS to another can seem daunting. Handling all of the transformations for each content type can seem like a lengthy and frustrating process, but with the right tools and direction, you can “blok” out those second thoughts, even when staring down content types packed with 30+ fields.

This article was created with the assumption that you have previous experience with Contentful and have already created a Storyblok account and space along with component definitions.

hint:

You can find the complete code for this example migration script in the Contentful exporter repo.

Objective of Article:

This guide will walk you through the process of migrating a simple blog from Contentful to Storyblok. We will go through the following steps:

  1. Export content from Contentful using the Contentful CLI
  2. Traverse the content within the exported JSON file
  3. Transform and map the content model from Contentful to Storyblok’s structure.
  4. Import content and assets into Storyblok using the Management API
  5. Customize the migration process to fit your needs

Why migrate to Storyblok?

Contentful is also a headless CMS but Storyblok is loved by developers. Don’t believe us? See this testimonial and explore why developers love Storyblok here.

What does Storyblok offer?

  • A component-driven system that enables straightforward querying, making it easier to target page entries.
  • Improved organization that doubles as your routing structure: use folders as root slugs to group related pages as child entries.
  • Greater flexibility with multiple SDKs that allow you to work within your favorite framework.
  • Improved authoring, editing, and developer experiences.

Understanding the migration process

Migrating content from Contentful to Storyblok requires several steps. In this section, we’ll break down the process and explain how the script handles each step. We’ll also highlight relevant snippets from the script to give you a clear understanding of how it works.

Things to keep in mind

When moving from Contentful to Storyblok, there are a few key things to keep in mind to make the migration as smooth as possible.

  • Storyblok offers multiple flexible options for content modeling: content type blocks, nested blocks, and universal blocks, while Contentful relies on a single flexible content type model. Because of this difference, you may need to evaluate how each Contentful model is structured and decide how it should be represented in Storyblok’s component-driven system.
  • Some things, like rich text fields and linked entries, don’t translate directly and will need custom logic to convert properly.
  • Storyblok also uses folder-based organization that directly ties into your website’s routing, which you will need to adapt in your solution. The hierarchical structure you define using folders determines the URL paths on your site. For example, if you create a folder named /blogs, all stories (or content entries) within that folder will be accessible under paths like /blogs/[slug].
  • If you’ve defined a way to have reusable values or defined fields with dropdown options in Contentful, those should be moved into Storyblok Datasources.
  • Webhook payloads in Storyblok and Contentful differ in options, so those should be revised before migrating.
  • If you’ve built any custom apps, they’ll need to be refactored to fit Storyblok’s plugin ecosystem.

What is being migrated?

For this example, we are migrating a blog space that includes three main content types: Article Page, Author, and Category. Below are the definitions of these content types for both Contentful and Storyblok.

  • Article Page
    • Contentful Model:
      • Internal Name: Short text, unique. Used to identify entries in the web app.
      • Slug: Short text, unique. Used for routing.
      • Title: Short text. Blog post title.
      • Body: Contentful Rich Text. Main blog content.
      • Author(s): Reference field. Linked authors.
      • Categories: Reference field. Linked categories.
    • Storyblok Model:
      • Name: Text, unique. Included in the content type block. This field is provided as part of the content type block..
      • Slug: Text, unique. This field is provided as part of the content type block.
      • Title: Text. Blog post title.
      • Body: Storyblok Richtext. Main blog content.
      • Author(s): Reference field. Linked authors.
      • Categories: Reference field. Linked categories.
  • Author
    • Contentful Model:
      • Internal Name: Short text, unique. Used internally.
      • Name: Short text, unique. Author’s display name.
      • Image: Contentful image asset.
      • Bio: Contentful Rich Text.
    • Storyblok Model:
      • Slug: Text, unique. This field is provided as part of a content type block.
      • Name: Short text, unique. Author’s display name.
      • Image: Storyblok image asset.
      • Bio: Storyblok Richtext.
      • External ID: Text, unique. Contentful sys.id. Used to map categories during migration.
  • Category
    • Contentful Model:
      • Name: Short text, unique. Category id and display name.
      • Slug: Short text, unique. Used for routing.
    • Storyblok Model:
      • Name: Text, unique. Category id and display name.
      • Slug: Text, unique. This field is provided as part of the content type block.
      • External ID: Text, unique. Contentful sys.id. Used to map categories during migration.
hint:
  • Slug fields are added explicitly to the Storyblok content type and universal blocks to handle routing.
  • Name fields are added explicitly to Storyblok components, no need to add an internal name field.
  • External ID is a custom field used in Storyblok that stores the corresponding Contentful entry’s sys.id. This is used when associating related content.

Exporting content from Contentful

The first step is to export the content from Contentful in order to map it and import it into Storyblok.

This script uses the space export command provided by Contentful’s CLI.

This command handles bulk exporting all space content without extra configuration, downloads export files and images for version tracking, and provides response with a structure that makes it easier for us to manage the migration steps. This approach allows us to then focus on transforming and uploading content to Storyblok.

Below is the configuration used for this example. This configuration saves the exported content to a JSON file and downloads all assets. This allows the downloaded versions of the assets to be used as the source files when uploading them to Storyblok. Doing this also allows for migration of embargoed assets. See the space export command documentation for further information

{
  "spaceId": "your_space_id",
  "environmentId": "desired_environment_id",
  "managementToken": "your_management_token",
  "deliveryToken": "your_delivery_api_token", // not needed for this example
  "exportDir": "content", // the directory we are targeting for this exported content
  "saveFile": true, // saves the exported content to a json file
  "contentFile": "contentfulExport.json", // the name of our exported file
  "includeDrafts": false,
  "includeArchived": false,
  "skipContentModel": false,
  "skipEditorInterfaces": true,
  "skipContent": false,
  "skipRoles": true, // not needed for this example
  "skipWebhooks": true, // not needed for this example
  "contentOnly": false,
  "downloadAssets": true, // we are downloading the assets so we can use those downloaded version as our source of truth - this handles migrating embargoed assets
  "host": "api.contentful.com",
  "rawProxy": false,
  "maxAllowedLimit": 1000, // number of entries per page request
  "errorLogFile": "errors/error.log", // path to log file
  "useVerboseRenderer": false
}

The output within the targeted directory should look something like this:

.
└── content/
    ├── images.ctfassets.net/
    │   └── jdbmcpkynci/
    │       ├── 4MNqnlSPp7sGtNaYLSmBBJ
    │       ├── 5VM0z9IWetCsE0g3xX3ct
    │       └── ...
    └── contentfulExport.json

The content folder includes a JSON file containing all exported content, along with all associated assets organized in a file structure that mirrors their original paths within Contentful.

This is what the structure of the contentfulExport.json file looks like:

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

Next, we have to read the data exported by Contentful. Here is how to accomplish this:

import fs from "fs/promises";
import path from "path";

async function loadExport() {
  const filePath = path.resolve(
    process.cwd(),
    "content",
    "contentfulExport.json"
  );
  const raw = await fs.readFile(filePath, "utf-8");
  return JSON.parse(raw);
}

This function reads and parses the contentfulExport.json file. The result is a parsed JavaScript object representing the exported Contentful data, ready to be used in our script.

Setting up storyblokConfig

The script depends on two configuration files: the contentfulConfig we discussed at the start of this tutorial, and the storyblokConfig.

The storyblokConfig relies on two variables to authenticate with Storyblok’s Management Api:

  • storyblokPersonalAccessToken: your personal access token
  • storyblokSpaceId: the target Storyblok space id

Set these variables in your storyblokConfig.json file:

{
  "storyblokPersonalAccessToken": "your_personal_access_token",
  "storyblokSpaceId": "your_space_id",
}

Setting up the Storyblok JavaScript Client

In order to create entries within Storyblok, we first have to connect to our Storyblok space using the Storyblok Management API client.

import StoryblokClient from "storyblok-js-client";
import storyblokConfig from "../../storyblokConfig.json" with { type: "json" };

export const Storyblok = new StoryblokClient({
  oauthToken: storyblokConfig.storyblokPersonalAccessToken,
});

This code initializes the Storyblok client using the personal access token provided in the storyblokConfig.json file. This is required to authenticate and interact with Storyblok’s Management API.

Transforming content to fit Storyblok’s structure

Once the content is loaded, it needs to be transformed into a format that Storyblok can understand. The script maps Contentful fields to Storyblok fields and prepares the data for import. Here’s an example of how the transformation can be achieved for the article page content type:

const rt = convertContentfulRT(entry.fields?.body?.[locale]); // transform the Contentful RichText to Storyblok RichText

return {
  name: entry.fields?.internalName?.[locale],
  slug: entry.fields?.slug?.[locale],
  content: {
    name:
      entry.fields?.title?.[locale] || entry.fields?.internalName?.[locale],
    body: rt,
    date: entry.fields?.date?.[locale],
    authors: authors.data.stories.map((author) => author.uuid),
    categories: categories.data.stories.map((category) => category.uuid),
    component: "article",
  },
  is_folder: false,
  parent_id: parentFolder, // ID of the parent folder "blogs"
  is_startpage: false,
};

This code returns an object formatted according to the structure expected by the article story in Storyblok, with values mapped from the corresponding fields in the Contentful entry.

hint:

Note: Notice that when migrating Contentful rich text fields to Storyblok, the rich text object needs to be transformed to match Storyblok’s format. You can see an example of that transformation here.

Also, observe that the reference fields are pointing to the entry IDs they relate to. At this point, those referenced entries may not have been created yet. As a result, entries need to be imported in an order that respects their dependencies to ensure all references resolve correctly.

hint:

Dependency: In this context, a dependency is any entry that is referenced by another entry. For example, since articles reference categories and authors, these entries are considered dependencies and need to be created first so that the relationships are properly established during import.

Importing Content into Storyblok sequentially using the Management API

Let’s refer to our main function:

  try {
    console.log("Starting migration...");
    console.log("Loading Data:");
    const data = await loadExport();
    console.log("✅ Data loaded successfully.", data);
    await importAssets(data.assets, "en-US");
    await importEntries(data.entries, data.assets);
  } catch (error) {
    console.error("Migration failed:", error);
  }

Here you can see that everything happens sequentially. First we load and parse the data, then we import assets, and finally import entries where the importEntries function also imports entries sequentially based on the content type id.

Importing Assets

The script begins by migrating the first dependency: the image assets.

export default async function importAssets(assets, locale = "en-US") {
  console.log("Importing assets...");
  for (const asset of assets ?? []) {
    const file = asset.fields.file?.[locale];
    if (file?.details && asset.fields.file?.[locale]?.fileName) {
        const response = await Storyblok.post(
          `spaces/${storyblokConfig.storyblokSpaceId}/assets/`,
          {
            filename: asset.fields.file?.[locale]?.fileName,
            size: `${file.details.image.width}x${file.details.image.height}`,
            title: asset.fields.title?.[locale] || asset.fields.file?.[locale]?.fileName,
            alt: asset.fields.description?.[locale] || "",
          }
      );
      if (file?.url) {
         const contentPath = path.resolve(
         process.cwd(),
         "content",
         file.url.replace(/^\/+/, "")
        );
        await finishUpload(response.data, contentPath);
      }
    }
  }
}

This code traverses the exported images and uploads them to Storyblok using the Management API following this documentation. You can use the same approach to import other types of assets as well.

Importing Entries

In order to import content sequentially, we must first create a function that groups our content. This ensures we can honor dependencies between content types during import. For example, since blogPage entries reference both category and author entries, we need to import categories and authors first in order for those references to exist when the blogPage entries are created.

function groupEntries(entries) {
  const grouped = { category: [], author: [], article: [] };

  for (const entry of entries ?? []) {
    const type = entry.sys.contentType.sys.id;
    if (type === "category") grouped.category.push(entry);
    else if (type === "author") grouped.author.push(entry);
    else if (type === "blogPage") grouped.article.push(entry);
  }

  return grouped;
}

This function groups entries into arrays based on the content type ID and returns an object with all of the grouped arrays.

The resulting grouped object is then used to sequentially import the entries:

async function createStoriesSequentially(groupedEntries, assets, locale) {
  // Create stories for each group sequentially
   for (const [group, entries] of Object.entries(groupedEntries)) {
    for (const entry of entries) {
      try {
        let mapped = null;
        switch (group) {
          case "author":
            mapped = await mapAuthorEntry(entry, assets, locale, authorFolder.data.story.id);
            break;
          case "category":
            mapped = mapCategoryEntry(entry, locale, categoryFolder.data.story.id);
            break;
          case "article":
            mapped = await mapArticleEntry(entry, locale, blogFolder.data.story.id);
            break;
        }

        if (mapped) {
          await Storyblok.post(
            `spaces/${storyblokConfig.storyblokSpaceId}/stories`,
            {
              story: mapped,
            }
          );
        }
      } 
    }
  }
}

This code processes grouped entries by type, calling the corresponding transform function in order to ensure dependencies are handled correctly.

Breaking down the steps of mapping a blog entry

The transform functions handle relating the associated entries.

async function mapArticleEntry(entry, locale, parentFolder) {
  const authors = await Storyblok.get(
    `spaces/${storyblokConfig.storyblokSpaceId}/stories`,
    {
      component: "author",
      filter_query: {
        externalId: { // the externalId field is a temporary field that stores the corresponding contentful sys.id
          in: entry.fields?.authors?.[locale]
            ?.map((author) => author.sys.id)
            .join(","),
        },
      },
    }
  );
  const categories = await Storyblok.get(
    `spaces/${storyblokConfig.storyblokSpaceId}/stories`,
    {
      component: "category",
      filter_query: {
        externalId: {
          in: entry.fields?.categories?.[locale]
            ?.map((author) => author.sys.id)
            .join(","),
        },
      },
    }
  );

  return {
    name: entry.fields?.internalName?.[locale],
    slug: entry.fields?.slug?.[locale],
    content: {
      name:
        entry.fields?.title?.[locale] || entry.fields?.internalName?.[locale],
      body: convertContentfulRT(entry.fields?.body?.[locale]),
      date: entry.fields?.date?.[locale],
      authors: authors.data.stories.map((author) => author.uuid),
      categories: categories.data.stories.map((category) => category.uuid),
      component: "article",
    },
    is_folder: false,
    parent_id: parentFolder, // ID of the parent folder "blogs"
    is_startpage: false,
  };
}

This code retrieves the corresponding referenced entries from Storyblok and returns an object representing an article in the structure Storyblok is expecting.

hint:

Notice that the related entries are being queried by externalId. This is a temporary field that was added to both the author and category component definitions within Storyblok. This field stores the sys.id of the corresponding entry from Contentful. By doing this, we can accurately map referenced entries in Storyblok to their original counterparts in Contentful during the migration process.

Customizing the script

Importing tags

The sample script does not handle migrating tags, but handling this will follow the same pattern.

const tags = data.tags; // data is the exported json from Contentful

for(const tag in tags){
	await Storyblok.post(`/spaces/${storyblokConfig.storyblokSpaceId}/tags`, {
	  "tag": {
	    "name": tag.name
	   }
   })
}

This code traverses the tags array exported by the Contentful space-export command and uploads them to Storyblok.

Handling locales

The sample script does not handle locales. Here is an example of how you can handle locales if both your Contentful content models and Storyblok components use field level localization:

const locales = data.locales.map((l) => l.code); // gets all locales codes from the exported json from Contentful

function createLocalizedField(fieldName, value){
	const localizedField = {};
	for(const locale in locales){
		localizedField[`${fieldName}__i18n__${locale}`] = value[locale];
	}
	return localizedField;
}

const localizedNameField = createLocalizedField("name", entry.fields?.name);

await Storyblok.post(`/spaces/${storyblokConfig.storyblokSpaceId}/stories`, {
	 "story": {
	   name: entry.fields?.name?.[locale],
	   slug: entry.fields?.slug?.[locale],
	   content: {
	     name: entry.fields?.name?.[your_default_locale],
	     ...localizedNameField,
	     component: "category",
       externalId: entry.sys.id,
	   },
	   is_folder: false,
	   parent_id: parentFolder,
	   is_startpage: false,
		"publish": 1
		}
	})

This code stores all of the exported locale codes into an array and creates a localized version of name for all available locales. This approach assumes that the same locales are enabled in both Contentful and Storyblok containing matching locale codes, and that both Contentful content models and Storyblok components use field level translation.

Batch processing

For large sites with thousands of entries, you may need to optimize the script to handle the migration in batches to account for the Storyblok Management API’s rate limits. Here’s how you can implement batch processing:

const entries = [...];
const batchSize = 50; // Process 50 entries at a time
const batches = [];

for (let i = 0; i < entries.length; i += batchSize) {
	batches.push(arr.slice(i, i + batchSize));
}

for (let i = 0; i < batches.length; i++) {
	await importEntries(batches);
	
  // Optional: Add a small delay between batches to avoid rate limiting
  if (i < batches.length - 1) {
    await new Promise(resolve => setTimeout(resolve, 1000)); // 1 second
  }
}

This code processes entries in batches of 50, improving performance.

Executing the script

Once the environment variables are configured, you can migrate content by first running the Contentful space export command and then running the migration script using.

contentful space export --config contentfulConfig.json && node src/scripts/migrate.js

Verifying the migration

After running the script, verify that the content was successfully migrated to Storyblok:

  1. Login to Storyblok and navigate to the Content section.
  2. Check the stories and assets to ensure they match the content exported from Contentful.

Clean up

After verifying the migration, it’s time to clean up our block definitions. We need to remove the temporary field externalId from our definitions. We can do this pragmatically with the Storyblok Management API.

await Storyblok.put(`/spaces/${storyblokConfig.storyblokSpaceId}/components/${componentId}`, {
	"component": {
		id: componentId,
		name: "category",
		display_name: "Category",
		schema: {
			name: {
				type: "text",
				translatable: true,
				description: "This field is used as the value of the category entry."
			}
		},
		"is_root": true, //set to true because this is a content type blok
		"is_nestable": false, // component is not nestable (insertable) in block field types
	}
})

This code redefines the category content type block without the externalId field. Since updating a component uses a PUT request, you will need to include all desired fields when updating the component to avoid unintentionally removing them. Repeat this step for other components defined with an externalId field. Another option is to delete the externalId field manually via the Storyblok web app.

Conclusion

In this guide, we’ve gone through the steps to migrate content from Contentful to Storyblok using a Node.js script and Storyblok’s Management API. Migrating between headless CMSs can seem complex, but with the right setup and a clear strategy, it becomes a manageable and, dare I say, joyful process.

This example focused on a simple blog structure, but the same approach can be scaled to handle larger, more complex websites. From transforming rich text to importing entries in the correct order, the core concepts stay the same. You can find the complete code used in this migration in our GitHub repository.

If you're migrating from Contentful, Storyblok’s component-based architecture and modern developer experience make it a powerful choice for building scalable content systems. We hope this guide was helpful.

Happy migrating and welcome to the world of Joyful Headless with Storyblok!

Further Resources

Looking to migrate from a different CMS or have any questions?

Migrating from Drupal to Storyblok

A step-by-step guide to moving content from Drupal into Storyblok's component-based system.

Read the guide

Storyblok Community

Ask questions, share experiences, or get help from the Storyblok team and other developers.

Join the community

Author

Daniel Mendoza

Daniel Mendoza

Daniel is a Senior Developer Relations Engineer at Storyblok who blends technical know-how with a genuine passion for developer experience. He loves sharing knowledge, creating helpful content, and being an active part of the developer community.