Four Ways to Render Storyblok Rich Text
Storyblok's rich text field stores content as structured JSON — the same format used by the TipTap editor under the hood. When you fetch a story from the Content Delivery API, a rich text field looks like this:
{
"type": "doc",
"content": [
{
"type": "heading",
"attrs": { "level": 2 },
"content": [{ "type": "text", "text": "Getting started" }]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "This is " },
{ "type": "text", "text": "important", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": " content." }
]
}
]
}This is not HTML. Your frontend needs to convert it. There are three approaches, each with different trade-offs.
Method 1: Custom renderer — walk the tree yourself
The most direct approach. You write a recursive function that walks the JSON tree and produces HTML strings. No dependencies.
type RichTextNode = {
type: string
text?: string
content?: RichTextNode[]
attrs?: Record<string, any>
marks?: { type: string; attrs?: Record<string, any> }[]
}
function renderNode(node: RichTextNode): string {
if (node.type === 'text') {
let result = escapeHtml(node.text || '')
for (const mark of node.marks || []) {
if (mark.type === 'bold') result = `<b>${result}</b>`
if (mark.type === 'italic') result = `<i>${result}</i>`
if (mark.type === 'link') result = `<a href="${mark.attrs?.href}">${result}</a>`
// ... other marks
}
return result
}
const children = node.content?.map(renderNode).join('') ?? ''
switch (node.type) {
case 'doc': return children
case 'paragraph': return `<p>${children}</p>`
case 'heading': return `<h${node.attrs?.level}>${children}</h${node.attrs?.level}>`
case 'bullet_list': return `<ul>${children}</ul>`
case 'ordered_list': return `<ol>${children}</ol>`
case 'list_item': return `<li>${children}</li>`
case 'blockquote': return `<blockquote>${children}</blockquote>`
case 'code_block': return `<pre><code>${children}</code></pre>`
case 'image': return `<img src="${node.attrs?.src}" alt="${node.attrs?.alt || ''}">`
case 'horizontal_rule': return '<hr>'
case 'hard_break': return '<br>'
default: return children
}
}
export function renderRichText(doc: any): string {
if (!doc) return ''
return renderNode(doc)
}When to use this
You want zero dependencies — no npm packages, no version conflicts
You need full control over the HTML output — every tag, every attribute, every class
Your project uses a limited subset of rich text features (no tables, no embeds, no custom blocks)
You're building a static site and want the smallest possible bundle
Trade-offs
You must handle every node type yourself. If Storyblok adds a new node type to the editor, your renderer won't know about it until you update the code.
Edge cases (nested marks, empty paragraphs, text alignment) need manual handling.
No built-in support for embedded Storyblok components (
bloknodes inside rich text).
Method 2: Use @storyblok/richtext — the official package
Storyblok provides @storyblok/richtext, a package that handles the conversion using TipTap's extension system. It supports all node types and marks out of the box.
npm install @storyblok/richtextimport { richTextResolver } from '@storyblok/richtext'
const { render } = richTextResolver()
export function renderRichText(doc: any): string {
if (!doc) return ''
return render(doc) as string
}That's it. Three lines. Every standard node type — paragraphs, headings, lists, blockquotes, code blocks, images, tables, horizontal rules — and every mark — bold, italic, strike, underline, code, links, highlight, subscript, superscript — is handled automatically.
When to use this
You want complete coverage of all rich text features without writing any mapping code
You use advanced features like tables, text alignment, or emoji nodes
You want to stay in sync with Storyblok's editor — when they add features, the package updates
Trade-offs
Adds
@storyblok/richtextplus its TipTap dependencies (~30 packages). TipTap's peer dependency management can cause version conflicts with bundlers — newer TipTap versions have changed their build output, which can break Vite's SSR builds.The HTML output is generic. Headings get
<h1>,<h2>, etc., but no CSS classes. Paragraphs are plain<p>tags. If you need styled output, you rely on a CSS layer like TailwindCSS Typography (proseclass) to style the generic HTML.
Method 3: Override extensions — customize the output
This is the most flexible approach. You start with @storyblok/richtext for the base rendering, then override specific extensions to customize how individual node types render.
import Heading from '@tiptap/extension-heading'
import { richTextResolver } from '@storyblok/richtext'
const CustomHeading = Heading.extend({
renderHTML({ node, HTMLAttributes }) {
const level = node.attrs.level
const classes = {
1: 'font-serif text-4xl font-extrabold tracking-tight',
2: 'font-serif text-3xl font-extrabold tracking-tight',
3: 'font-serif text-2xl font-bold',
}
return [`h${level}`, { class: classes[level] || '', ...HTMLAttributes }, 0]
},
})
const { render } = richTextResolver({
tiptapExtensions: { heading: CustomHeading },
})The tiptapExtensions option accepts an object where the key matches the extension name. Pass a matching key to replace the built-in extension. Everything you don't override keeps its default behavior.
Adding new node types
You can also register entirely new extensions for custom node types that don't exist in the standard TipTap set:
import { Node } from '@tiptap/core'
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 },
})When to use this
You want the package's complete coverage but need custom HTML output for specific nodes
You're adding CSS classes directly in the rendering (e.g., Tailwind classes on headings)
You have custom Storyblok components embedded in rich text fields
You need image optimization (e.g., appending Storyblok's image service parameters)
Trade-offs
Requires familiarity with TipTap's extension API
Same dependency footprint as Method 2, plus the specific
@tiptap/extension-*packages you overrideThe extension API can change between TipTap major versions
Method 4: Custom Markdown renderer — same tree walk, different output
Methods 1 through 3 produce HTML. But some consumers don't want HTML at all:
AI models and LLMs — work better with Markdown than HTML tags
Mobile apps with native Markdown rendering libraries
Documentation pipelines that generate
.mdfiles from CMS contentAPI responses where the client chooses its own rendering
Email builders where lightweight formatting beats full HTML
The approach is the same as Method 1 — walk the JSON tree recursively — but every node produces Markdown syntax instead of HTML tags.
function renderNode(node: RichTextNode, indent = 0): string {
if (node.type === 'text') {
return renderMarks(node.text || '', node.marks)
}
const children = node.content?.map(c => renderNode(c, indent)).join('') ?? ''
switch (node.type) {
case 'doc':
return node.content?.map(c => renderNode(c, indent)).join('\n\n').trim() ?? ''
case 'paragraph':
return children
case 'heading': {
const prefix = '#'.repeat(node.attrs?.level || 1)
return `${prefix} ${children}`
}
case 'blockquote':
return children.split('\n').map(line => `> ${line}`).join('\n')
case 'bullet_list':
return node.content?.map(c => renderNode(c, indent)).join('\n') ?? ''
case 'list_item':
return `${' '.repeat(indent)}- ${children}`
case 'code_block':
return `\`\`\`${node.attrs?.language || ''}\n${children}\n\`\`\``
case 'image':
return ``
case 'horizontal_rule':
return '---'
case 'hard_break':
return ' \n'
default:
return children
}
}Marks become Markdown inline syntax:
function renderMarks(text: string, marks?: Mark[]): string {
if (!marks?.length) return text
return marks.reduce((result, mark) => {
switch (mark.type) {
case 'bold': return `**${result}**`
case 'italic': return `*${result}*`
case 'strike': return `~~${result}~~`
case 'code': return `\`${result}\``
case 'link': return `[${result}](${mark.attrs?.href || ''})`
default: return result
}
}, text)
}Given this Storyblok rich text JSON:
{
"type": "doc",
"content": [
{
"type": "heading",
"attrs": { "level": 2 },
"content": [{ "type": "text", "text": "Getting started" }]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "This is " },
{ "type": "text", "text": "important", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": " content." }
]
}
]
}The output is:
## Getting started
This is **important** content.Markdown limitations
Some rich text features have no standard Markdown equivalent:
| Rich text feature | Markdown | Fallback | |---|---|---| | bold, italic, strike, code, link | Native support | — | | underline | No equivalent | Plain text | | superscript / subscript | No standard syntax | ^text^ / ~text~ (some parsers support this) | | text color / highlight | No equivalent | Plain text | | text alignment | No equivalent | Ignored | | tables | Supported but verbose | Markdown table syntax |
This is fine. Markdown's constraints are a feature, not a bug. The consumers that want Markdown — AI models, mobile apps, documentation tools — don't need text color or alignment. They need structure and formatting.
When to use this
Your content is consumed by multiple clients with different rendering needs
You're building an API that serves content to frontends you don't control
You're feeding content to AI/LLM pipelines where Markdown is the native format
You want a zero-dependency alternative format alongside your HTML renderer
Comparison
| | Method 1: Custom HTML | Method 2: Package | Method 3: Override | Method 4: Markdown | |---|---|---|---|---| | Output | HTML | HTML | HTML (customized) | Markdown | | Dependencies | Zero | ~30 packages | ~30 + extensions | Zero | | Coverage | What you implement | All standard | All standard + custom | What you implement | | HTML control | Total | None (generic) | Per-extension | N/A | | Maintenance | Manual | Automatic | Partial | Manual | | Bundle size | Minimal | Moderate | Moderate | Minimal | | Best for | Simple sites, zero deps | Standard websites | Styled HTML, custom nodes | AI, mobile, APIs, docs |
Which one should you use?
Method 2 (package) — Start here for a standard website. Generic HTML styled with CSS (e.g., TailwindCSS Typography's prose class) covers most cases. Least code, most complete.
Method 3 (override) — When you need per-element control: Tailwind classes on headings, image optimization, custom embedded components. Same package, more power.
Method 1 (custom HTML) — When you want zero dependencies and your content uses a predictable subset of features. More code upfront, but nothing to install or break.
Method 4 (Markdown) — When your consumer isn't a browser. AI models, mobile apps, documentation pipelines, API responses. Same structured JSON, different format.
All four methods read the same structured JSON from Storyblok. The content is authored once. The output format depends on who's consuming it.