Four Ways to Render Storyblok Rich Text

|
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 (blok nodes 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/richtext
import { 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/richtext plus 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 (prose class) 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 override

  • The 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 .md files from CMS content

  • API 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 `![${node.attrs?.alt || ''}](${node.attrs?.src || ''})`
    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.

Story Drops

For developers and Storyblok power editors who already know the basics and want to go further. Story Drops is a collection of opinionated tips — each one focused, practical, and honest about trade-offs. No best-practice rewrites. Just real patterns from real projects.


Roberto 2026