Studio Docs

When to Use

Register the unified RTE renderer via registerRTERenderer so Studio can render rich text (JSON RTE, HTML RTE and Markdown alike) plus embedded entries, embedded assets and custom element types.

Use for any rich-text field bound to a component prop, whatever its storage format. Phrases: “JSON RTE”, “HTML RTE”, “rich text field”, “markdown field”, “RichText component”, “rich text renders as raw HTML”, “RTE shows <p> tags as literal text”, “rich text renders blank”, “embed a card inside rich text”, “embedded entries/assets in RTE”, “custom element type in RTE”, “registerRTERenderer”, “registerJSONRTE is deprecated”. Also when the JSON tree contains element types the default serializer doesn’t know, or you want to override the default HTML for a standard tag. Register once at app bootstrap.

Auth preflight: settle the credential before the first API call. Resolve it OAuth-first per authenticate-cma: CS_OAUTH_ACCESS_TOKEN, else the Contentstack MCP’s stored session. Never ask the user for a session authtoken. If nothing resolves, or a refresh fails with 400 invalid_refresh_token, hand them ! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth (it needs a TTY and a browser, so it cannot be run for them), and wait. 403 error_code 316 is a valid credential aimed at another org: fix the org or the api_key, do not re-authenticate.

Register an RTE Renderer

The name says json-rte for historical reasons. The API is not JSON-RTE-only. registerRTERenderer is a single call covering JSON RTE, HTML RTE and Markdown. If you arrived here with an HTML RTE or a Markdown field, you are in the right place: everything below applies unchanged.

Context

Contentstack stores rich text in more than one shape, and Studio renders all of them through one renderer:

FieldStored asNotes
JSON RTEa structured JSON tree (nodes with type + attrs)serialized to HTML for the prop
HTML RTEan HTML stringembedded refs extracted from the markup
Markdowna Markdown stringconverted, then rendered

The built-in RichText component is typed any and auto-detects which of these it received, so a JSON tree, an HTML string, Markdown, or a plain string all render without the author choosing a variant.

When Studio resolves a component prop bound to a rich-text field, it serializes the value to HTML and passes it to your component.

The default serializer already handles paragraphs, headings, lists, links, marks, images, and tables. Use registerRTERenderer (the unified, canonical API) when any of these apply:

  • The JSON tree contains custom element types (anything outside the default tag map)
  • The RTE field embeds entries that should render as components/cards (e.g. a blog body that embeds a Product card)
  • The RTE field embeds assets that need custom markup (override the default <img> / <video> / <a download> fallback)
  • You want to override the default rendering of a standard tag

registerRTERenderer covers all four in a single call.

Registration is a side-effect on the SDK singleton. It must happen once, at app bootstrap, before any <StudioComposition /> (or <StudioComponent />) mounts. Late registration won’t retroactively re-render props that have already resolved.

registerJSONRTE is deprecated. It still works (preserves previously-registered embeddedEntry/embeddedAsset) but the SDK marks it @deprecated. New code uses registerRTERenderer. Migrate existing calls when you next touch them. registerEmbeddedEntryRenderer is also deprecated for the same reason.

See: docs/bring-your-own-components/json-rte-custom-element-rendering.md.

