Rich text rendering

How a Contentful rich-text body becomes semantic HTML, typeset by the rich-text well and split into rows at its embeds.

2026-11-07

Overview

Editorial "rich text" is the long-form body an editor writes in Contentful — the article prose, the accordion panels, the download blurbs, the cells of a table block. Contentful stores it as a structured document (a tree of typed nodes: paragraphs, headings, lists, links, embedded assets and entries), NOT as an HTML string. Something has to walk that tree and turn it into page markup. That something is packages/content-app/src/lib/rich-text.tsx.

The renderer is an RSC, and dependency-free on purpose

RichText is a plain React Server Component. Its props:

Prop What it does
rich The document — a NormalisedRichText value from @v4/model.
embeds The rendered embedded entries, keyed by entry id, resolved by the page ahead of rendering.
nodes Render these nodes instead of the whole document — one segment of a body split at its embeds (below).
renderers table, to draw a table with a real component, and href, which decides where each link goes.
entryLinks Where each link to another entry goes, keyed by entry id, when the page resolved them itself.
externalOpensNewTab Open a link to another site in a new tab (off by default; the Text Block opts in).

It returns a ReactNode and pulls in no third-party rendering library:

import { RichText } from "../lib/rich-text";

<RichText rich={block.body} renderers={renderers} />;

The article body is split at its embeds

The article body does not render one RichText into one well. It splits the document with segmentAtEmbeds(view.body, (id) => embeds.has(id)) (news-article/index.tsx) into runs of ordinary nodes and single embeds; each run renders as <RichText rich={view.body} nodes={segment.nodes} … /> in its own ArticleRichTextWell, and each embed is its own row of the body, so rows take the body's rhythm rather than the well's paragraph margin. An embed that draws nothing — left out on the server — is not a split point: the copy either side runs on as one block.

The reason it is hand-rolled rather than delegating to Contentful's own @contentful/rich-text-react-renderer is deliberate: the official renderer is client-oriented, and the body here is rendered inside server components. A dependency-free walk over the node tree runs on the server with no "use client" boundary, no hydration cost, and no shipped renderer bundle. The whole thing is one short module: a switch over the node types and the two mark-and-link helpers around it.

Normalise once, render many

The renderer never touches Contentful's raw shape. Contentful's GraphQL API returns rich text as a { json, links } pair — a document plus separate maps of the entries and assets it links to. @v4/model's normaliseRichText flattens that pair once into a NormalisedRichText:

export interface NormalisedRichText {
  document: unknown; // the raw node tree (source.json)
  assetsById: ReadonlyMap<string, RichTextLinkedAsset>;
  entriesById: ReadonlyMap<string, RichTextLinkedEntry>;
  entryHrefs: ReadonlyMap<string, string>; // the site path of each entry-hyperlink target
}

assetsById and entriesById collapse Contentful's links.assets.{block, hyperlink} and links.entries.{block, inline, hyperlink} arrays into id-keyed maps, so a renderer resolves an embedded asset or entry by its sys.id without ever re-walking the link arrays. This is the "normalise once, never copy-paste per app" rule: apps consume NormalisedRichText, not the Contentful envelope.

Division of labour: semantic HTML vs typography

The renderer's single job is structure — it emits plain, unstyled, semantic HTML (<p>, <h2>, <ul>, <blockquote>, <table>, <a>…). It attaches no classes and carries no typography of its own.

The styling is owned entirely by the well the markup is rendered into: ContentBlockRichText from @v4/ghost-content (composed here as ArticleRichTextWell). That well is the RTE typography surface — it provides the heading ramp, body copy, link colours, block rhythm and divider rules through its theme, and typesets whatever semantic markup lands inside its children well.

This split is the "remote-foundations ruling". In Ghost – Content 2.0 the RTE elements (RTE-TextStyle, RTE-Divider) are instances of a remote foundations library that resolves from neither the content package nor @v4/ghost-core, so they are modelled as CSS rules inside the well rather than as components. The consequence for this guide: the renderer produces a bare <h2>, and the well's h2 rule — not the renderer — decides how it looks. Keep the two responsibilities separate and neither has to know about the other.

Node and mark mapping

The renderer is a single switch over each node's nodeType, recursing into node.content for children. Every node type it knows about maps to exactly one semantic element; anything it does not know about degrades safely (see the last row). Nothing here carries a class or a style — the surrounding ContentBlockRichText well typesets the output.

Block and inline nodes

Contentful nodeType HTML emitted
document fragment wrapping its children (RichText root)
paragraph <p>
heading-1 <h2> — see the h1 rule below
heading-2 <h2>
heading-3 <h3>
heading-4 <h4>
heading-5 <h5>
heading-6 <h6>
unordered-list <ul>
ordered-list <ol>
list-item <li>
blockquote <blockquote>
hr <hr />
table renderers.table's component, given the cells (tableCells); without one, <table><tbody>… in an overflow-x: auto <div>
table-row <tr>
table-header-cell <th>
table-cell <td>
hyperlink <a href={data.uri}>, through the href hook — see below; in a new tab where the surface opts in — see "Links to other sites"
entry-hyperlink <a href> to the entry's page — see "Links to other entries"
asset-hyperlink <a href={asset.url}>; the words unlinked when the asset has no URL
text the string value, wrapped by its marks (below)
embedded-asset-block inline <img> — see the embedded-entries guide
embedded-entry-block the rendered embed from embeds for its target id; nothing when there is none — see the embedded-entries guide
anything else degrades — see "unknown nodes" below

