Skip to content

CMS Migration

CMS migration refers to the process of moving content and functionality from one content management system (CMS) to another. This is often also referred to as replatforming. CMS migration can be a complex undertaking — thorough preparation, a solid understanding, and using the right tools are key to success. This concept provides an overview of everything relevant from a developer’s perspective.

Refer to the following hands-on developer tutorials to learn how to migrate to Storyblok from any of the platforms listed below. It is recommended to first read this developer concept on CMS migration to obtain a comprehensive understanding of the topic.


Before diving into platform-specific migration guides, consider the following phases when planning a CMS migration project. As every such project has unique requirements, use these steps as a template to plan and customize a project-specific roadmap.

Phase

Key Activities

Deliverables / Checks

Discovery

Inventory of all content, URLs, assets, embedded media, custom fields, relationships, redirects, and custom integrations

Content inventory spreadsheet, gap analysis, complexity map

Design

Design Storyblok content model (components, blocks, fields, references); define mapping rules from old model to new

Mapping document, sample mapping code

Export

Export content (for example, via APIs, database dumps, content export tools, web scraping), including all fields, assets, and metadata

Raw content export, backup copies

Transform

Clean, convert, transform content (for example, HTML → rich text, fix broken links, normalize data shapes)

Transformation scripts, test outputs

Migrate Assets

Upload images, files, media into Storyblok assets; maintain mapping from old URL → new asset references

Asset upload scripts, mapping table

Migrate Content

Use the Management API (or CLI or importer tools) to create stories, folders, references, and set correct metadata

Imported stories in Storyblok, correct folder structure

Validate

Compare source vs target content, check links, media rendering, relationships, metadata, missing or invalid content

QA reports, issue lists, fixes

SEO

Set up redirects, canonical URLs, and preserve SEO metadata

Redirect rules, canonical tags, sitemap updates

Rollout

Deploy migrated content to production, monitor, and rollback if needed

Live site with migrated content, monitoring dashboards

Post-migration

Fix edge cases, migrate remaining content, and train editors

Final content coverage, stabilization, and post-mortem documentation

First, determine the content structure and content model in the legacy CMS. Evaluate whether there are types of content that follow a repeating, predictable pattern. For example, articles or press releases typically have a consistent content model, in that they are composed of the same set of fields, whereas marketing landing pages may differ significantly from one another.

Content that follows a consistent content model is straightforward to migrate programmatically. Content with an inconsistent model may sometimes be more efficiently migrated manually.

Next, evaluate carefully whether the current content model actually enables or impedes content editors. Ideally, interviews with content editors and marketers are conducted to get an understanding of their pain points.

Finally, learn how content modeling works in Storyblok by reading the blocks, fields, datasources, references, and internationalization developer concepts. It is highly advisable to create a proof-of-concept to establish the ideal content model before proceeding to migrate any actual content. It is suggested not to rush this process. Instead, refine the approach in multiple iterations and involve key stakeholders from design, marketing, and other relevant teams.

Before performing any migration operations to Storyblok, consider how to extract the source content from the legacy CMS. When migrating from another headless CMS, utilize its REST or GraphQL API endpoints to retrieve all relevant data in JSON format. Some monolithic CMSs also provide such API endpoints.

If the legacy CMS does not provide any API endpoints, it may be possible to access the database directly and export all relevant data. Alternatively, extract data directly from the HTML pages of the production environment using a crawler library such as CheerioCrawler.

If the source data cannot be obtained in JSON format (for example, it may be in XML or CSV format), use a parser to convert it to JSON before proceeding with any mapping or uploading operations.

Consider the following best-practice recommendations for crafting migration scripts.

Start by importing only a limited number of entries and validating the result before proceeding to migrate everything.

Adopt an iterative, test-driven approach:

  1. Start with a small slice. Pick a simple content type (such as blog articles, for example) or a subset of content (for example, the latest 10) and run through the complete extraction → transformation → import → validation cycle.
  2. Validate thoroughly. Compare source and migrated content by checking text fields, formatting, assets, internal links, missing references, and more.
  3. Adapt mapping and transform logic continuously. Adjust scripts and mapping rules as edge cases are identified.
  4. Add more complexity gradually. Migrate more content types, nested structures, relationships, old archives.
  5. Use migration of incremental sets (for example, by date ranges or folders).
  6. Leverage idempotency. Ideally, import scripts can detect if a story already exists (by source ID or slug) and update rather than duplicate. See the next section for further details.
  7. Use content migrations in Storyblok. If schema changes are required post-import (for example, renaming or splitting a field), perform migrations using the Storyblok CLI.
  8. Create automated tests or content diff tools to highlight differences or missing items.

