Migrating AEM Content Structures to a Flexible Content Model in Storyblok
Storyblok is the first headless CMS that works for developers & marketers alike.
Migrating from Adobe Experience Manager (AEM) to Storyblok involves restructuring your content, including assets and content references, to fit your new CMS.
In this guide, we’ll compare the content modeling capabilities of AEM and Storyblok, explain the transformations needed to adapt your schema, and provide practical examples for migrating two content types: a blog entry and a landing page.
For a comprehensive overview of the migration process, see our migration core documentation.
Key differences between content modeling in AEM and Storyblok
Before refactoring your schema, it is crucial to understand where AEM's and Storyblok’s modeling differ.
Schema flexibility
- AEM: Uses Content Fragment Models with predefined field types (text, rich text, references, etc.). While you can nest models, doing so often becomes cumbersome for authors, so teams typically keep models relatively flat and rigid.
- Storyblok: Uses a component-based schema designed for composition and deep nesting. Components (blocks) can be reused and stacked, making it easy to evolve your schema over time without heavy structural changes.
Because Storyblok supports deep nesting of components within a single entry, you will often restructure AEM’s distributed network of fragments and components into more hierarchical, self-contained Storyblok entries. However, Storyblok still allows you to create standalone content items for reuse, which you can reference from multiple entries when needed. See the blocks concept for further information.
Nestability and hierarchy
- AEM: Supports nested fragments via Fragment Reference fields, but the structure is more static and defined in your content fragment model.
- Storyblok: Supports a Blocks field, allowing for the arbitrary nesting of components (blocks within blocks). You can restrict which block types are allowed and limit the number.
Content structures in Storyblok can more naturally reflect hierarchical or modular page layouts (hero, grid, CTA, etc.).
References between entries
- AEM: Uses Content Reference and Fragment Reference types in its content model, enabling the referencing of other content fragments.
- Storyblok: Provides a References field. When querying via API, you can use
resolve_relationsto fetch referenced stories completely.
AEM reference fields should be mapped to Storyblok reference fields.
Rich text and embedded content
- AEM: Its multi-line field supports rich text, including inline references to other content fragments or assets. When you use the GraphQL API with
jsonreturn type, you get a structured JSON representing the rich text tree. - Storyblok: The Richtext field stores data in a JSON structure based on TipTap. It supports inline blocks/components, links, and assets.
Converting content requires transforming AEM rich text JSON into Storyblok’s rich text schema, handling inline references, and possibly uploading embedded assets.
Link resolution
In AEM:
- Rich text links: In rich text fields, authors can embed links to internal pages, content fragments, or assets. When queried through GraphQL, AEM returns the rich text as
jsontogether with a_referencesarray that contains metadata about each inline reference (targets, types, and paths). - Dedicated link fields: AEM does not provide a unified “link” field type. Links are typically stored as either a string path (e.g.,
/content/site/page) or a reference to another resource. How links are modeled varies by project and component definition.
In Storyblok:
- Rich text links: Storyblok stores rich text as structured JSON (TipTap / ProseMirror). Inline links appear as dedicated nodes with
linktype,id,url, and other attributes. Link resolution can be controlled via API parameters such asresolve_links. - Link field: Storyblok provides a dedicated Link field that supports internal stories, external URLs, emails, assets, anchors, and custom link attributes out of the box. This provides a consistent model for links outside rich text.
During migration, you must handle both kinds of links:
- Inline rich text links → convert AEM’s
_referencesentries into Storyblok’s link nodes. - Non-rich-text links → map AEM’s string paths or reference fields into Storyblok’s Link field format.
This ensures that Storyblok’s API can automatically resolve internal links and that editors gain a unified, predictable link model.
Asset references
- AEM: Typically stores assets (images, files) in its DAM, and content fragment models may reference DAM assets via Content Reference fields.
- Storyblok: Has a built‑in Asset Library. The asset or multi-asset field allows you to upload, reference, and manage files.
Assets need to be uploaded and migrated to Storyblok first. Then, AEM asset references can be mapped to existing Storyblok asset objects.
Localization, validation, and custom fields
- Localization: In AEM, fragment models can support localization. In Storyblok, you have 2 different approaches for localization.
- Validation / Custom Fields: AEM allows validation on fragments and reference constraints. Storyblok`s component schema supports configuration such as required fields, default values, validation, and file types for assets.
You’ll need to re-evaluate your validation and localization rules when rebuilding your schema.
Transforming your content model: key refactoring steps
Based on the differences above, migrating from AEM to Storyblok typically involves several kinds of transformations.
Restructuring content
What you need to do:
- Audit your AEM fragment models (or page components) to identify which fragments are reused, and which are only used in a specific context.
- Decide whether to represent a fragment as:
- a nestable block in Storyblok (if it's only used inside a parent), or
- a content type (if it’s shared, reused, or referenced independently).
- Define Storyblok component schema using Blocks fields to reflect nested structure.
Transformation examples:
If HeroFragment is used only inside pages in AEM, you can convert it into a nestable block (e.g., hero_block) in Storyblok. This block can then be included inside any content type component via the Blocks field, rather than creating a standalone entry. There’s also the possibility of using it as a content type if it’s set as a universal block.
If ArticlePage is a content type in AEM, it should be migrated as a content type in Storyblok with fields that match the ones in AEM. If the page contains multiple structured sections, such as hero, body text, quotes, or image galleries, you can include a Blocks field in the content type to store nestable components like hero_block, text_block, quote_block, or gallery_block.
Refactoring references
What you need to do:
- Map AEM Fragment References or Content References to Storyblok References fields.
- Create or migrate content in Storyblok for referenced fragments (authors, categories, etc.).
- Maintain a mapping between AEM reference identifiers (paths, IDs) and Storyblok story UUIDs or slugs.
Transformation example:
AEM fragment reference (simplified):
{
"author": {
"_path": "/content/fragments/author/jane-doe"
}
} After migration in Storyblok (assuming author is a References field):
{
"author": ["uuid-of-jane-doe-story"]
} Migrating and resolving links
For inline links (rich text):
- Extract AEM inline link metadata from the
_referencesarray. - Convert linked nodes into Storyblok rich text link nodes (
type: "link"withattrs.linktype,id, orurl).
For links stored as fields:
- AEM typically stores non-rich-text links as string paths (e.g.,
/content/site/page) or as component-specific reference properties. - Convert these into Storyblok’s Link field format, choosing the correct
linktype(story,url,email,asset, etc.).
Example 1: Inline links (rich text)
{
"json": {
"content": [
{
"nodeType": "paragraph",
"content": [
{
"nodeType": "text",
"value": "Go to "
},
{
"nodeType": "link",
"data": {
"target": {
"_path": "/content/fragments/other-article"
},
"id": "other-uuid"
},
"content": [
{
"nodeType": "text",
"value": "Other Article"
}
]
}
]
}
]
},
"_references": [
{
"id": "other-uuid",
"_path": "/content/fragments/other-article"
}
]
} {
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "Go to "
},
{
"text": "other page",
"type": "text",
"marks": [
{
"type": "link",
"attrs": {
"href": "/other-article",
"uuid": "76f7b106-da7f-48ab-9fa3-733a5be6fddb",
"anchor": null,
"target": "_self",
"linktype": "story"
}
}
]
}
]
}
]
} Example 2: Link fields (non–rich text)
AEM link field example
In AEM, links outside rich text are often simple strings:
{
"ctaLink": "/content/site/contact-us"
} Or a component-specific reference object:
{
"ctaLink": {
"_path": "/content/site/contact-us",
"id": "uuid-of-contact-us-page",
"type": "cq:Page"
}
} Storyblok link field after transformation
{
"cta_link": {
"linktype": "story",
"fieldtype": "multilink",
"id": "uuid-of-contact-us-page"
}
} If the link is external in AEM (e.g., stored as a plain URL):
{
"ctaLink": "<https://example.com/contact-us>"
} Then, if the URL matches the production URL or is a relative link, the object in Storyblok becomes:
{
"cta_link": {
"linktype": "story",
"fieldtype": "multilink",
"id": "9911a777-8449-4181-a4b8-7bc3e61e093f"
}
} In case the domain of the URL doesn’t match the production domain, it’s clearly an external link, and the structure becomes:
{
"cta_link": {
"linktype": "url",
"fieldtype": "multilink",
"url": "<https://example.com/contact-us>"
}
} Handling asset references
What you need to do:
- Extract all asset references from AEM (e.g., via GraphQL or Sling Model Export).
- Upload those assets into Storyblok’s Asset Library.
- Keep track of the mapping between AEM asset paths / IDs and Storyblok asset IDs.
- In your Storyblok schema, use the asset (or multi-asset) field type.
- When you import content, set asset fields to the proper Storyblok asset objects.
Transformation example:
{
"image": {
"_path": "/content/dam/images/blog/hero.jpg"
}
} {
"image": {
"id": 12345,
"filename": "hero.jpg",
"short_filename": "hero.jpg",
"content_type": "image/jpeg",
"alt": "Hero image",
"copyright": "",
"title": "Hero Image",
"focus": null,
"fieldtype": "asset",
"url": "<https://a.storyblok.com/f/12345/hero.jpg>",
"meta_data": {
"size": "1778x1334"
},
"is_external_url": false
}
} Converting rich text
What you need to do:
- Query AEM with GraphQL, requesting the multi-line rich text field in
jsonformat. - Parse the AEM rich text JSON tree, handling
nodeType, inlinereferencenodes, etc. converting it into HTML. - Convert to Storyblok richtext JSON. You can use the
htmlToStoryblokRichtextmethod of the @storyblok/richtext package (or custom logic) to help. - Map inline references and assets appropriately during transformation.
Localization, validation, and conditional fields
What you need to do:
- Localization: If your AEM fragments are localized, mark fields as translatable in the Storyblok component schema.
- Validation: recreate validation rules (required fields, regex, etc.) in Storyblok schema.
- Conditional Fields: if your AEM components had conditional logic (fields shown only in certain contexts), replicate this using Storyblok’s conditional field logic (available depending on your plan).
Practical example 1: Blog entry AEM Content Fragment Model
Assume in AEM you have a Content Fragment Model named BlogEntryModel defined with these fields:
summary(single-line text)image(Content Reference to DAM)content(multi-line rich text)author(Fragment Reference to anAuthorModel)
This is typical in enterprise AEM setups.
Storyblok schema definition
- A
blog_entrycontent type - An
authorcontent type - A component schema that includes:
summary(textarea)image(asset)content(rich text)author(References to anauthorstory)
JSON transformation
{
"data": {
"blogEntry": {
"item": {
"summary": "A short summary …",
"image": {
"id": "image-uuid",
"filename": "hero.jpg",
"mimeType": "image/jpeg",
"_path": "/content/dam/images/blog/hero.jpg",
"url": "<https://aem-instance/content/dam/images/blog/hero.jpg>"
},
"content": {
"json": [
{
"nodeType": "paragraph",
"content": [
{
"nodeType": "text",
"value": "Welcome to my blog post…"
}
]
}
],
"_references": []
},
"author": {
"id": "author-uuid",
"_path": "/content/fragments/author/jane-doe"
}
}
}
}
} {
"name": "My Blog Entry",
"slug": "my-blog-entry",
"content": {
"component": "blog_entry",
"summary": "A short summary …",
"image": {
"id": 12345,
"filename": "hero.jpg",
"short_filename": "hero.jpg",
"content_type": "image/jpeg",
"alt": "Hero Image",
"copyright": "",
"title": "Hero Image",
"focus": null,
"fieldtype": "asset",
"url": "<https://a.storyblok.com/f/12345/hero.jpg>"
},
"content": {
"type": "doc",
"content": [
{
"type": "paragraph",
"attrs": {
"textAlign": null
},
"content": [
{
"text": "Discover more below.",
"type": "text"
}
]
}
]
},
"author": [
"uuid-of-jane-doe-story"
]
}
} Transformation Explanation
- The AEM
imagefield from DAM is re-mapped to a full Storyblok asset object, not just an ID. Read more about programmatic asset migration to Storyblok. - The rich text
contentis converted to Storyblok’s richtext JSON schema (type: "doc"with nestedchildren), compatible with Storyblok’s editor. Read more about programmatic content migration to Storyblok. - The
authorreference is turned into an array of Storyblok story UUIDs, because Storyblok’s References field type stores IDs of related stories.
Practical example 2: Landing page
AEM Component / Model
Suppose your AEM page model has the following fields:
heroTitle(string)heroDescription(rich text / multi-line)heroBackground(asset/reference)ctaTitle(string)ctaDescription(string)ctaLink(link)
You may have defined this in AEM’s component system or via Content Fragment Models.
Storyblok schema definition
- Create a
pagecontent type with ablocksfield calledbody. - Define two nestable components:
- hero_block
title(text)description(rich text)background_image(asset)
- cta_block
title(text)description(text)button(link)
- hero_block
JSON transformation
{
"heroTitle": "Welcome to Our Site",
"heroDescription": {
"json": [
{
"nodeType": "paragraph",
"content": [
{
"nodeType": "text",
"value": "Discover more below."
}
]
}
],
"_references": []
},
"heroBackground": {
"id": "image-uuid",
"filename": "bg.jpg",
"mimeType": "image/jpeg",
"_path": "/content/dam/images/hero/bg.jpg",
"url": "<https://aem-instance/content/dam/images/hero/bg.jpg>"
},
"ctaTitle": "Get Started",
"ctaDescription": "Click below to begin.",
"ctaLink": {
"_path": "/content/site/en/signup",
"id": "target-uuid"
}
} {
"name": "My Landing Page",
"slug": "my-landing-page",
"content": {
"component": "page",
"body": [
{
"component": "hero_block",
"title": "Welcome to Our Site",
"description": {
"type": "doc",
"content": [
{
"type": "paragraph",
"attrs": {
"textAlign": null
},
"content": [
{
"text": "Discover more below.",
"type": "text"
}
]
}
]
},
"background_image": {
"id": 54321,
"filename": "bg.jpg",
"short_filename": "bg.jpg",
"content_type": "image/jpeg",
"alt": "Hero background",
"copyright": "",
"title": "Background Image",
"focus": null,
"fieldtype": "asset",
"url": "<https://a.storyblok.com/f/54321/bg.jpg>"
}
},
{
"component": "cta_block",
"title": "Get Started",
"description": "Click below to begin.",
"button": {
"linktype": "url",
"url": "/signup"
}
}
]
}
} Transformation Explanation
- The AEM flat page model is converted into a block-based page in Storyblok, improving modularity.
- AEM’s rich text
heroDescriptionis transformed into Storyblok's rich text JSON. - The
heroBackgroundasset is mapped to a Storyblok asset object. - The CTA link becomes a Storyblok Link field (
multilink/url) withlinktypeandurl.
Challenges and best practices
When migrating, here are some challenges you’re likely to face, and how to handle them.
- Circular References: If your AEM fragments reference each other in a cycle, migrating them may require a two-pass import: first create stories without references, then update references once all target stories exist.Use Storyblok’s
resolve_relationsparameter to validate and fetch relationships after import. - Inline References in Rich Text: AEM’s
_referencesin rich text may include different types (images, fragments). You need to map them correctly into Storyblok link nodes.Build a migration script / transformer that walks through the AEM rich text AST and rebuilds the TipTap JSON structure accordingly. - Asset Migration: Migrating many assets can be time-consuming; use automation (via Storyblok Management API) to upload assets in bulk and maintain a mapping table from AEM asset paths to Storyblok asset IDs.Be careful with metadata (alt text, title), preserve and map it when possible.
- Validation Differences: Storyblok’s out-of-the-box validation may differ from AEM’s validation setup. Reconcile what validation matters after migration.If certain fields were required in AEM, ensure the corresponding Storyblok schema enforces them (or not) as needed.
- Testing and QA: After migrating, fetch content from Storyblok using the Content Delivery API (
resolve_relations,resolve_links) and render it in your frontend.Validate that rich text, links, and media behave as expected, and run content previews to spot any discrepancies. - Start small and iterate: Start with a few content types, refine your transformation scripts through testing, and gradually scale up. With this approach, your Storyblok schema will be cleaner, more modular, and better suited to the evolving needs of your project. Once you are done with this part of the migration, you can focus your efforts on migrating other parts of your project, like roles, permissions and workflows.