How AI-ready is your team? Take our AI Readiness Assessment to find out how you score — now live.

Introducing @storyblok/richtext v4

Developers
Alex Jover Morales

Storyblok is the first headless CMS that works for developers & marketers alike.

We're excited to announce @storyblok/richtext v4, a major release that replaces the hand-written resolver system with Tiptap extensions as the sole engine for both parsing and rendering. One extension definition now handles both directions: HTML to JSON and JSON back to HTML. This eliminates an entire class of sync bugs while opening a powerful customization model built on the standard tiptap API.

This is a breaking change, mostly affecting custom overrides: the resolvers option and its associated types have been removed. If you use custom resolvers, you'll need to migrate them to tiptapExtensions. If you only call richTextResolver() with no custom resolvers, the upgrade is a version bump with no code changes.

What changed? One engine, both directions

Previously, @storyblok/richtext maintained two completely separate systems that had to stay in sync:

v3v4
Parsing (HTML/Markdown to JSON)Custom parser with tag-to-resolver mapTiptap extensions with parseHTML via @tiptap/html
Rendering (JSON to HTML/Framework)Hand-written map of 28+ resolver functionsTiptap extensions with renderHTML
Adding a new node typeUpdate two files independentlyDefine one extension
CustomizationStoryblok-specific resolvers callback APIStandard tiptap .extend() / .configure()

In v3, the HTML parser and the renderer had to independently agree on every node type's name, attributes, and behavior. Adding or modifying a node required changes in two places, and inconsistencies between the two systems caused bugs where malformed JSON from the parser could crash the editor.

In v4, each extension defines both parseHTML (how to read) and renderHTML (how to write). One source of truth for every node and mark type.

If you don't use custom resolvers, the basic API is unchanged:

import { richTextResolver } from '@storyblok/richtext'

const { render } = richTextResolver({
  optimizeImages: true,
  keyedResolvers: true,
})

const html = render(doc)

renderFn, textFn, optimizeImages, and keyedResolvers all work exactly as before.

Accurate HTML parsing with tiptap

The HTML parser has been rebuilt on top of @tiptap/html's generateJSON, the same function the tiptap editor uses internally. This means parsing now produces JSON that is fully consistent with what the Storyblok rich text editor generates, fixing edge cases around nested links, inline marks, and whitespace handling that the previous custom parser got wrong.

Because parsing and rendering use the same extensions, a round-trip is guaranteed to be consistent: parse HTML to JSON, render it back, and the output matches.

The Markdown parser now converts Markdown to HTML first, then passes it through the same tiptap-based HTML parser:

import { markdownToStoryblokRichtext } from '@storyblok/richtext/markdown-parser'

const json = markdownToStoryblokRichtext('# Hello\\n\\nWorld')

Customization

Custom extensions for parsing and rendering

@storyblok/richtext v4 replaces the resolvers option with tiptapExtensions. A single extension handles both parsing and rendering:

import { Node } from '@tiptap/core'
import { richTextResolver } from '@storyblok/richtext'
import { htmlToStoryblokRichtext } from '@storyblok/richtext/html-parser'

// Define once, works in both directions
const Callout = Node.create({
  name: 'callout',
  group: 'block',
  content: 'inline*',
  addAttributes() {
    return {
      type: {
        default: 'info',
        parseHTML: (el) => el.getAttribute('data-type') || 'info',
      },
    }
  },
  parseHTML() {
    return [{ tag: 'div[data-callout]' }]
  },
  renderHTML({ HTMLAttributes }) {
    return ['div', { 'data-callout': '', 'data-type': HTMLAttributes.type, class: `callout-${HTMLAttributes.type}` }, 0]
  },
})

const extensions = { callout: Callout }

// Parse: HTML to rich text JSON
const json = htmlToStoryblokRichtext(
  '<div data-callout data-type="warning">Watch out!</div>',
  { tiptapExtensions: extensions },
)

// Render: rich text JSON to HTML
const html = richTextResolver({
  tiptapExtensions: extensions,
}).render(json)

This is the standard Tiptap extension API. If you've worked with tiptap or ProseMirror, you already know it. Extensions support addAttributes() for declarative attribute handling, .extend() to inherit from built-in extensions, and .configure() to set runtime options.

Override built-in nodes and marks

You can replace any built-in extension by passing one with the same key. Use .extend() on a tiptap extension to inherit its parseHTML logic and only override renderHTML:

import Heading from '@tiptap/extension-heading'
import { richTextResolver } from '@storyblok/richtext'

const CustomHeading = Heading.extend({
  renderHTML({ node, HTMLAttributes }) {
    const { level, ...rest } = HTMLAttributes
    return [`h${node.attrs.level}`, { class: `heading-${node.attrs.level}`, ...rest }, 0]
  },
})

const { render } = richTextResolver({
  tiptapExtensions: { heading: CustomHeading },
})

This works for any node or mark: headings, links, images, code blocks, bold, italic, and all others. The extension you pass replaces the built-in one that key, so both parsing and rendering use your custom logic.

For details on rendering framework components (Vue/React) inside extensions with asTag, blok component rendering via ComponentBlok.configure, and the new segmentStoryblokRichText utility, see the @storyblok/richtext package reference.

Migration Guide

Step 1: Update your packages

Since @storyblok/richtext v4 is a new major version, all framework SDKs that depend on it also ship new major versions.

If you use @storyblok/richtext directly:

npm install @storyblok/richtext@latest

If you use a framework SDK, update to the corresponding new major:

PackagePreviousNew
@storyblok/richtextv3.xv4.x
@storyblok/jsv4.xv5.x
@storyblok/vuev9.xv10.x
@storyblok/nuxtv9.xv10.x
@storyblok/reactv5.xv6.x
@storyblok/astrov7.xv8.x
@storyblok/sveltev5.xv6.x

# Example for Vue
npm install @storyblok/vue@latest

# Example for React
npm install @storyblok/react@latest
Hint:

Hint: If you don't use custom resolvers in your code, the upgrade is just a version bump. The framework SDKs handle the internal changes automatically. Skip to Step 4.

Step 2: Replace resolvers with tiptapExtensions

The resolvers option, StoryblokRichTextNodeResolver<T>, StoryblokRichTextResolvers<T>, and StoryblokRichTextContext<T> have been removed. All customization goes through tiptapExtensions.

See before/after examples for framework SDKs later in this document.

Link override example
// BEFORE (v3)
import { MarkTypes } from '@storyblok/richtext'

const resolvers = {
  [MarkTypes.LINK]: (node) => {
    return node.attrs?.linktype === 'story'
      ? `<a href="${node.attrs.href}" class="internal">${node.text}</a>`
      : `<a href="${node.attrs?.href}">${node.text}</a>`
  },
}
richTextResolver({ resolvers })

// AFTER (v4)
import { Mark } from '@tiptap/core'

const CustomLink = Mark.create({
  name: 'link',
  renderHTML({ HTMLAttributes }) {
    if (HTMLAttributes.linktype === 'story') {
      return ['a', { href: HTMLAttributes.href, class: 'internal' }, 0]
    }
    return ['a', { href: HTMLAttributes.href }, 0]
  },
})
richTextResolver({ tiptapExtensions: { link: CustomLink } })
Heading override example
// BEFORE (v3)
import { BlockTypes } from '@storyblok/richtext'

const resolvers = {
  [BlockTypes.HEADING]: (node) => {
    const level = node.attrs?.level || 1
    return `<h${level} class="custom">${node.children}</h${level}>`
  },
}
richTextResolver({ resolvers })

// AFTER (v4)
import Heading from '@tiptap/extension-heading'

const CustomHeading = Heading.extend({
  renderHTML({ node, HTMLAttributes }) {
    return [`h${node.attrs.level}`, { class: 'custom', ...HTMLAttributes }, 0]
  },
})
richTextResolver({ tiptapExtensions: { heading: CustomHeading } })

Step 3: Replace context.originalResolvers / context.mergedResolvers (only if used)

In v3, custom resolvers could delegate to the default implementation via the context object. In v4, use .extend() on a tiptap extension to inherit default behavior and only override what you need:

// BEFORE (v3)
resolvers: {
  [BlockTypes.HEADING]: (node, ctx) => {
    // Delegate to default, then wrap
    return ctx.originalResolvers.get(BlockTypes.HEADING)?.(node, ctx)
  },
}

// AFTER (v4)
import Heading from '@tiptap/extension-heading'

const CustomHeading = Heading.extend({
  renderHTML({ node, HTMLAttributes }) {
    // parseHTML is inherited, renderHTML is overridden
    return [`h${node.attrs.level}`, { class: 'custom', ...HTMLAttributes }, 0]
  },
})
richTextResolver({ tiptapExtensions: { heading: CustomHeading } })