Task

  1. Import registerRTERenderer from @contentstack/studio-react (re-exported from @contentstack/studio-client):

    import { registerRTERenderer } from "@contentstack/studio-react";
  2. Call it at app bootstrap, once, in the same module where you call studioSdk.init(...) (typically src/lib/contentstack.ts). Place it alongside any registerComponent(...) calls.

  3. Pass an RTEConfig: extends IJsonToHtmlOptions with embeddedEntry + embeddedAsset. Each call REPLACES the full config. Merge yourself if you call it more than once.

    registerRTERenderer({
      // Render embedded entries as components/cards. The SDK auto-resolves
      // the embedded entry and passes the full entry object — no extra fetch.
      embeddedEntry: ({ entry, contentTypeUid, displayType }) => {
        // displayType is one of "block", "link", or other editor-defined kinds
        if (displayType === "block") {
          return `<article class="card">
            <h3>${entry.title}</h3>
            <p>${entry.summary ?? ""}</p>
          </article>`;
        }
        if (displayType === "link") {
          return `<a href="${entry.url ?? "#"}">${entry.title}</a>`;
        }
        return `<span>${entry.title}</span>`;
      },
    
      // Render embedded assets. Omit to use the built-in default
      // (img for image/*, video/audio for those MIMEs, <a download> otherwise).
      embeddedAsset: ({ asset }) => {
        if (asset.content_type?.startsWith("image/")) {
          return `<img src="${asset.url}" alt="${asset.title ?? ""}" loading="lazy" />`;
        }
        return `<a href="${asset.url}" download>${asset.filename}</a>`;
      },
    
      // Custom JSON RTE element types (handlers return HTML strings)
      customElementTypes: {
        callout: (attrs, child, jsonBlock) => {
          const variant = jsonBlock?.attrs?.variant ?? "info";
          return `<aside class="callout callout--${variant}"${attrs}>${child}</aside>`;
        },
        // Override default heading rendering:
        h2: (attrs, child) => `<h2 class="prose-h2"${attrs}>${child}</h2>`,
      },
    
      // Other IJsonToHtmlOptions keys passed through:
      allowNonStandardTypes: true,
    });
  4. embeddedEntry args, full signature: { entry, contentTypeUid, displayType, ...metadata }. The entry is the complete resolved entry object (Studio fetches it). Read any field on it directly. displayType carries the editor’s chosen display variant (“block”, “link”, or any custom string the editor exposes). Branch on it to render the same entry differently.

  5. embeddedAsset args: { asset, ...metadata }. The asset is the complete asset object including url, content_type, filename, title, dimension etc. Both JSON RTE (attrs.type: "asset") and HTML RTE (<figure class="embedded-asset" data-sys-asset-uid="…">) paths invoke this handler.

  6. customElementTypes handler signature: (attrs: string, child: string, jsonBlock: IAnyObject, extraProps?: object) => string

    • attrs: pre-serialized HTML attribute string
    • child: pre-serialized inner HTML of children
    • jsonBlock: raw node, read jsonBlock.attrs, jsonBlock.type, custom metadata
    • Return an HTML string, not a React element
  7. Match keys to node type exactly. Keys in customElementTypes must equal the type string on the JSON RTE node emitted by Contentstack, byte-for-byte. callout will not match "Callout" or "custom-callout". Use a standard tag name (h2, a, ul) to override the default rendering for that tag.

  8. Declare the prop in your component schema as json_rte. Studio creates a JsonRTEProp and binds it to the field like any other prop: your component code stays JSON-RTE-agnostic.

  9. Render it: check which path the installed SDK supports. Two options, and the newer one removes most of this work:

    grep -rq "useStudioRichText" node_modules/@contentstack/studio-react/dist \
      && echo "hook available" || echo "hook ABSENT — use the manual path"

    hook available means one call. useStudioRichText runs the same pipeline as the built-in RichText: it detects JSON RTE / HTML RTE / Markdown, resolves embedded entries and assets, and sanitizes before rendering. No dangerouslySetInnerHTML, no DOMPurify, no format branching.

    import { useStudioRichText } from "@contentstack/studio-react";
    
    export function Article({ body, $body, entry }) {
      return <div className="prose">{useStudioRichText(body, $body, entry)}</div>;
    }

    Pass entry when you have it: embedded items are then read from entry._embedded_items instead of being refetched, free if the caller already used .includeEmbeddedItems().

    hook ABSENT means the manual path. The prop hands over the raw value, so the component serializes and sanitizes it itself, and embedded entries/assets will NOT render:

    import { jsonToHtml } from "@contentstack/json-rte-serializer";
    import DOMPurify from "isomorphic-dompurify";
    
    export function Article({ body }: { body: object }) {
      const html = DOMPurify.sanitize(jsonToHtml(body));
      return <div className="prose" dangerouslySetInnerHTML={{ __html: html }} />;
    }

    Sanitize on this path. RTE content is author-supplied. Rendering it unsanitized is an XSS vector. The hook does this for you. The manual path does not.

    On an SDK without the hook, json_rte also accepts only JSON RTE (an object). HTML RTE and Markdown are strings and cannot bind to it. Bind those to an any prop, or use the built-in RichText, until the SDK is upgraded.

  10. (Optional) Inspect current config: three internal readers used by hooks:

    • getJSONRTE() returns IJsonToHtmlOptions (without embeddedEntry/embeddedAsset)
    • getEmbeddedEntryRenderer() returns the registered entry renderer or null
    • getEmbeddedAssetRenderer() returns the registered asset renderer or null (falls back to the SDK default if not registered)

    All three import from @contentstack/studio-client (not @contentstack/studio-react, which only re-exports the registration functions).

Inputs Needed From the User

  1. embeddedContentTypes: list of CTs the RTE field can embed (so the embeddedEntry callback can branch on contentTypeUid per CT).
  2. customElementTypes: list of custom node type values you need to render. Pull these from the actual entry JSON, not from guesses.
  3. overrideTags: optional list of standard tags whose default HTML you want to replace.

