JSON Rich Text Editor and custom RTE plugins
JSON Rich Text Editor and custom RTE plugins
TL;DR
- JSON RTE stores rich text as a structured document tree, not an HTML string -- making it traversable, transformable, and multi-platform.
- Embedded entries and assets in a JSON RTE are stored as reference nodes; request include_embedded_items[] to get their full data.
- You need a rendering layer on the frontend to convert JSON RTE nodes into HTML or native components.
- Custom RTE plugins extend the editor toolbar and produce custom node types that your renderer must also handle.
Rich text is the field type where structured content gets messy. An HTML-based rich text editor stores <p>Check out our <a href="/products/widget">Premium Widget</a> - it's <strong>50% off</strong>!</p> as a flat string, making it impossible to extract the embedded product reference, validate the link target, or transform the output for a mobile app that does not render HTML. Contentstack's JSON Rich Text Editor (JSON RTE) solves this by storing rich text as a structured JSON document tree, turning what was once an opaque HTML blob into data you can traverse, transform, and render on any platform.
HTML RTE vs. JSON RTE
Contentstack supports two rich text field types. The older HTML-based Rich Text Editor stores content as an HTML string. The newer JSON Rich Text Editor stores content as a JSON document tree. Both provide a WYSIWYG editing experience, but their API outputs are fundamentally different.
| Characteristic | HTML RTE | JSON RTE |
|---|---|---|
| API output format | HTML string | JSON document tree |
| Embedded entries | Not natively supported | Supported (inline and block) |
| Embedded assets | <img> tags with URLs | Structured asset nodes with metadata |
| Rendering | Insert HTML directly (or sanitize) | Requires a rendering function |
| Portability | HTML-native platforms only | Any platform with a JSON parser |
| Content extraction | Requires HTML parsing | Direct JSON traversal |
| Custom elements | Limited | Extensible via custom node types |
For new projects, the JSON RTE is the recommended choice. It aligns with the structured content philosophy covered in lesson 2.1.1: content should be data, not markup. The HTML RTE exists for backward compatibility with stacks that predate the JSON format.
When adding a JSON RTE field to a content type, select JSON Rich Text Editor from the field type list in the content type builder. The field's data_type in the schema is json with field_metadata.rich_text_type set to "advanced", as shown in the schema examples in lesson 2.1.2.
The JSON document structure
A JSON RTE field stores content as a tree of nodes, following a structure inspired by the Slate.js editor framework. The root is always a doc node, and every element within the document is a node with a type, optional attributes, and children.
Here is what a simple paragraph with bold text looks like in the JSON RTE format:
{
"type": "doc",
"uid": "doc_uid_001",
"attrs": {},
"children": [
{
"type": "p",
"uid": "p_uid_001",
"attrs": {},
"children": [
{ "text": "This product is " },
{ "text": "built for developers", "bold": true },
{ "text": " who need reliable tooling." }
]
}
]
}- Every node has a type. Block-level types include p (paragraph), h1 through h6 (headings), ul and ol (lists), li (list items), blockquote, code, table, img, and hr.
- Text nodes have no type. They are leaf nodes identified by having a text property. Inline formatting is stored as boolean properties on the text node: bold, italic, underline, strikethrough, subscript, superscript, code.
- Every node gets a unique uid. Contentstack assigns a UID to each node in the document. These UIDs are stable and can be used for tracking or analytics.
- Nodes can have attrs. Attributes carry metadata specific to the node type. A link node has attrs.href and attrs.target. An image node has attrs.src, attrs.alt, and asset metadata.
A more complex example
Consider a product description that includes a heading, a paragraph, a bulleted list, and an embedded image:
{
"type": "doc",
"uid": "doc_uid_002",
"children": [
{
"type": "h2",
"uid": "h2_uid_001",
"children": [{ "text": "Why choose the Premium Widget" }]
},
{
"type": "p",
"uid": "p_uid_002",
"children": [
{ "text": "The Premium Widget combines " },
{ "text": "enterprise-grade durability", "italic": true },
{ "text": " with a developer-friendly API." }
]
},
{
"type": "ul",
"uid": "ul_uid_001",
"children": [
{
"type": "li",
"uid": "li_uid_001",
"children": [{ "text": "99.9% uptime SLA" }]
},
{
"type": "li",
"uid": "li_uid_002",
"children": [{ "text": "Sub-100ms response times" }]
},
{
"type": "li",
"uid": "li_uid_003",
"children": [{ "text": "Full REST and GraphQL support" }]
}
]
},
{
"type": "img",
"uid": "img_uid_001",
"attrs": {
"src": "https://images.contentstack.io/v3/assets/.../widget-diagram.png",
"alt": "Premium Widget architecture diagram",
"asset_uid": "bltasset_widget_001",
"width": 800,
"height": 450
},
"children": [{ "text": "" }]
}
]
}This tree is fully traversable. A mobile app can extract just the list items for a feature comparison screen. A voice assistant can read the text nodes in order, skipping the image. A web app can render the full document with custom components for each node type.
Embedded entries and assets
One of the most powerful features of the JSON RTE is the ability to embed entries from other content types and assets directly within rich text content. This goes beyond simple image insertion - you can embed any entry type as an inline element or a block element within the text flow.
Embedded entries
When an editor inserts an embedded entry (via the toolbar's "Embed Entry" button), the JSON RTE stores a reference node:
{
"type": "reference",
"uid": "ref_uid_001",
"attrs": {
"type": "entry",
"class-name": "embedded-entry",
"entry-uid": "blt_matrix_link_bracelet_001",
"content-type-uid": "product_comparison",
"display-type": "block"
},
"children": [{ "text": "" }]
}The display-type attribute indicates whether the embedded entry appears as a block (its own visual block, like a comparison table between paragraphs) or inline (within a line of text, like a product name with a tooltip). The entry-uid and content-type-uid attributes identify the referenced entry.
This is significant for the product description use case. Imagine a product page where the description field allows editors to embed a Product Comparison Table entry between paragraphs. The comparison table is its own content type (with fields for products, feature rows, and highlight settings), and it is referenced, not duplicated, inside the rich text. If the comparison data changes, the embedded entry updates everywhere it appears.
Embedded assets
The display-type attribute indicates whether the embedded entry appears as a block (its own visual block, like a comparison table between paragraphs) or inline (within a line of text, like a product name with a tooltip). The entry-uid and content-type-uid attributes identify the referenced entry.
This is significant for the product description use case. Imagine a product page where the description field allows editors to embed a Product Comparison Table entry between paragraphs. The comparison table is its own content type (with fields for products, feature rows, and highlight settings), and it is referenced, not duplicated, inside the rich text. If the comparison data changes, the embedded entry updates everywhere it appears.
Embedded assets
Assets (images, PDFs, videos) embedded in a JSON RTE appear as structured nodes with full metadata:
{
"type": "reference",
"uid": "ref_uid_002",
"attrs": {
"type": "asset",
"class-name": "embedded-asset",
"asset-uid": "bltasset_callout_001",
"display-type": "display",
"asset-link": "https://images.contentstack.io/v3/assets/.../callout-box.png",
"asset-name": "callout-box.png",
"asset-type": "image/png",
"content-type-uid": "sys_assets"
},
"children": [{ "text": "" }]
}Unlike HTML RTE images (which are just <img> tags with a URL), embedded assets in JSON RTE retain their asset UID. This means your rendering layer can look up the asset's metadata, apply Contentstack's Image Delivery API transformations (resize, crop, format conversion), and generate responsive image markup. See lesson 3.2.3 on image delivery and transformation for details.
Common Pitfall
Forgetting to add include_embedded_items[]=<field_uid> to your API call means embedded entries in the JSON RTE return as bare UIDs with no content data, causing them to silently disappear from your rendered output.
Rendering JSON RTE content
The JSON RTE format requires a rendering step on the frontend. You cannot insert a JSON document into the DOM the way you can insert an HTML string. Contentstack provides the @contentstack/utils package to handle this conversion.
Installation
npm install @contentstack/utils
Basic rendering with jsonToHtml
The jsonToHtml function converts a JSON RTE document to an HTML string. For simple cases where you just need HTML output:
import { jsonToHtml } from '@contentstack/utils';
const htmlString = jsonToHtml({
entry: entryData,
paths: ['description'] // field UID(s) containing JSON RTE data
});
// Use in React with dangerouslySetInnerHTML or a sanitizerThe paths parameter tells the utility which fields in the entry contain JSON RTE data that needs conversion. If your entry has multiple JSON RTE fields (e.g., description and summary), list them all.
Custom rendering with renderOption
For more control over how specific node types render, use the renderOption parameter. This is essential when your JSON RTE contains embedded entries or assets that need custom rendering:
import { jsonToHtml } from '@contentstack/utils';
const renderOption = {
renderNode: {
'h2': (node, next) => {
return `<h2 class="product-heading">${next(node.children)}</h2>`;
},
'p': (node, next) => {
return `<p class="product-text">${next(node.children)}</p>`;
},
'img': (node) => {
const { src, alt } = node.attrs;
// Apply Contentstack Image Delivery API transformations
const optimizedSrc = `${src}?width=800&format=webp&quality=80`;
return `<img src="${optimizedSrc}" alt="${alt}" loading="lazy">`;
}
},
renderMark: {
'bold': (text) => `${text}`,
'italic': (text) => `${text}`
}
};
const htmlString = jsonToHtml({
entry: entryData,
paths: ['description'],
renderOption
});Rendering embedded entries
Embedded entries require special handling because the JSON RTE only stores a reference to the entry, not the entry's content. You need to resolve the reference and render it:
const renderOption = {
renderNode: {
'reference': (node, next) => {
const { attrs } = node;
// Handle embedded entries
if (attrs.type === 'entry') {
const contentTypeUid = attrs['content-type-uid'];
const entryUid = attrs['entry-uid'];
if (contentTypeUid === 'product_comparison') {
// Render a product comparison table
// The entry data is available if you used include_embedded_items
return `<div class="comparison-table" data-entry="${entryUid}">
<!-- Render comparison table component -->
</div>`;
}
if (contentTypeUid === 'callout_box') {
return ``;
}
}
// Handle embedded assets
if (attrs.type === 'asset') {
const assetUrl = attrs['asset-link'];
const assetName = attrs['asset-name'];
return `<figure>
<img src="${assetUrl}?width=800&format=webp" alt="${assetName}">
</figure>`;
}
return '';
}
}
};To get the full data for embedded entries in the API response, add include_embedded_items[]=description to your Delivery API request (where description is the field UID of the JSON RTE field). This resolves the embedded references and includes the entry data in the _embedded_items object of the response.
React-specific rendering
For React applications, you can build a component-based renderer instead of producing HTML strings:
import React from 'react';
interface RTENode {
type?: string;
text?: string;
bold?: boolean;
italic?: boolean;
children?: RTENode[];
attrs?: Record;
}
function RenderRTENode({ node }: { node: RTENode }) {
// Text leaf node
if (node.text !== undefined) {
let element: React.ReactNode = node.text;
if (node.bold) element = {element};
if (node.italic) element = {element};
if (node.underline) element = {element};
if (node.code) element = {element};
return <>{element};
}
const children = node.children?.map((child, i) => (
));
switch (node.type) {
case 'doc': return <>{children};
case 'p': return ;
case 'h1': return ;
case 'h2': return ;
case 'h3': return ;
case 'ul': return ;
case 'ol': return ;
case 'li': return ;
case 'blockquote': return ;
case 'img':
return ;
case 'a':
return ;
case 'reference':
return ;
default: return <>{children};
}
}
function EmbeddedContent({ attrs }: { attrs: Record }) {
if (attrs.type === 'entry') {
// Render based on content type
return This approach gives full control over the rendering of every node type and integrates naturally with React's component model. Each node type maps to a React component, and embedded entries can be rendered using dedicated components that fetch or receive the resolved entry data.
Custom RTE plugins
Contentstack allows you to extend the JSON RTE editor with custom plugins. Plugins add new toolbar buttons, custom elements, and specialized editing behaviors that go beyond the built-in formatting options.
What plugins can do
RTE plugins operate within the Contentstack entry editor. They can:
- Add toolbar buttons that insert custom content (e.g., a "Callout Box" button that wraps selected text in a callout node).
- Define custom element types that render as specialized blocks in the editor (e.g., a product comparison table that editors can populate visually).
- Modify paste behavior to transform pasted content into structured nodes.
- Add keyboard shortcuts for frequently used formatting patterns.
- Integrate with external services to pull in data from third-party APIs during editing.
Plugin architecture
JSON RTE plugins are built using the @contentstack/app-sdk and follow the Contentstack app framework. A plugin is a JavaScript module that registers itself with the RTE editor instance and provides configuration for toolbar items, element renderers, and event handlers.
A basic plugin structure looks like this:
import ContentstackSDK from '@contentstack/app-sdk';
ContentstackSDK.init().then(async (sdk) => {
const rtePlugin = sdk.location.RTEPlugin;
if (rtePlugin) {
// Register a custom toolbar button
rtePlugin.on('toolbar', (toolbar) => {
toolbar.addButton({
label: 'Insert Callout',
icon: 'callout-icon',
action: (editor) => {
// Insert a custom callout node at the cursor position
editor.insertNode({
type: 'callout',
attrs: { style: 'info' },
children: [{ text: 'Enter callout text here...' }]
});
}
});
});
// Register a custom element renderer for the editor UI
rtePlugin.on('render', (element) => {
if (element.type === 'callout') {
return {
component: 'div',
props: {
className: `rte-callout rte-callout-${element.attrs.style}`,
style: {
padding: '16px',
borderLeft: '4px solid #0078d4',
backgroundColor: '#f0f7ff',
margin: '12px 0'
}
}
};
}
});
}
});Deploying a custom RTE plugin
Custom RTE plugins are deployed as Contentstack apps through the Developer Hub:
- Create a new app in Developer Hub > + New App.
- Set the app type to include an RTE Plugin location.
- Host the plugin code (the built JavaScript bundle) on a publicly accessible URL or use the Contentstack App hosting.
- Configure the plugin's entry point URL in the app settings.
- Install the app on your stack.
- Enable the plugin on specific JSON RTE fields in the content type settings under the field's Plugins configuration.
Once installed, editors see the custom toolbar buttons when editing any JSON RTE field that has the plugin enabled. The custom nodes are stored in the JSON document tree alongside standard nodes, using the custom type values you defined.
Frontend rendering of custom nodes
Custom nodes from RTE plugins appear in the API response as regular nodes with your custom type values. Your frontend renderer needs to handle these types:
// Extending the renderNode configuration for custom types
const renderOption = {
renderNode: {
'callout': (node, next) => {
const style = node.attrs?.style || 'info';
const styleMap = {
info: { borderColor: '#0078d4', bgColor: '#f0f7ff' },
warning: { borderColor: '#f59e0b', bgColor: '#fffbeb' },
success: { borderColor: '#10b981', bgColor: '#ecfdf5' }
};
const colors = styleMap[style] || styleMap.info;
return ``;
},
// ... other custom node types
}
};This is a key coordination point. The plugin developer, the content modeler, and the frontend developer must agree on the custom node types, their attributes, and how they render. Document custom node schemas the same way you document content type schemas - they are part of the API contract, as discussed in lesson 2.1.2.
The trade-off: structure vs. rendering complexity
The JSON RTE gives you structured, traversable, platform-independent rich text data. But this comes at a cost: you need to build and maintain a rendering layer. With an HTML RTE, you can insert the output directly into a web page (after sanitization). With a JSON RTE, you need a renderer that maps every node type to the appropriate output for your platform.
This trade-off is worth it when:
- You deliver to multiple channels. A web app renders JSON RTE nodes as HTML components. A mobile app renders them as native views. A voice assistant extracts the text content. One data format serves all platforms.
- You need to process content programmatically. Extracting all links, counting words, generating tables of contents, or identifying embedded entries is straightforward with a JSON tree. Doing the same with an HTML string requires a parser.
- You use embedded entries. If editors embed Product Comparisons, Callout Boxes, or Code Snippets within rich text, the JSON format preserves those references as structured data. HTML RTE cannot do this natively.
- You want consistent rendering. By controlling the renderer, you ensure that every heading, paragraph, and list renders with your design system's components, regardless of what HTML the editor might have pasted.
The trade-off is not worth the added complexity when content is only consumed by a single web application and contains no embedded entries. In that case, the HTML RTE with careful sanitization may be simpler. However, even in single-channel scenarios, the JSON RTE's support for embedded entries and structured data often tips the scale in its favor.
Common mistakes
- Storing structured data in a JSON RTE instead of discrete fields. If a product has a price, SKU, and availability status, these should be separate Number, Single Line, and Select fields - not formatted text inside a JSON RTE. The RTE is for prose content (descriptions, articles, instructions), not for data that needs to be queried, filtered, or displayed independently. This echoes the structured content principle from lesson 2.1.1.
- Not handling embedded entries in the frontend renderer. When editors embed entries in a JSON RTE field, the API response contains reference nodes with entry-uid values. If the frontend renderer does not handle the reference node type, those embedded entries silently disappear from the rendered output. Always implement a reference handler, even if it only renders a fallback placeholder.
- Forgetting to request include_embedded_items[] in the API call. Without this parameter, embedded entry references in the JSON RTE contain only UIDs, not the actual entry data. The frontend must either make additional API calls to resolve each reference or include this parameter to get all embedded data in one response. The parameter value should be the field UID of the JSON RTE field: include_embedded_items[]=description.