Skip to content

Rendering rich text

A richtext field stores what the editor wrote, in the format its schema chose: an HTML string by default, a Markdown string, or a ProseMirror JSON document (see Rich text). Two things stand between that value and your page: the images inside it, and sanitizing.

When an editor inserts an image, Studio stores it with the id of the media it shows:

<p>The harvest began at dawn.</p>
<img src="/api/media/media-8f3a1c/variants/medium" alt="Harvest at dawn" data-trokky-id="media-8f3a1c" data-trokky-variant="medium">
  • HTML: an <img> whose src is the API’s own path, plus data-trokky-id and data-trokky-variant.
  • Markdown: ![Harvest at dawn](/api/media/media-8f3a1c/variants/medium).
  • ProseMirror: an image node whose src is a placeholder, [trokky-image:media-8f3a1c], with the variant in data-trokky-variant.

Content written before late 2025 may hold placeholders instead: [trokky-image id="media-8f3a1c" variant="medium" alt="…"].

The stored src works only on the CMS’s own origin, and only while the API stays at that path. Pass rich text through resolveContent() before rendering:

---
import { client } from '../lib/trokky'
const post = await client.from('post').eq('slug', Astro.params.slug).first()
---
<div class="prose" set:html={client.resolveContent(post.body)} />

resolveContent() turns placeholders into images, and with a media base rebuilds every stored image from its media id and variant. It takes HTML, Markdown or a ProseMirror document and returns the same kind, and empty content gives ''. Images from elsewhere are left as they are.

For Markdown, resolve first, then render with a Markdown renderer. For ProseMirror, resolve first, then render the document to HTML yourself, for example with generateHTML from @tiptap/html and the extensions your editor uses.

In React, useResolvedContent(client, content) from @trokky/client/react memoises the same call.

mediaBaseUrl on the client says where browsers load media from:

export const client = new TrokkyClient({
baseUrl: import.meta.env.TROKKY_API_URL,
apiToken: import.meta.env.TROKKY_API_TOKEN,
mediaBaseUrl: '/media', // a proxy route on this site, or a full URL
})

Every image then comes from there. resolveContent() rebuilds the ones inside rich text from their media id, whatever path or host they were saved with, and media fields come from imageUrl() and createImageUrlBuilder(). If the API moves, or you put a proxy or a CDN in front, you change this one value. Stored content never needs rewriting. resolveContent(body, { mediaBaseUrl }) does the same for one call.

Without mediaBaseUrl, images stored in rich text keep the path they were saved with (/api/media/…), which works when the pages and the API share an origin. Media fields and placeholders point at baseUrl. So set mediaBaseUrl whenever the frontend is on another origin, or baseUrl is an address browsers cannot reach, such as http://localhost:3000 on the server. See Media on the frontend.

mediaBaseUrl and rebuilding from the id arrived in @trokky/client 3.5.3.

set:html and dangerouslySetInnerHTML insert the stored HTML as it is, scripts included. Studio cleans what editors paste, but the server does not sanitize rich text when a document is saved. Anything that can write content through the API, whether an API token, an import script or an AI agent, can store any HTML it likes.

If every writer is someone you would trust with your site’s JavaScript, rendering as above is fine. Otherwise sanitize at render time, after resolving:

import sanitizeHtml from 'sanitize-html'
const html = sanitizeHtml(client.resolveContent(post.body), {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
allowedAttributes: { ...sanitizeHtml.defaults.allowedAttributes, img: ['src', 'alt', 'title', 'width', 'height', 'class'] },
})

Sanitize after resolving, not before. A sanitizer strips the data-trokky-id that resolving reads, and resolving placeholders afterwards would produce tags it never checked.