If none of the three is provided, ask the user whether they actually need this skill: the default serializer + default asset renderer may already cover their JSON RTE.

Acceptance

This skill succeeds only when ALL of the following are true:

  • registerRTERenderer({...}) is called once, in the bootstrap module, before any <StudioComposition /> or <StudioComponent /> mounts.
  • If embedded entries are in scope: the embeddedEntry handler branches on contentTypeUid and/or displayType and returns an HTML string per case.
  • If embedded assets need custom markup: the embeddedAsset handler returns an HTML string. Otherwise the default is acceptable.
  • Every declared custom element type has a handler returning an HTML string (not React).
  • Handler keys match the raw type values in the entry JSON exactly.
  • Components consuming the prop render it via useStudioRichText where the installed SDK exports it, or via sanitized dangerouslySetInnerHTML where it does not (no escaped HTML visible on the page).
  • getJSONRTE() returns the serializer options. getEmbeddedEntryRenderer() returns the entry renderer.
  • Rendering a composition bound to the json_rte field shows custom node markup AND embedded-entry markup AND custom-asset markup in the DOM. Standard nodes still render via their defaults.

Which Component for Which Field

Rich text is never a Heading/Paragraph job. Map the CT field to the right component, then register what that component needs:

Contentstack fieldComponentProp typeMust register
JSON RTERichTextjson_rteregisterRTERenderer: always, if it embeds entries/assets or custom nodes
HTML RTE (multiline + rich text editor)RichTextanysame call: embedded refs are extracted from the markup
Markdown (multiline + markdown)RichTextanysame call
Rich text that must collapse / “read more”CollapsibleTextsame as abovesame call: it accepts the full RTE matrix
Single-line / multiline plain textbuilt-in Text / Headerplaintextnothing

The line between them: if an author can apply any formatting (bold, a link, a list, an embed) it is rich text and needs RichText. Only genuinely unformatted strings belong on Text/Header, which render String(text) and show markup as literal characters.

CollapsibleText is not a different content model: it is RichText plus expand/collapse. Choose it whenever the design truncates long copy behind “read more”, an accordion, or a “show details” toggle.

Embedded Entries and Embedded Assets: the Reason This API Exists

These two are what registerRTERenderer is for. A rich-text field that only holds paragraphs needs no registration at all. The moment an author embeds an entry or an asset, unregistered rendering degrades badly:

Without registrationWith a handler
Embedded entryrenders as a raw delivery-API span: title only, no field access, no markupyour card / component markup, with full entry fields
Embedded assetdefaultAssetRender fallback by MIMEyour markup (captions, lazy-loading, art direction, CDN transforms)

Embedded entries are stored as references, so their content must be resolved (see the next section). The handler receives the full delivery-API entry, plus contentTypeUid and displayType. Branch on contentTypeUid first, never on entry shape, because different content types often share field names.

displayType tells you how the author placed it, and each wants different markup:

displayTypeAuthor intentTypical markup
blocka standalone card in the flow of textfull card: image, heading, summary, link
inlinea mention inside a sentencea short inline span. Never a block-level element
linktext linking to the entryan <a> with the entry’s resolved URL

Returning block markup for an inline embed breaks the paragraph it sits in, the most common visual bug here.

Embedded assets need no fetch: their metadata travels inline in the RTE markup. Register embeddedAsset only when the default markup can’t express what you need.

Both handlers return HTML strings, not React: a returned JSX object stringifies to [object Object].

Embedded Items: Let the Fetch You Already Did Do the Work

Embedded entries are stored in the RTE as references, so their content must be fetched. Embedded assets are not: their metadata travels inline in the markup and never costs a request.

Two ways the entries get resolved, and the cheaper one is easy to miss:

RouteWhat happensCost
Pass the entry prop to RichTextResolves entry._embedded_items[<field>] via the $text CSLP tagno extra request
Pass only the RTE valueRefs are extracted, grouped by content type, and fetched in parallelone query per content type

So if the caller already fetched with .includeEmbeddedItems(), pass entry through: the embedded items are in the payload and re-fetching them is pure waste. Reach for the fetching route only when the RTE value arrives without its parent entry.

Embedded Assets: the Default is Usually Enough, and It is Guarded

defaultAssetRender branches on the asset’s MIME type, so a custom embeddedAsset handler is only needed for markup the default cannot express:

