Rich text
What you'll learn
What editors can do in a rich text field, how it is stored, and how to show it safely on your pages with Nuxt, Next.js or any frontend.
Before this page: Fields.
The field
{ name: 'body', type: 'richText', localized: true }In the admin, richText fields are edited with Tiptap: headings (H2–H4), bold, italic, underline, inline code, links, bullet and numbered lists, quotes, images from the media library (or a URL), undo and redo. Keyboard shortcuts work as in other editors (Ctrl/⌘ + B, I, U, Z).
The value is stored as Tiptap JSON, a tree of nodes, not HTML:
{
"type": "doc",
"content": [
{ "type": "heading", "attrs": { "level": 2 }, "content": [{ "type": "text", "text": "Hello" }] },
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Read the " },
{ "type": "text", "text": "guide", "marks": [{ "type": "link", "attrs": { "href": "/guide" } }] }
]
}
]
}JSON keeps content independent of how you show it: the same document can become HTML, plain text for a search index, or your own components.
Showing it on a page
Install the renderer:
npm install @easy-cms/richtextpnpm add @easy-cms/richtextyarn add @easy-cms/richtextbun add @easy-cms/richtext<script setup lang="ts">
import { renderRichText } from '@easy-cms/richtext'
const { data: post } = await useFetch(`/api/posts/${useRoute().params.slug}`)
const html = computed(() => renderRichText(post.value?.body))
</script>
<template>
<!-- Safe: renderRichText escapes text and drops unsafe URLs -->
<div class="prose" v-html="html" />
</template>import { renderRichText } from '@easy-cms/richtext'
export function PostBody({ body }: { body: unknown }) {
// Safe: renderRichText escapes text and drops unsafe URLs
return <div className="prose" dangerouslySetInnerHTML={{ __html: renderRichText(body) }} />
}import { renderRichText } from '@easy-cms/richtext'
const post = await fetch('/api/cms/posts/1').then((r) => r.json())
document.querySelector('#body')!.innerHTML = renderRichText(post.body)It renders these nodes: paragraphs, headings, bullet and numbered lists, quotes, code blocks, line breaks, horizontal rules and images; and these marks: bold, italic, underline, strikethrough, code and links. Unknown nodes keep their text without markup, so newer content never breaks a page.
Safety
renderRichText escapes all text and attributes and drops unsafe URLs (javascript:, data: and anything other than http(s), mailto, tel and relative links), so its output is safe for v-html or dangerouslySetInnerHTML. External links get rel="noopener noreferrer".
Plain text
import { richTextToPlainText } from '@easy-cms/richtext'
const excerpt = richTextToPlainText(post.body).slice(0, 160) // meta descriptions, searchMarkdown
renderMarkdown(doc) turns rich text into Markdown, e.g. for AI assistants (AI search), emails or exports. Text that looks like Markdown is escaped and unsafe URLs are dropped, as with HTML. Pass nodes to render your own node types.
import { renderMarkdown } from '@easy-cms/richtext'
const markdown = renderMarkdown(post.body) // "## Heading\n\nA paragraph with **bold** text."Customizing the output
Replace or add the renderer of any node. It receives the node and its rendered children:
import { slugify } from '@easy-cms/core'
import { escapeHtml, renderRichText, safeUrl } from '@easy-cms/richtext'
renderRichText(post.body, {
nodes: {
// Wrap images in a figure with a caption from the alt text.
image: (node) => {
const src = safeUrl(node.attrs?.src)
const alt = escapeHtml(String(node.attrs?.alt ?? ''))
return src ? `<figure><img src="${escapeHtml(src)}" alt="${alt}"><figcaption>${alt}</figcaption></figure>` : ''
},
// Add ids to headings for a table of contents.
heading: (node, children) => {
const level = Number(node.attrs?.level ?? 2)
return `<h${level} id="${escapeHtml(slugify(children))}">${children}</h${level}>`
},
},
// Attributes for every link, e.g. open external ones in a new tab.
linkAttributes: (href) => (href.startsWith('http') ? { target: '_blank', rel: 'noopener noreferrer' } : {}),
})Custom renderers receive raw attributes: escape them yourself with the exported escapeHtml and safeUrl (children are already escaped).
Next steps
- Uploads & media: the images editors insert.
- Localization: a rich text field per language.