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.
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.
Links to other entries
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.
Where links go: the href hook
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.
Links to other sites
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:
- Model —
bodyEmbedsFrom(@v4/model,article-page.ts) reads the body's linked entries and turns each one it can embed into a view withembedItem, the same mapping an Embed Block on a page uses. - App —
bodyEmbedNodes(@v4/content-app,body-embeds.tsx) prepares each view withprepareEmbed(embed-view.tsx) and renders it through the clientEmbedcomponent (embed-render.tsx), keyed by entry id. - Renderer —
RichTextplaces 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:
EmbedSocialis 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 toEmbedVideo, which composes ghost-core's realVideofor the still, its play button and the barrier rather than re-implementing them. The app'sEmbedVideoPlayerswaps 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
bodyEmbedNodesrendered; eachembed-*block owns its own theme, exactly asContentBlockRichTextowns 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.