Conceptualize scripts in an idempotent and incremental fashion, avoiding content duplication when run several times. A safe import typically follows this order:

  1. Create a folder structure and hierarchy in Storyblok.
  2. Create referenced entities (authors, categories, tags, etc.) first.
  3. Migrate and upload assets and store a mapping table (old URL → new asset reference).
  4. Create or update main stories (pages, blog posts, etc.).
  5. Establish references and internal links after.
  6. Optionally, run content migrations or patches post-import.

Create a configuration file that contains mapping rules and transformations. For each field present in the source data, define the following:

  • source field key
  • target component and field name
  • transformation logic to be applied (for example, convert HTML to Storyblok RichText, sanitize HTML tags, split delimited values into array, and similar operations)
  • asset mapping (if the field references a file)
  • fallbacks and default values for missing data

Work in a development or staging environment. Decide whether stories should be published automatically upon import or left in draft for manual review. For additional control, leverage Storyblok’s workflow stages to label content that has been programmatically migrated for review before publication. If the legacy CMS has version history and drafts, optionally import only the latest published version. Additionally, capture and preserve timestamps (such as created_at, published_at) in the new stories.

It is also recommended to communicate and implement a content freeze, which will prevent further content updates in the legacy CMS while preparing the new CMS for production.

Migrating assets refers to the process of moving images, videos, audio files, text files, and other files from one platform to another. Consider the following scenarios.

  • Assets are stored in the legacy CMS: Assets that are stored and managed in the legacy CMS have to be migrated. Such assets could either be migrated to Storyblok’s built-in digital asset management (DAM) or an external DAM.
  • Assets are stored in an external DAM: Assets that are already stored and managed in an external DAM may be integrated with Storyblok.

Programmatic asset migration refers to the process of parsing the source data, mapping it to the target content model, downloading the assets via direct URLs, and uploading them to Storyblok.

Use the CLI's asset push command to push local stories from a third-party CMS to Storyblok. The CLI automatically maps ID-based references from legacy CMS entries to the new IDs generated by Storyblok, maintaining relational integrity.

  1. Map external content to match the JSON structure of a Storyblok asset object.

    {
    "id": 12345, // legacy ID
    "meta_data": {},
    }
  2. Place assets and accompanying asset sidecar files in the CLI directory for the target space. This defaults to .storyblok/assets/<target-space-id>/{asset}_{ID}.{ext} and .storyblok/assets/<target-space-id>/{asset}_{ID}.json.

  3. Run storyblok assets push --space <target-space-id>. Optionally, use the --dry-run flag to validate the results first.


Alternatively, write a custom-tailored script to programmatically migrate assets. In a Node.js script, use Storyblok’s JavaScript client to access Storyblok’s Management API to create or update migrated content in a space.

Consider the minimal code example below.