MIMERendered as
image/*<img>
video/*<video controls>
audio/*<audio controls>
anything else<a download>

If you replace it, keep the URL guard. The default blocks javascript:, vbscript: and non-image data: URLs and falls back to a plain-text placeholder. An asset URL is author-supplied content, so rendering one unfiltered into an href/src is an XSS vector. A hand-written embeddedAsset handler that interpolates the URL straight into markup silently drops that protection.

Deprecated Built-Ins: Do Not Reach for These

Number, JSONRTE and Section are deprecated and no longer appear in getBasicComponents / BASIC_COMPONENTS. For rich text use RichText (or CollapsibleText, which accepts the same RTE matrix). The built-in Text and Header now type their text prop as plaintext rather than string. Text renders String(text) and no longer parses HTML, so markup passed to it shows as literal characters: that is what RichText is for.

Common Pitfalls

PitfallWhy it bitesFix
Assuming this is JSON-RTE-only because of the nameregisterRTERenderer covers JSON RTE, HTML RTE and Markdown in one call. Treating HTML RTE as unsupported leads to hand-rolled parsingOne registration serves every rich-text field, whatever its storage format
Putting rich text through the built-in TextText renders String(text) and no longer parses HTML, so tags appear as literal <p> charactersUse the RichText component (typed any, auto-detects the payload)
Re-fetching embedded entries the caller already loadedWithout the entry prop the refs are fetched again, one query per content type, on every render pathPass entry so _embedded_items is used. Pair with .includeEmbeddedItems() at fetch time
Custom embeddedAsset handler that interpolates the URL directlyDrops the default’s scheme guard: javascript: / vbscript: / non-image data: URLs become an XSS vectorKeep the scheme check, or extend defaultAssetRender rather than replacing it
Using the deprecated Number / JSONRTE / Section basicsRemoved from BASIC_COMPONENTS. The @deprecated marker sits on the declarations so IDEs warnUse RichText for rich text. Number/Section have no replacement in the basics palette
Using registerJSONRTE for new codeDeprecated. registerRTERenderer is the unified canonical APIUse registerRTERenderer. Existing registerJSONRTE calls still work, migrate when you next touch the file.
Embedded entry renders blank or as a placeholderNo embeddedEntry handler is registered (default renders nothing for entry embeds)Register embeddedEntry: ({ entry, contentTypeUid, displayType }) => html
embeddedEntry returns a React element / JSXSerializer is HTML-string based. React objects stringify to [object Object]Return a plain HTML string from the handler
Calling registerRTERenderer more than onceEach call REPLACES the full config. Later calls drop earlier customElementTypes / embeddedEntryBuild a single merged config and call once at bootstrap. Or use registerJSONRTE for serializer-only updates (it preserves existing embeddedEntry/embeddedAsset), but the right answer is “register everything in one call.”
Registering too lateAfter props resolve, re-registering does not retroactively re-renderAlways register at bootstrap, before any composition mount
Wrong key name in customElementTypesKeys must match the raw type in entry JSON, not the editor’s display labelVerify against the actual entry JSON
Forgetting dangerouslySetInnerHTML on the manual pathThe page renders escaped HTML as visible textUse useStudioRichText if the SDK exports it. Otherwise render via dangerouslySetInnerHTML
Rendering the manual path without sanitizingRTE content is author-supplied, so unsanitized HTML is an XSS vector. useStudioRichText sanitizes, hand-rolled rendering does notRun DOMPurify before dangerouslySetInnerHTML, or upgrade to an SDK exporting the hook
Expecting embedded entries/assets to appear on the manual pathOnly the built-in components and useStudioRichText resolve embeds: a hand-serialized jsonToHtml call drops them silentlyUse the hook, or accept that embedded cards/media will not render
Registering standard types unnecessarilyParagraphs, headings, lists, links, marks, images, tables already renderOnly register to override or add
Branching embeddedEntry on entry shape instead of contentTypeUidFragile: entries may share field names across CTsUse contentTypeUid as the primary branch key
API-authoring an entry with a json_rte embed reference node whose attrs omits localeCMA rejects the write with "Reference must contain content-type-uid, entry-uid, locale and display-type.". The SDK’s read path never surfaces this because it’s a write-time validationEvery embedded-entry reference node under json_rte needs the full attr set: { type: "entry", "entry-uid": "…", "content-type-uid": "…", "display-type": "block"|"inline"|"link", "locale": "en-us" }. Applies both to node-level RTE fields and static_value.json_rte[*].value embeds (mirrors provision-studio-project § static_value sub-schema JSON-RTE row)

See Also