Where a surface passes renderers.table — the article body passes one that draws ArticleTable — the table is drawn by that component from its cells, read out by tableCells. Without it, the table case is the only one that wraps its element: the <table> is placed inside a <div style={{ overflowX: "auto" }}> so a wide CMS table scrolls within its own container rather than pushing the page sideways. It emits a <tbody> only — there is no <thead>, because Contentful models a header row as a normal table-row whose cells are table-header-cell (<th>).

Marks

A text node carries a value and an optional list of marks. Each mark wraps the accumulated output in one element, so multiple marks nest:

Contentful mark HTML element
bold <strong>
italic <em>
underline <u>
code <code>

An unrecognised mark is simply skipped — the text still renders, just without that wrapper.

An entry-hyperlink carries only the target entry's id (data.target.sys.id); the document holds no route for it. So the page resolves the targets itself, in ONE follow-up query — GetRichTextEntryLinks, keyed on every target id its blocks' copy carries (collectEntryLinkIds) — and hands RichText an entryLinks map of target id → href. The hrefs come from richTextEntryLinkHrefs in @v4/model, which runs each target through the same route table as a Link entry (entryHref, see the link-resolution reference), so a link in the copy and a call to action to the same entry go to the same page.

What the renderer has What it emits
the target in entryLinks <a href="/the/page">
entryLinks, but not this target <a href="#"> — unpublished, deleted, or a type with no page here
no entryLinks at all the model's own resolution, rich.entryHrefs (below)

Without an entryLinks map the renderer uses entryHrefs, which normaliseRichText resolved from the targets the fragment selected — the article body works this way. A target with no page there keeps its words, unlinked.

The query is a follow-up for the same reason the accordion embeds' is: a rich-text links list takes no limit, so selecting the targets inside a block fragment multiplies through contentBlocksCollection(limit: 15). The Text and Image block's Body Copy, on the Content and Home pages, is the surface that resolves them today; every other surface passes no map.

renderers.href receives each link's address as written — a hyperlink's uri, or an entry link's resolved href — and returns where it goes; table cells get it too. The article body (news-article/index.tsx) and every block's copy (prepare-blocks.tsx, linksIn) pass seasonedListingHref(href, season, sections), so a link to a LISTING names the current season and skips the listing's redirect, which emptied the page under the header and flashed the footer — a competition's news (/news/eurocup) too, which only the sections tell from an article. Every other address — another page, another site, # — is kept. With no hook, every link goes where it was written.

