Skip to content

@storyblok/richtext (Version 4.x)

@storyblok/richtext is a custom resolver for Storyblok rich text in JavaScript applications.

Add the package to a project by running this command in the terminal:

Terminal window
npm install @storyblok/richtext@latest
import { richTextResolver } from "@storyblok/richtext";
const { render } = richTextResolver();
const html = render(doc);
document.querySelector("#app").innerHTML = `<div>${html}</div>`;

The recommended way to customize rendering is via the tiptapExtensions option. Extensions follow the Tiptap extension API and work for both parsing and rendering.

Create a custom node as demonstrated in the example below:

import { Node } from "@tiptap/core";
import { richTextResolver } from "@storyblok/richtext";
const Callout = Node.create({
name: "callout",
group: "block",
content: "inline*",
parseHTML() {
return [{ tag: "div[data-callout]" }];
},
renderHTML({ HTMLAttributes }) {
return ["div", { "data-callout": "", class: "callout", ...HTMLAttributes }, 0];
},
});
const { render } = richTextResolver({
tiptapExtensions: { callout: Callout },
});
const html = render(doc);

Use the same extension to parse HTML via htmlToStoryblokRichtext:

import { htmlToStoryblokRichtext } from "@storyblok/richtext/html-parser";
const json = htmlToStoryblokRichtext(html, {
tiptapExtensions: { callout: Callout },
});

Pass an extension with the same key as a built-in to replace it. 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 = node.attrs.level;
return [`h${level}`, { class: `heading-${level}`, ...HTMLAttributes }, 0];
},
});
const { render } = richTextResolver({
tiptapExtensions: { heading: CustomHeading },
});

Tiptap’s renderHTML returns a DOMOutputSpec, a nested array where the first element is a string tag name like 'a' or 'div'. In framework SDKs, it is usually preferable to render a framework component instead.

The asTag helper casts a component reference to support TypeScript while the rich text renderer handles it correctly at runtime:

import { Mark } from "@tiptap/core";
import { asTag } from "@storyblok/vue"; // also available from @storyblok/react, @storyblok/richtext
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];
},
});
const { render } = richTextResolver({
tiptapExtensions: { link: CustomLink },
});

Storyblok components are embedded in rich text as blok nodes. Each such node contains a body array with one or more components that need to be rendered as actual framework components, not as HTML tags.

@storyblok/richtext version 4.x handles this through ComponentBlok.configure({ renderComponent }), a callback on the blok extension that receives each blok and returns framework-native output. This is something usually done, if needed, at frontend framework level.

The segmentStoryblokRichText utility splits a rich text document into an ordered list of HTML segments and blok segments. This is useful when framework-specific rendering of blok nodes alongside raw HTML is needed:

import { segmentStoryblokRichText } from "@storyblok/richtext";
const segments = segmentStoryblokRichText(doc);
for (const segment of segments) {
if (segment.type === "html") {
renderHtml(segment.content);
}
if (segment.type === "blok") {
renderBlokComponent(segment.blok);
}
}

To optimize images, use the optimizeImages property on the richTextResolver options. For a full list of available options, refer to the Image Service documentation.

import { richTextResolver } from "@storyblok/richtext";
const html = richTextResolver({
optimizeImages: {
class: "my-peformant-image",
loading: "lazy",
width: 800,
height: 600,
srcset: [400, 800, 1200, 1600],
sizes: ["(max-width: 400px) 100vw", "50vw"],
filters: {
format: "webp",
quality: 10,
grayscale: true,
blur: 10,
brightness: 10,
},
},
}).render(doc);

The package includes a utility for converting Markdown content to Storyblok’s rich text format, which can be rendered via richTextResolver.

Supported markdown elements:

  • Text formatting: **bold**, *italic*, ~~strikethrough~~, `code`, [links](url)
  • Headings: # H1 through ###### H6
  • Lists: - unordered and 1. ordered lists with nesting
  • Code blocks: ```fenced``` and indented blocks
  • Blockquotes: > quoted text
  • Images: ![alt](src "title")
  • Links: [text](url) and [text](url "title")
  • Tables: Standard markdown table syntax
  • Horizontal rules: ---
  • Line breaks: (two spaces) for hard breaks