Step 4: Install @tiptap/core if extending

The built-in tiptap extensions are bundled with @storyblok/richtext, so you don't need any extra installs for default usage. If you write custom extensions or use .extend() on built-in ones, install @tiptap/core and the specific extension packages you need:

npm install @tiptap/core
# Only if extending specific built-in extensions:
npm install @tiptap/extension-heading @tiptap/extension-link

Step 5: Remove deleted type imports

// BEFORE
import type {
  StoryblokRichTextNodeResolver,
  StoryblokRichTextResolvers,
  StoryblokRichTextContext,
} from '@storyblok/richtext'

// AFTER: These types no longer exist. Remove the imports.

Before (v3):

<script setup lang="ts">
import { h, type VNode } from 'vue'
import {
  MarkTypes,
  StoryblokRichText,
  type StoryblokRichTextNode,
  useStoryblok,
} from '@storyblok/vue'
import { RouterLink } from 'vue-router'

const story = await useStoryblok('richtext', { version: 'draft' })

const resolvers = {
  [MarkTypes.LINK]: (node: StoryblokRichTextNode<VNode>) => {
    return node.attrs?.linktype === 'story'
      ? h(RouterLink, { to: node.attrs?.href, target: node.attrs?.target }, node.text)
      : h('a', { href: node.attrs?.href, target: node.attrs?.target }, node.text)
  },
}
</script>

<template>
  <StoryblokRichText :doc="story.content.richText" :resolvers="resolvers" />
</template>

After (v4):

<script setup lang="ts">
import { Mark } from '@tiptap/core'
import { asTag, StoryblokRichText, useStoryblok } from '@storyblok/vue'
import { RouterLink } from 'vue-router'

const story = await useStoryblok('richtext', { version: 'draft' })

const CustomLink = Mark.create({
  name: 'link',
  renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, string> }) {
    if (HTMLAttributes.linktype === 'story') {
      return [asTag(RouterLink), { to: HTMLAttributes.href }, 0]
    }
    return ['a', { href: HTMLAttributes.href, target: HTMLAttributes.target }, 0]
  },
})
</script>

<template>
  <StoryblokRichText
    :doc="story.content.richText"
    :tiptap-extensions="{ link: CustomLink }"
  />
</template>

Before (v3):

import React from 'react'
import {
  MarkTypes, StoryblokRichText,
  type StoryblokRichTextNode, useStoryblok,
} from '@storyblok/react'
import { Link } from 'react-router'

function RichtextPage() {
  const story = useStoryblok('richtext', { version: 'draft' })
  const resolvers = {
    [MarkTypes.LINK]: (node: StoryblokRichTextNode<React.ReactElement>) => {
      if (node.attrs?.linktype === 'story') {
        return <Link to={node.attrs.href}>{node.text}</Link>
      }
      return <a href={node.attrs?.href} target={node.attrs?.target}>{node.text}</a>
    },
  }
  return <StoryblokRichText doc={story.content.richText} resolvers={resolvers} />
}

After (v4):

import React from 'react'
import { Mark } from '@tiptap/core'
import { asTag, StoryblokRichText, useStoryblok } from '@storyblok/react'
import { Link } from 'react-router'

const CustomLink = Mark.create({
  name: 'link',
  renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, string> }) {
    if (HTMLAttributes.linktype === 'story') {
      return [asTag(Link), { to: HTMLAttributes.href }, 0]
    }
    return ['a', { href: HTMLAttributes.href, target: HTMLAttributes.target }, 0]
  },
})

function RichtextPage() {
  const story = useStoryblok('richtext', { version: 'draft' })
  return <StoryblokRichText doc={story.content.richText} tiptapExtensions={{ link: CustomLink }} />
}

Compatibility

@storyblok/richtext@storyblok/js@storyblok/vue@storyblok/nuxt@storyblok/react@storyblok/astro@storyblok/svelte
v4.xv5.xv10.xv10.xv6.xv8.xv6.x
v3.xv4.xv9.xv9.xv5.xv7.xv5.x

The framework SDKs ship new major versions because their dependency on @storyblok/richtext is a major bump. The SDK code itself changes only to replace resolvers with the auto-configured blok tiptapExtension. If you don't use custom resolvers, your app code stays the same.

Need Help?