A hyperlink is an address and nothing else: Contentful gives a link typed into the copy no "Open in new tab". By default it opens in the same tab. A surface that passes externalOpensNewTab opens a link whose address is on another site (isOffSiteAddress in @v4/model: http(s):// or //) in a new tab, with rel="noopener noreferrer". A site path, #, mailto: and tel: keep the same tab. That is the test a Link entry's button applies when its own box is unset, so the two agree. It reads the address the href hook returns, which for another site is the address as written: the hook only rewrites site paths.

The Text Block's Body Copy is the only surface that passes it (a QA finding; user ruling, 22 Oct 2026). Every other copy (the article body, accordions, the Text and Image, table and download blocks) keeps the same tab.

The heading-1 → h2 rule

Both heading-1 and heading-2 render as <h2>. This is intentional, and it is a document-outline decision rather than a styling one:

case "heading-1":
case "heading-2":
  return <h2 key={key}>{children()}</h2>; // the page's h1 belongs to the header block

The page's single <h1> belongs to the article header block (the title, above the body), not to the editorial body. If an editor's heading-1 inside the body also emitted an <h1>, the page would have two top-level headings and a broken outline. Collapsing body heading-1 to <h2> keeps exactly one <h1> per page and starts the body's heading hierarchy one level down, where it belongs. Editors can therefore reach for "Heading 1" in Contentful without corrupting the page outline — the renderer normalises it.

Unknown nodes degrade, they never crash

The default branch of the switch catches any node type the renderer does not handle. Rather than throw, it renders the node's children inside a <span> if it has any, or nothing at all if it does not:

default:
  // an unrecognised node: render its children rather than crash — the
  // article still reads
  return node.content ? <span key={key}>{children()}</span> : null;

An article with a node type the renderer has not learned yet still reads cleanly; the unknown block is skipped, not fatal.

A fragment entry point for lifted content

Alongside RichText, the module exports renderRichFragment(nodes, rich, embeds?, renderers?, entryLinks?), which renders an arbitrary node list rather than a whole document, with the same optional maps and renderers. It exists because prepare-blocks.tsx lifts a table block's rows out of the rich-text document and into a TableBlock component: each cell's child nodes are rendered through renderRichFragment so the cell content keeps the same node-and-mark mapping as the rest of the body (the table surfaces pass neither map today).

Embedded entries and assets

Contentful lets an editor drop two kinds of embed into the middle of a rich-text body: an embedded asset (a media file — an image straight from the asset library) and an embedded entry (a reference to another content entry — a gallery, a video, a pull quote). Both arrive as nodes in the document tree, and both are resolved by id through the NormalisedRichText maps built at normalisation time (assetsById, entriesById).

Embedded assets render inline today

embedded-asset-block is handled directly by the renderer. It looks the asset up in assetsById by the node's target id, and — if the asset has a URL — emits a plain, responsive <img> carrying the asset's own dimensions and its description (falling back to the title, then to an empty alt):

case "embedded-asset-block": {
  const asset = rich.assetsById.get(node.data?.target?.sys?.id ?? "");
  if (asset?.url == null) return null;
  return (
    <img
      src={asset.url}
      alt={asset.description ?? asset.title ?? ""}
      width={asset.width ?? undefined}
      height={asset.height ?? undefined}
      style={{ maxWidth: "100%", height: "auto" }}
    />
  );
}

A missing or URL-less asset renders nothing rather than a broken image.

Embedded entries are rendered before the body, and placed by id

embedded-entry-block has its own case: it looks the node's target id up in the embeds map RichText is handed and places what it finds. The map is built BEFORE the renderer runs, so the renderer itself stays free of component imports:

  1. Model — bodyEmbedsFrom (@v4/model, article-page.ts) reads the body's linked entries and turns each one it can embed into a view with embedItem, the same mapping an Embed Block on a page uses.
  2. App — bodyEmbedNodes (@v4/content-app, body-embeds.tsx) prepares each view with prepareEmbed (embed-view.tsx) and renders it through the client Embed component (embed-render.tsx), keyed by entry id.
  3. Renderer — RichText places each rendered embed at its node. An entry with nothing to draw — an empty gallery, a video with no still or nothing to play, a type no surface on the site renders — is OMITTED from the map, so its node renders nothing.

In the article body (and an accordion section's body) the third step happens one level up: the body is split with segmentAtEmbeds and each drawn embed becomes its own row beside the rich-text segments, rather than a node inside one rich-text tree (see the Overview). Only top-level embeds are lifted out; the segments still receive embeds, so an embed nested deeper (in a list item, say) is placed by its node as above. An accordion section's body is split the same way (prepare-blocks.tsx).

In the body an embed sits bare in the reading column and takes the PAGE's body colour way (bodyEmbedNodes(embeds, labels, way)); it has no theme of its own there. On a page, an Embed Block wraps the same embed in a full-bleed EmbedBlock band that takes the block's Theme.

These are the embed-* components in @v4/ghost-content an embedded editorial entry maps onto:

Component Contentful/Ghost name What it renders
EmbedBlock Embed-Block A full-bleed painted band that centres a capped measure around one image embed.
EmbedImage Embed-Image A <figure> with a caption panel below the image. An Image Embed draws it 16:9, as ELB does in the body and the Embed Block: from lg six columns wide, the body copy's width. The component's own default shape is 4:3.
EmbedGallery Embed-Gallery An in-article image carousel — a titled, themed band over a scroll-snapping image track.
EmbedVideo Embed-Video A Video Item's still (its Image, else the video JSON's own thumbnail) with a play button, the title, and the running time beside the Sub-title (with no Sub-title, the title alone); it plays in place when selected (EmbedVideoPlayer + provider-player.ts).
EmbedSocial Embed-Social A container-width well that centres third-party embed markup — a container, never an embed engine.
EmbedPullQuote Embed-PullQuote A full-width painted band with a decorative quote mark, the quotation and an optional attribution.

Two things are worth keeping straight when reading these:

  • EmbedSocial is a well, not an engine. It never fetches, sanitises, resolves oEmbed, or injects a provider <script> — the consumer supplies the embed markup as its slot content. The same "compose the real component" discipline applies to EmbedVideo, which composes ghost-core's real Video for the still, its play button and the barrier rather than re-implementing them. The app's EmbedVideoPlayer swaps the provider's player (Mux, else YouTube) in for the still on the visitor's click, and EmbedVideo draws nothing over it once it plays.

  • The renderer stays dependency-free and structure-only regardless. It only places what bodyEmbedNodes rendered; each embed-* block owns its own theme, exactly as ContentBlockRichText owns the typography of the surrounding prose.

Where standalone content blocks fit

Do not confuse an embedded entry (an entry referenced from inside a rich-text body) with a top-level content block. An article body is also composed of a list of ContentBlockViews — text blocks, table blocks, accordions, downloads, carousels — prepared server-side in prepare-blocks.tsx and each rendered by its own ghost-content component. Those blocks each carry their own rich-text body, which is rendered through the same RichText / renderRichFragment renderer described in this guide. The embed-* components above are the in-flow embeds that live within that prose; the block list is the structure around it.