import { markdownToStoryblokRichtext } from "@storyblok/richtext/markdown-parser";
const markdown = `
# Main Heading
This is a **bold** paragraph with *italic* text.
- List item 1
- List item 2
> This is a blockquote
`;
const richtextDoc = markdownToStoryblokRichtext(markdown);
const html = richTextResolver().render(richtextDoc);
document.getElementById("content").innerHTML = html;

Customize how specific Markdown elements are parsed by providing custom Tiptap extensions:

import { markdownToStoryblokRichtext } from "@storyblok/richtext/markdown-parser";
const richtextDoc = markdownToStoryblokRichtext(markdown, {
tiptapExtensions: { heading: CustomHeading },
});

The package includes a utility for converting HTML content to Storyblok’s rich text format, which can be rendered via richTextResolver.

Supported HTML elements:

  • Headlines: <h1-6>
  • Paragraphs: <p>
  • Lists: <ol>, <ul>, <li>
  • Tables: <table>, <thead>, <tbody> , <tr>, <th>, <td>
  • Blockquote: <blockquote>
  • Code: <pre>, <code>
  • Links: <a>
  • Formatting: <strong>, <b>, <em>, <i>, <del>, <s>
  • Images: <img>
  • Misc: <span>, <hr>, <br>
import { htmlToStoryblokRichtext } from "@storyblok/richtext/html-parser";
const html = `
<h1>Main Heading</h1>
<p>This is a <strong>bold</strong> paragraph with <em>italic</em> text.</p>
<ul>
<li>List item 1</li>
<li>List item 2</li>
</ul>
<blockquote>
<p>This is a blockquote</p>
</blockquote>
`;
const richtextDoc = htmlToStoryblokRichtext(html);
const output = richTextResolver().render(richtextDoc);
document.getElementById("content").innerHTML = output;

Customize parsing by providing custom tiptap extensions:

import { Node } from "@tiptap/core";
import { htmlToStoryblokRichtext } from "@storyblok/richtext/html-parser";
const Callout = Node.create({
name: "callout",
group: "block",
content: "inline*",
parseHTML() {
return [{ tag: "div[data-callout]" }];
},
renderHTML({ HTMLAttributes }) {
return ["div", { "data-callout": "", class: "callout", ...HTMLAttributes }, 0];
},
});
const richtextDoc = htmlToStoryblokRichtext(html, {
tiptapExtensions: { callout: Callout },
});

The @storyblok/richtext package is framework-agnostic and can be used with any JavaScript-based frontend framework. Below are examples of how to use the package with different frameworks.

import React from "react";
import { richTextResolver } from "@storyblok/richtext";
const options: StoryblokRichTextOptions<ReactElement> = {
renderFn: React.createElement,
keyedResolvers: true,
};
function Example({ doc }) {
const html = richTextResolver(options).render(doc);
return <>{formattedHtml}</>;
}

Refer to playground/react in the @storyblok/richtext package repository for a complete example.

<script setup>
import type { VNode } from 'vue';
import { createTextVNode, h } from 'vue';
import { richTextResolver, type StoryblokRichTextOptions } from '@storyblok/richtext';
const options: StoryblokRichTextOptions<VNode> = {
renderFn: h,
textFn: createTextVNode,
keyedResolvers: true,
};
const root = () => richTextResolver<VNode>(options).render(doc);
</script>
<template>
<root />
</template>

Refer to playground/vue in the @storyblok/richtext package repository for a complete example.

Correct type support in a framework-agnostic way is ensured by using Typescript generics, circumventing the need to import types and require framework packages as dependencies.

import { Mark } from "@tiptap/core";
import { richTextResolver } from "@storyblok/richtext";
const CustomLink = Mark.create({
name: "link",
renderHTML({ HTMLAttributes }) {
return ["a", { href: HTMLAttributes.href, class: "custom-link" }, 0];
},
});
const html = richTextResolver<string>({
tiptapExtensions: { link: CustomLink },
}).render(doc);
const options: StoryblokRichTextOptions<React.ReactElement> = {
renderFn: React.createElement,
keyedResolvers: true,
};
const root = () => richTextResolver<React.ReactElement>(options).render(doc);
const options: StoryblokRichTextOptions<VNode> = {
renderFn: h,
keyedResolvers: true,
};
const root = () => richTextResolver<VNode>(options).render(doc);

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.