import 'dotenv/config';
import StoryblokClient from 'storyblok-js-client';
import fs from 'fs';
import { writeFile } from 'fs/promises';
import FormData from 'form-data';
import sourceData from './data.json' with { type: 'json' };
if (!process.env.STORYBLOK_SPACE_ID) {
throw new Error('Missing STORYBLOK_SPACE_ID environment variable.');
}
const spaceId = process.env.STORYBLOK_SPACE_ID;
if (!process.env.STORYBLOK_PERSONAL_ACCESS_TOKEN) {
throw new Error(
'Missing STORYBLOK_PERSONAL_ACCESS_TOKEN environment variable.',
);
}
const StoryblokMAPI = new StoryblokClient({
oauthToken: process.env.STORYBLOK_PERSONAL_ACCESS_TOKEN,
});
/* Programmatically generate a list of redirects from old asset URLs to new ones */
const redirects = [];
/* Map fields from source assets to the format expected by Storyblok */
const mapFields = (asset) => {
return {
filename: asset.filename,
title: asset.title || '',
alt: asset.description || '',
};
};
/* Download asset from URL to local assets folder */
const downloadAsset = async (url, filename) => {
const filepath = `./assets/${filename}`;
if (fs.existsSync(filepath)) {
console.log(`Skipped download (already exists): ${filepath}`);
return;
}
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to download: ${response.statusText}`);
}
const buffer = await response.arrayBuffer();
await writeFile(filepath, Buffer.from(buffer));
console.log(`Downloaded: ${filepath}`);
};
/* Upload asset to Storyblok */
const uploadAsset = async (assetObject) => {
try {
const newAssetEntry = await StoryblokMAPI.post(
`/spaces/${spaceId}/assets/`,
assetObject,
);
const signedResponse = newAssetEntry.data;
const form = new FormData();
for (const key in signedResponse.fields) {
form.append(key, signedResponse.fields[key]);
}
form.append(
'file',
fs.createReadStream(`./assets/${assetObject.filename}`),
);
form.submit(signedResponse.post_url);
const { data } = await StoryblokMAPI.get(
`spaces/${spaceId}/assets/${signedResponse.id}/finish_upload`,
);
const url = data.filename;
const cleanUrl = url.replace('s3.amazonaws.com/', '');
console.log(`Created asset with URL ${cleanUrl}.`);
return cleanUrl;
} catch (err) {
console.log(err);
return false;
}
};
25 collapsed lines
/* Programmatically download assets, map fields, and upload to Storyblok */
const migrateAssets = async () => {
for (const asset of sourceData.data.assets) {
if (!asset?.url || asset.url === '') continue;
if (!asset?.filename || asset.filename === '') continue;
await downloadAsset(asset.url, asset.filename);
const assetObject = mapFields(asset);
const newUrl = await uploadAsset(assetObject);
redirects.push([asset.url, newUrl]);
}
};
const run = async () => {
await migrateAssets();
console.log('Successfully migrated assets.');
console.log('Generated redirects:');
redirects.map((r) => {
console.log(`${r[0]} > ${r[1]}`);
});
};
run();

While programmatically downloading assets, some asset URLs may be broken because the original asset has been moved or deleted. In the context of programmatic asset migration, it is recommended to generate a report of missing assets. For such cases, skip the asset creation step in Storyblok and proceed to the next asset.

Uploading duplicate assets wastes storage space and creates clutter. Consider creating a content hash of the file's binary (not the file name) and keeping track of the hashes in the migration script. For every instance, check whether the hash is already in use. In that case, the asset already exists and has been successfully uploaded to Storyblok. Therefore, it should not be uploaded again.

The Storyblok CLI automatically creates a manifest.jsonl for this purpose.

Legacy CMSs often store resized versions of the same asset. It is important to understand that this is neither necessary nor desirable in Storyblok. Using the Image Service, relevant image sizes can be generated programmatically in the presentation layer.

Identify the pattern used by the legacy CMS for resized versions, such as banner-600x400.jpg or banner-800x600.jpg. In the migration script, use regular expressions or available metadata to exclusively use assets with the highest available resolution.

When migrating a large quantity of assets, it is advisable to compartmentalize them into logical chunks. For example, migrate all author profile pictures, hero images, and other relevant content, evaluating each migration task individually. Consider using asset folders to group assets thematically and logically.

Storyblok’s built-in DAM supports a variety of MIME types. See the asset concept for further reference. However, particularly when dealing with long-form, high-resolution video expected to generate a lot of traffic, consider a dedicated video hosting platform instead.

Note that migrating assets to Storyblok will change all asset URLs. Consider setting up redirects for highly frequented or even all asset URLs to avoid 404 errors once the legacy CMS has been taken out of production.

Alternatively, consider keeping some assets in their existing location to preserve SEO performance. Include a field for old asset URLs in the content model to place these URLs directly, or set up CDN rules to serve such assets from their current URLs while being stored in Storyblok. Setting up a custom asset domain for Storyblok-hosted assets provides additional control.

Migrating content refers to the process of moving content entries, such as articles, landing pages, or author profiles, from the legacy CMS to Storyblok.

Programmatic migration refers to the process of parsing the source content, mapping it to the target content model, and uploading it to Storyblok.

Use the CLI's stories push command to push local stories from a third-party CMS to Storyblok. The CLI automatically maps ID-based references from legacy CMS entries to the new IDs generated by Storyblok, maintaining relational integrity.

  1. Map external content to match the JSON structure of a Storyblok story object.

    {
    "id": 12345, // legacy ID
    "uuid": 12345, // legacy ID
    "name": "Article extracted from a legacy CMS",
    "slug": "legacy-cms-article",
    "content": {
    "author": 54321, // legacy author ID
    "content": "Article content extracted from a legacy CMS",
    "component": "article"
    }
    }
  2. Place generated story files in the CLI directory for the target space. This defaults to .storyblok/stories/<target-space-id>/{slug}_{ID}.json.

  3. Ensure that all required components exist locally by running storyblok components pull.

  4. Run storyblok stories push --space <target-space-id>. Optionally, use the --dry-run flag to validate the results first.

Alternatively, write a custom-tailored script to programmatically migrate content. In a Node.js script, use Storyblok’s JavaScript client to access Storyblok’s Management API to create or update migrated content in a space.

Consider the minimal code example below.

import 'dotenv/config';
import StoryblokClient from 'storyblok-js-client';
import sourceData from './data.json' with { type: 'json' };
if (!process.env.STORYBLOK_SPACE_ID) {
throw new Error('Missing STORYBLOK_SPACE_ID environment variable.');
}
const spaceId = process.env.STORYBLOK_SPACE_ID;
if (!process.env.STORYBLOK_PERSONAL_ACCESS_TOKEN) {
throw new Error(
'Missing STORYBLOK_PERSONAL_ACCESS_TOKEN environment variable.',
);
}
if (!process.env.STORYBLOK_SPACE_ACCESS_TOKEN) {
throw new Error('Missing STORYBLOK_SPACE_ACCESS_TOKEN environment variable.');
}
const StoryblokMAPI = new StoryblokClient({
oauthToken: process.env.STORYBLOK_PERSONAL_ACCESS_TOKEN,
});
const StoryblokCAPI = new StoryblokClient({
accessToken: process.env.STORYBLOK_SPACE_ACCESS_TOKEN,
});
/* Map fields from source data to the new content model defined in Storyblok */
const mapFields = (page, existingStoryObject) => {
const body = [];
if (page.content.length) {
page.content.forEach((contentSection) => {
if (contentSection.type === 'hero') {
body.push({
component: 'hero',
headline: contentSection?.headline || '',
});
}
});
}
const content = {
component: 'page',
body,
};
const newStoryObject = {
story: {
name: page?.title || '',
created_at: page?.created_at || '',
updated_at: page?.updated_at || '',
content,
slug: page?.slug || '',
},
publish: 1,
};
/* If the story already exists, only update the content */
if (existingStoryObject) {
const updatedStoryObject = {
story: {
...existingStoryObject.story,
content,
},
publish: 1,
};
return updatedStoryObject;
}
return newStoryObject;
};
54 collapsed lines
/* Update an existing story */
const updateStory = async (storyObject) => {
try {
console.log(storyObject.story.id);
await StoryblokMAPI.put(
`/spaces/${spaceId}/stories/${storyObject.story.id}`,
storyObject,
);
console.log(`Updated story with slug ${storyObject.story.slug}.`);
} catch (err) {
console.log(err);
return false;
}
};
/* Create a new story */
const createStory = async (storyObject) => {
try {
await StoryblokMAPI.post(`/spaces/${spaceId}/stories`, storyObject);
console.log(`Created story with slug ${storyObject.story.slug}.`);
} catch (err) {
console.log(err);
return false;
}
};
/* Migrate content from source data to Storyblok by looping through the content entries and creating new stories or updating existing ones */
const migrateStories = async () => {
for (const page of sourceData.data.pages) {
if (!page?.slug || page.slug === '') continue;
const existingStory = await StoryblokCAPI.get(`cdn/stories/${page.slug}`, {
version: 'published',
}).catch(() => null);
const existingStoryID = existingStory?.data.story.id || null;
const existingStoryResponse = await StoryblokMAPI.get(
`/spaces/${spaceId}/stories/${existingStoryID}`,
).catch(() => null);
const storyObject = mapFields(page, existingStoryResponse?.data);
if (existingStoryResponse !== null) {
console.log(`Story with slug ${page.slug} already exists.`);
await updateStory(storyObject);
} else {
await createStory(storyObject);
}
}
};
migrateStories();

Mapping CMS-relational references, such as authors, categories, tags, internal links, and related posts, is a common challenge when migrating to a new CMS. See the references and fields developer concepts to learn more about how referenced content works with references, single option, multi option, and link fields in Storyblok. The following order of actions is recommended:

  1. Migrate referenced assets to Storyblok. For example, consider an author content type that has an asset field named profile. Migrate all assets before migrating any authors.

  2. Migrate referenced content to Storyblok. For example, consider an article content type that has a references field named author. Migrate all authors before migrating any articles.

  3. Create a mapping lookup table to map each legacy entity identifier (for stories and assets) to its new Storyblok ID.

  4. When migrating content with referenced entities, use the mapping lookup table to match the referenced legacy entity identifier to the new Storyblok UUID and populate the reference field accordingly.

Consider the minimal code example below.

import 'dotenv/config';
import StoryblokClient from 'storyblok-js-client';
import sourceData from './data.json' with { type: 'json' };
import mappingData from './mapping.json' with { type: 'json' };
// ...
/* Map fields from source data to the new content model defined in Storyblok */
const mapFields = (page, existingStoryObject) => {
const body = [];
if (page.content.length) {
page.content.forEach((contentSection) => {
if (contentSection.type === 'hero') {
body.push({
component: 'hero',
headline: contentSection?.headline || '',
});
}
});
}
const author = [
mappingData.authors.find((a) => a.old_id === page.author.id)?.new_id,
];
const content = {
component: 'page',
body,
author,
};
// ...
migrateStories();

Storyblok offers three distinct approaches to content internationalization and localization: field-level translation, folder-level translation, and space-level translation.

Study the primary use cases and benefits of each approach, and carefully consider which strategy best suits the project requirements, keeping long-term goals and scalability in mind.

Storyblok provides granular control over roles and permissions, which work synergistically with workflows and workflow stages.

Similar to content, migrating roles, permissions, and workflows should be approached from the perspective of seeing this as an opportunity to assess what has been working well and what has not. Therefore, it is suggested not to try to replicate everything entirely, but adapt it based on the changes to the content model and the possibilities of Storyblok.

Migrating roles, permissions, and workflows should typically not be migrated in a programmatic manner. Instead, it is advisable to carefully implement the initial setup and continuously adapt and refine based on changing requirements throughout the new website’s lifecycle. However, roles, permissions, and workflows should not be treated as an afterthought, but as an integral element of any migration endeavor that also has the potential to inform and shape the content model.

Storyblok can be extended via field plugins, tool plugins, and space plugins. Any existing integration with an API-based third-party platform can be replicated in Storyblok.

For integrations with an external DAM, PIM, or comparable system, create a field plugin to be used on the story level. For integrations with third-party vendors affecting the entire website or space, such as an analytics platform, create a space plugin. Additionally, some integrations may be required exclusively in the presentation layer and do not necessitate a CMS plugin.

Refer to the Storyblok App Directory for an overview of all official, ready-to-use integrations.

Run the following checks to ensure that the migration has been performed successfully:

  • Compare the sitemaps of the production website and the old website to ensure that no content has gone missing. Remember to take into account desirable and planned structural changes – the sitemaps may not be expected to match perfectly. Account for changed URLs via redirects.
  • Use Storyblok’s Broken Links Checker to find and resolve any broken links within the Storyblok space.
  • Validate the component schema, ensuring that there are no deviations.
  • Run complete diagnostics on the production website (for example, using Unlighthouse) to identify performance and accessibility issues, as well as other problems such as missing assets.

Once the migrated website has entered production, the following is highly recommended:

  • Keep running complete diagnostics on the production website to identify potentially unforeseen problems as soon as possible.
  • Consider both internal and external feedback.
  • Especially in the first weeks and months post-launch, support content creators and ensure that they use the new content model as intended (ideally, they should have already undergone training at this point).
  • Keep the old website functional for the first few days post-launch, allowing for a seamless transition back in case of major issues with the production website and preventing any downtime.

The new push commands make it straightforward to push local stories and assets from third-party CMS data to Storyblok, and the CLI handles mapping ID-based references from legacy CMS data structures to the IDs Storyblok generates.

Example: WordPress migration

The following example demonstrates how to quickly migrate content from WordPress to Storyblok using the CLI.

  1. Map your external content to the Storyblok story JSON structure.
    {
      "id": 12345, // source WordPress ID
      "uuid": 12345, // same as the source ID
      "name": "Hello WordPress",
      "slug": "hello-wordpress",
      "content": {
        "component": "page",
        "headline": "Hello from WP",
        "cta": {
          "fieldtype": "multilink",
          "linktype": "story",
          "id": 12346, // linked WP post ID (same as that story's uuid)
          "url": ""
        }
      }
    }
    
  2. Place generated story files under .storyblok/stories/TARGET_SPACE/{slug}_{ID}.json.
  3. Ensure required components exist locally: storyblok components pull --space TARGET_SPACE.
  4. Run storyblok stories push --space TARGET_SPACE (use -dry-run first to validate).

This approach allows you to programmatically import legacy data while maintaining relational integrity between your stories.

Was this page helpful?

What went wrong?

This site uses reCAPTCHA and Google's Privacy Policy (opens in a new window) . Terms of Service (opens in a new window) apply.