arkynChangelogGuides
arkyn
docs / guides / how-to-work-with-rich-text-content

Store and render RichText content

RichText doesn't edit HTML — it edits a Slate JSON document. That distinction drives almost every decision about how to store and re-render its content, and it's easy to get tangled up between the editor's native value, the HTML you show on a public page, and the two conversion helpers that sit between them.

What RichText actually holds

RichText's defaultValue prop is a JSON string of Slate nodes (default "[]"), not HTML:

tsx

<RichText name="content" defaultValue='[{"type":"paragraph","children":[{"text":"Hello"}]}]' />
The component itself submits this same JSON string through a hidden <input name="content">, so whatever your form action receives for that field is already the editor's native value — no conversion needed to round-trip an edit-save-edit cycle.

Two conversion helpers, two different jobs

You may not need toRichTextValue at all
If your storage is the RichText JSON itself, feed the stored string straight back into defaultValue — that's it. toRichTextValue only matters when your source of truth is HTML instead.
toHtml(richTextValue) — converts a parsed Slate value (an array, not the JSON string) into an HTML string, for rendering somewhere that isn't the editor: a public blog page, an email, a PDF.
toRichTextValue(html) — the reverse: parses an HTML string into a Slate value array, for loading content that originated as HTML (a legacy CMS export, hand-authored HTML) into the editor for the first time.

tsx

import { toHtml } from "@arkyn/components/toHtml";
import { toRichTextValue } from "@arkyn/components/toRichTextValue";
// Rendering stored content elsewhere as HTML
const html = toHtml(JSON.parse(storedRichTextJson));
// Loading HTML-sourced content into the editor
const value = toRichTextValue(legacyHtmlContent);
toRichTextValue returns an array, not a JSON string
RichText's defaultValue expects a JSON string, but toRichTextValue returns a plain array of nodes. JSON.stringify() the result before passing it to defaultValue: <RichText defaultValue={JSON.stringify(toRichTextValue(html))} />.

Recommended storage strategy

Store the RichText JSON as your canonical value, and derive HTML only when you need to display the content outside the editor — instead of storing HTML and converting it back for editing. This avoids an unnecessary round-trip and sidesteps the video limitation below entirely. toHtml/toRichTextValue don't touch the DOM, so both can run just as well in a server-side loader as in the browser if you'd rather precompute the HTML once at write time than on every read.

typescript

// Write path — store the editor's own value, untouched
async function saveArticle(richTextJson: string) {
await db.articles.update({ content: richTextJson });
}
// Read path (public page) — convert only when rendering as HTML
async function renderArticle(id: string) {
const article = await db.articles.findById(id);
return toHtml(JSON.parse(article.content));
}

Media insertion

imageConfig and videoConfig add insertion buttons to the toolbar. Image insertion follows the same two-step upload contract as FileUploadimageConfig.action is an endpoint that receives the file and returns its public URL:

tsx

<RichText
name="post"
imageConfig={{
action: "/api/upload/image",
modalTitle: "Insert Image",
modalInputUrlLabel: "Image URL",
modalInputImageLabel: "Upload image",
}}
videoConfig={{
modalTitle: "Insert Video",
modalInputLabel: "Video URL",
}}
/>
videoConfig only accepts a YouTube URL (it extracts the ?v= parameter) — there's no upload endpoint for video, unlike images.
Video doesn't round-trip through HTML
The video toolbar button also renders by default even without a videoConfig — pass hiddenButtons={["video"]} if you don't want it. More importantly: toHtml serializes a video node into an <iframe>, but toRichTextValue has no matching case to parse that <iframe> back into a video node. If you store content as HTML and later load it back into the editor for re-editing, any video embed will be silently dropped. This is exactly why storing the native RichText JSON (above) as your canonical value — rather than HTML — matters most for content that includes video.

Rendering the resulting HTML safely

Whatever renders toHtml's output on the display side almost always does so via dangerouslySetInnerHTML, since it's a plain HTML string. RichText is typically aimed at trusted content producers (admins, editors), not arbitrary end-user input — but if your editor is exposed to untrusted users, sanitize the HTML (e.g. with a library like DOMPurify) before rendering it, the same way you would for any other user-supplied HTML in your app. Arkyn does not sanitize this output for you.

Notes

  • maxLimit (default 10000) counts characters against the serialized JSON string, not the visible text length — a heavily formatted document can hit the limit sooner than its word count suggests.
  • The hidden <input> behind RichText does not forward an external ref — if you need imperative access to the underlying value, read it from onChange instead.
  • slate, slate-react, slate-history, is-hotkey, and html-react-parser all need to be installed for the editor to work — see the RichText reference for the exact install command.
  • See Build forms with server-side validation for how RichText's built-in FieldWrapper/FieldLabel/FieldError composition reads fieldErrors the same way other form fields do.
Related in @arkyn/components
On this page