Migrating from @storyblok/richtext v4 to v5
Storyblok is the first headless CMS that works for developers & marketers alike.
@storyblok/richtext v5 introduces a new rendering architecture focused on smaller bundles, framework-first rendering, and a simplified public API.
This release contains breaking changes and requires updates to existing implementations.
Overview
The biggest change in v5 is that rich text rendering is no longer built around a Tiptap-based public rendering runtime.
Instead, v5 provides a lightweight rendering engine and utility helpers, while framework SDKs handle rendering using native framework primitives.
Benefits include:
- Smaller bundle sizes
- Improved framework integration
- Simpler rendering APIs
- Better TypeScript support
- Consistent customization patterns across SDKs
Although many concepts remain familiar, v5 introduces a completely new rendering architecture. Before upgrading, review your existing custom renderers, framework integrations, and TypeScript imports.
Breaking changes
richTextResolver has been removed
The previous renderer creation pattern has been replaced with a single rendering entry point:
import { renderRichText } from '@storyblok/richtext'; Before (v4)
const resolver = richTextResolver(options);
const html = resolver.render(document); After (v5)
const html = renderRichText(document, options); Rendering is now static
v5 uses a lightweight static rendering engine.
If you previously relied on resolver instances, migrate those implementations to use renderRichText() directly.
Before
import Heading from "@tiptap/extension-heading";
import { richTextResolver } from "@storyblok/richtext";
const CustomHeading = Heading.extend({
renderHTML({ node, HTMLAttributes }) {
const level = node.attrs.level;
return [`h${level}`, { class: `heading-${level}`, ...HTMLAttributes }, 0];
},
});
const resolver = richTextResolver({
tiptapExtensions: { heading: CustomHeading },
});
resolver.render(document); After
const html = renderRichText(document, {
renderers: {
heading: ({ attrs, children }) =>
`<h${attrs?.level} data-type="custom-heading">${children}</h${attrs?.level}>`,
},
}); Available renderer properties include:
attrschildrencontentcontext
The available properties depend on the node type being rendered.
Recursive rendering
Custom renderers can recursively render content using renderRichText.
const options: SbRichTextRenderContext = {
renderers: {
paragraph: ({ content, context }) =>
`<p class="text-xl">${renderRichText(content, context)}</p>`,
},
}; Table utilities
A table helper utility splitTableRows is now available to customize table rendering.
const options: SbRichTextRenderContext = {
renderers: {
table: ({ content, context }) => {
const { headerRows, bodyRows } = splitTableRows(content);
return `
<table class="custom-table">
${
headerRows
? `<thead>${renderRichText(headerRows, context)}</thead>`
: ''
}
<tbody>${renderRichText(bodyRows, context)}</tbody>
</table>
`;
},
},
}; Image optimization
Image URL generation is now available through the public utility buildStoryblokImage.
import { buildStoryblokImage } from '@storyblok/richtext';
const { src } = buildStoryblokImage(assetUrl, {
width: 800,
height: 600,
}); Type updates
Several public types have been renamed, simplified, or promoted to first-class exports.
Available public types
import type {
SbRichTextDoc,
SbRichTextNode,
SbRichTextMark,
SbRichTextRenderContext,
SbRichTextRendererMap,
SbRichTextProps,
SbRichTextImageOptions,
} from '@storyblok/richtext'; Review your existing type imports and update them to the new public exports where applicable.
React
The React SDK now provides full customization support through framework-native React components while maintaining a familiar developer experience.
Before
import { Mark } from "@tiptap/core";
import { asTag } from "@storyblok/react";
import Link from "next/link";
const CustomLink = Mark.create({
name: "link",
renderHTML({ HTMLAttributes }) {
if (HTMLAttributes.linktype === "story") {
return [asTag(Link), { href: HTMLAttributes.href }, 0];
}
return [
"a",
{
href: HTMLAttributes.href,
target: HTMLAttributes.target,
},
0,
];
},
});
function App() {
return (
<StoryblokRichText
doc={blok.richtext_field}
tiptapExtensions={{ link: CustomLink }}
/>
);
} After
import {
StoryblokRichText,
type SbReactRichTextComponentMap,
type SbReactRichTextProps,
} from "@storyblok/react";
import Link from "next/link";
export function CustomLink({
children,
attrs,
}: SbReactRichTextProps<'link'>) {
return (
<Link
data-type="custom-link"
href={attrs?.href}
>
{children}
</Link>
);
}
const components: SbReactRichTextComponentMap = {
heading: CustomHeading,
paragraph: CustomParagraph,
link: CustomLink,
bold: ({ children }) => (
<b data-type="custom-bold">{children}</b>
),
};
function App() {
return (
<StoryblokRichText
document={blok.richtext_field}
components={components}
/>
);
} Register custom node and mark renderers through the components prop.
All rich text types and utilities, including buildStoryblokImage and splitTableRows, are re-exported from the React SDK and can be used when building custom renderers.
If you do not need any customization, StoryblokRichText works out of the box with sensible defaults. Embedded Storyblok blocks are also automatically resolved using StoryblokComponent.
useStoryblokRichText
For more advanced use cases, the React SDK also provides the useStoryblokRichText hook:
import { useStoryblokRichText } from '@storyblok/react';
function App() {
const render = useStoryblokRichText({
optimizeImage: true,
components: {
paragraph: ({ children }) => (
<p style={{ color: 'blue' }}>
{children}
</p>
),
},
});
return render(blok.richtext_field);
}
export default App; More examples and detailed usage information are available in the React SDK documentation.
Vue
Like the React SDK, the Vue SDK now follows the same framework-first rendering approach and provides full customization support.
Before
<script setup>
import { Mark } from '@tiptap/core';
import { asTag } from '@storyblok/vue';
import { RouterLink } from 'vue-router';
const CustomLink = Mark.create({
name: 'link',
renderHTML({ HTMLAttributes }) {
if (HTMLAttributes.linktype === 'story') {
return [asTag(RouterLink), { to: HTMLAttributes.href }, 0];
}
return ['a', {
href: HTMLAttributes.href,
target: HTMLAttributes.target,
}, 0];
},
});
</script>
<template>
<StoryblokRichText
:doc="blok.richtext_field"
:tiptap-extensions="{ link: CustomLink }"
/>
</template> After
<script setup lang="ts">
import {
StoryblokRichText,
useStoryblok,
type SbVueRichTextComponentMap,
} from '@storyblok/vue';
import { h } from 'vue';
import CustomLink from '../components/richtext/CustomLink.vue';
const components: SbVueRichTextComponentMap = {
heading: ({ content, attrs }) =>
h('h1', {
'data-type': 'custom-heading',
'data-level': attrs?.level,
}, [
h(StoryblokRichText, {
document: content,
components,
}),
]),// Recursive rendering
paragraph: ({ content }, { slots }) =>
h('p', {
'data-type': 'custom-paragraph',
}, slots.default?.()), // You can use slots too
link: CustomLink, // Custom component
};
</script>
<template>
<StoryblokRichText
:document="story?.content.richText"
:components="components"
/>
</template> <script setup lang="ts">
import type { SbVueRichTextProps } from '@storyblok/vue';
import { RouterLink } from 'vue-router';
const props = defineProps<SbVueRichTextProps['link']>();
</script>
<template>
<RouterLink
v-if="props.attrs?.linktype === 'story'"
:to="props.attrs.href"
>
<slot />
</RouterLink>
<a
v-else
:href="props.attrs?.href"
:target="props.attrs?.target"
>
<slot />
</a>
</template> useStoryblokRichText
The Vue SDK also provides a useStoryblokRichText composable for advanced rendering scenarios:
<script setup lang="ts">
import { useStoryblokRichText, type SbVueRichTextComponentMap } from '@storyblok/vue';
import CustomLink from '../components/richtext/CustomLink.vue';
const components: SbVueRichTextComponentMap = {
link: CustomLink,
};
const render = useStoryblokRichText({ components });
const RichText = () => render(story.value?.content.richText);
</script>
<template>
<RichText />
</template> More examples and detailed usage information are available in the Vue SDK documentation.
Astro
v5 introduces an official Astro rich text component.
Like React and Vue, Astro supports full customization of all supported nodes and marks. You can use Astro components, slots, and recursive rendering patterns when building custom renderers.
---
import StoryblokRichText from '@storyblok/astro/StoryblokRichText.astro';
import type {
SbAstroRichTextComponentMap,
} from '@storyblok/astro';
const components: SbAstroRichTextComponentMap = {
heading: CustomHeading,
};
---
<StoryblokRichText
document={input}
components={components}
/> ---
import type { SbAstroRichTextProps } from '@storyblok/astro';
type Props = SbAstroRichTextProps<'heading'>;
const { attrs } = Astro.props;
const Tag = `h${attrs?.level}`;
---
<Tag
data-type="custom-heading"
data-level={attrs?.level}
>
<slot />
</Tag> More examples and detailed usage information are available in the Astro SDK documentation.
Svelte
v5 introduces an official Svelte rich text component that follows the same customization model as the other framework SDKs.
<script lang="ts">
import { StoryblokRichText } from '@storyblok/svelte';
import type {
SbSvelteRichTextComponentMap,
} from '@storyblok/svelte';
const components: SbSvelteRichTextComponentMap = {
heading: CustomHeading,
paragraph: CustomParagraph,
link: CustomLink,
};
</script>
<StoryblokRichText
document={input}
components={components}
/> <script lang="ts">
import type { SbSvelteRichTextProps } from '@storyblok/svelte';
const props: SbSvelteRichTextProps<'heading'> = $props();
const tag = $derived(`h${props.attrs?.level ?? 1}`);
</script>
<svelte:element
this={tag}
data-type="custom-heading"
data-level={props.attrs?.level}
>
{@render props.children?.()}
</svelte:element> More examples and detailed usage information are available in the Svelte SDK documentation.
HTML parser
The HTML parser remains available and now includes improved typing and parser customization support.
import { htmlToStoryblokRichtext } from '@storyblok/richtext/html-parser'; No migration is required for basic usage.
Most basic HTML parsing implementations require no changes. However, custom parser implementations will need to be updated to align with the new parser APIs and TypeScript typings.
Custom parsers
import { htmlToStoryblokRichtext, mapToAttribute } from '@storyblok/richtext/html-parser';
// Example 1
const html = `<div class="content">
<p>
This is a simple paragraph with
<strong>bold text</strong>,
<em>italic text</em>, and a
<a href="https://example.com">link to Example</a>.
</p>
<p>
You can also place a link in between a sentence, like
<a href="https://astro.build">Astro</a>,
which is a great framework for building fast websites.
</p>
<div class="card">
<h2>Getting Started</h2>
<p>
This card contains a heading and a short description to demonstrate
nested content structure inside a container element.
</p>
</div>
</div>`
const document = htmlToStoryblokRichtext(html, {
parsers: {
blok: {
parseHTML: () => [{ tag: 'div.card' }],
attributeParsers: {
body: (el) => {
const title = el.querySelector('h2')?.textContent ?? '';
const description = el.querySelector('p')?.textContent ?? '';
return [{
title,
description,
component: 'card',
}];
},
},
},
}
});
//Example 2
const html = `<img
src="https://a.storyblok.com/f/12345/800x600/image.jpg"
alt="A beautiful landscape"
title="Mountain View"
data-source="Unsplash"
data-copyright="© 2024 Photographer Name"
>`;
const result = htmlToStoryblokRichtext(html, {
parsers: {
image: {
attributeParsers: {
source: mapToAttribute('data-source'),
copyright: mapToAttribute('data-copyright'),
meta_data: el => ({
alt: mapToAttribute('alt')(el),
title: el.getAttribute('title'),
source: el.getAttribute('data-source'),
copyright: el.getAttribute('data-copyright'),
}),
},
},
},
}); Markdown to Storyblok rich text
The markdownToStoryblokRichtext utility can be used to convert Markdown content into a Storyblok rich text document. Similar to htmlToStoryblokRichtext, it supports the parsers option, allowing the parsing behavior and node attributes to be customized.
const richtextDoc = markdownToStoryblokRichtext(markdown, {
parsers: {
heading: {
attributeParsers: {
level: (el: HTMLElement) => 5,
},
},
},
}); For more details, examples, and usage patterns, refer to the rich text documentation.
Migration checklist
Before upgrading, verify the following:
- Replace
richTextResolverwithrenderRichText - Update custom renderers to the new renderer API
- Review type imports
- Migrate framework integrations to the new rich text components where applicable
- Review custom HTML parser implementations
- Run your test suite and verify rendered output
Need help?
If you encounter issues during migration, open an issue on GitHub or reach out through our community channels. We're happy to help and welcome feedback as developers upgrade to v5.