Rendering Contentful Rich Text with Embedded Entries
This guide belongs to the Contentful Integration Guide and covers rendering Contentful’s rich text field in a React frontend: mapping document nodes to components, resolving embedded and inline entries and assets from the response’s link data, handling types the renderer does not know, and keeping the output accessible and fast.
Contentful rich text is a JSON document tree, not HTML. Paragraphs, headings, lists, quotes and tables are nodes with children; text nodes carry marks such as bold or code; and three node types reference other content: embedded-entry-block, embedded-entry-inline and embedded-asset-block, plus hyperlinks to entries. The document stores only the ids of referenced content. To render them, the frontend must fetch the referenced entries and assets, and map them back into the tree.
The Problem
A travel magazine rendered rich text with the default renderer and no custom node mapping. Embedded “tip box” entries and image assets rendered as nothing, because the renderer does not know how to display arbitrary entries. A developer added a mapping that fetched each embedded entry separately by id inside the renderer, which worked but made articles with twelve embeds perform twelve extra API requests during rendering, and failed completely during a rate-limit incident. Inline links to other articles pointed at entry ids rather than article URLs.
How Rich Text Rendering Works
Fetch links with the document. The GraphQL API returns rich text fields as json plus a links object containing the embedded and hyperlinked entries and assets, with whatever fields you select for each type. REST responses include linked entries in includes when include depth is sufficient. Either way, one request returns everything the renderer needs.
Build id maps. Before rendering, build maps from id to entry and id to asset from the links section. The renderer then looks up each embedded node’s target by id in constant time.
Map node types to components. Use @contentful/rich-text-react-renderer with a renderNode map for block nodes, a renderMark map for marks, and custom handling for embedded entries that dispatches by content type to your components.
Handle unknown types gracefully. An embedded entry of a type the frontend does not know should render nothing and log a warning, just like unknown page-builder blocks.
Implementation
The query selects the rich text JSON and the fields needed for each embedded type. Inline fragments per content type keep the payload small.
query Article($slug: String!, $locale: String!) {
articleCollection(where: { slug: $slug }, locale: $locale, limit: 1) {
items {
title
body {
json
links {
entries {
block {
sys { id }
__typename
... on TipBox { heading text }
... on ProductTeaser { name slug image { url width height description } }
}
inline { sys { id } __typename ... on Article { title slug } }
hyperlink { sys { id } __typename ... on Article { slug } }
}
assets { block { sys { id } url width height description contentType } }
}
}
}
}
}
The renderer builds id maps and dispatches embedded entries by __typename.
// components/rich-text.tsx
import { documentToReactComponents, type Options } from "@contentful/rich-text-react-renderer";
import { BLOCKS, INLINES, MARKS, type Document } from "@contentful/rich-text-types";
import { TipBox } from "./tip-box";
import { ProductTeaser } from "./product-teaser";
import { entryUrl } from "@/lib/routes";
type Linked = { sys: { id: string }; __typename: string; [k: string]: unknown };
interface Links { entries: { block: Linked[]; inline: Linked[]; hyperlink: Linked[] }; assets: { block: Linked[] } }
export function RichText({ json, links, locale }: { json: Document; links: Links; locale: string }) {
const byId = (list: Linked[]) => new Map(list.map((e) => [e.sys.id, e]));
const blocks = byId(links.entries.block);
const inlines = byId(links.entries.inline);
const hyperlinks = byId(links.entries.hyperlink);
const assets = byId(links.assets.block);
const options: Options = {
renderMark: { [MARKS.CODE]: (text) => <code>{text}</code> },
renderNode: {
[BLOCKS.EMBEDDED_ENTRY]: (node) => {
const entry = blocks.get(node.data.target.sys.id);
switch (entry?.__typename) {
case "TipBox": return <TipBox heading={entry.heading as string} text={entry.text as string} />;
case "ProductTeaser": return <ProductTeaser {...(entry as never)} />;
default:
console.warn(JSON.stringify({ kind: "unknown_embedded_entry", type: entry?.__typename ?? "missing" }));
return null;
}
},
[BLOCKS.EMBEDDED_ASSET]: (node) => {
const a = assets.get(node.data.target.sys.id);
if (!a) return null;
return <img src={`https:${String(a.url).replace(/^https?:/, "")}?w=1200&fm=webp`} width={a.width as number} height={a.height as number} alt={(a.description as string) ?? ""} loading="lazy" />;
},
[INLINES.EMBEDDED_ENTRY]: (node) => {
const e = inlines.get(node.data.target.sys.id);
return e ? <a href={entryUrl(e.sys.id, locale)}>{e.title as string}</a> : null;
},
[INLINES.ENTRY_HYPERLINK]: (node, children) => {
const e = hyperlinks.get(node.data.target.sys.id);
return e ? <a href={entryUrl(e.sys.id, locale)}>{children}</a> : <>{children}</>;
},
},
};
return <>{documentToReactComponents(json, options)}</>;
}
entryUrl resolves the linked entry’s path in the current locale from the route manifest, so links follow slug changes and translations automatically. Links to entries that are not published, which Contentful omits from links, render as plain text rather than broken links.
Headings, tables and accessibility
Map heading levels deliberately: if the page title is the only h1, rich text heading-1 nodes should render as h2 or be disallowed in the field’s validation. Tables need header cells, which Contentful marks as table-header-cell; render them as th with scope. Restrict the field’s allowed node types and marks in the content model so editors cannot create structures the design does not support.
Rich text for other outputs
The same document often needs other renderings: plain text for meta descriptions and search indexes, reading time estimates, excerpts for listing cards, and sometimes email or RSS HTML. Write small, separate walkers for these rather than rendering React to strings and stripping tags. The official documentToPlainTextString helper covers plain text; excerpts can take the first paragraphs until a word budget is reached, skipping embedded entries. Keep these helpers next to the React renderer and test them with the same fixture, so a new node type is handled everywhere at once.
Configuration Reference
| Aspect | Recommendation | Why |
|---|---|---|
| Data fetching | json plus links in one query | No per-embed requests. |
| Embedded entries | dispatch by content type, unknown renders nothing | New types never break pages. |
| Assets | dimensions and description from links | No layout shift, sensible alt text. |
| Entry links | URLs from the route manifest | Follow slugs and locales. |
| Allowed nodes | restricted per field in the model | Only what the design supports. |
| Heading levels | shifted or restricted | One h1 per page. |
Gotchas & Edge Cases
- Link limits. The GraphQL
linksresolution counts towards query complexity; articles with very many embeds may need a higher complexity budget or pagination. - Protocol-relative asset URLs. Contentful asset URLs start with
//. Prefixhttps:before use. - Localized links. Embedded entries resolve in the query’s locale; missing translations follow the space’s fallback settings.
- Whitespace and empty paragraphs. Editors create empty paragraphs; filter them to avoid stray spacing.
Worked Example
The travel magazine rewrote its rich text rendering with a single query per article returning JSON and links, id maps, typed components for tip boxes, product teasers and galleries, and manifest-based URLs for entry links. Article rendering went from up to thirteen API requests to one, embedded content rendered consistently, and an incident with rate limiting later that year affected no article pages. Editors gained two new embeddable types over the following months, each added by writing a component and a fragment.
Testing Rich Text Rendering
Rich text combines content and code in ways that fixtures capture well. Keep a fixture document per content type that uses every allowed node type, every mark, each embeddable entry type, an embedded asset, an inline entry, an entry hyperlink to a published and an unpublished entry, and an unknown embedded type. Render it in a component test and snapshot the output, and assert specific behaviours: unknown types render nothing and log, unpublished links render as text, headings are shifted, images carry dimensions. When editors are allowed a new node type or embed, add it to the fixture in the same pull request as the renderer change.
Rollout Checklist
- Query rich text JSON with links and the fields each embedded type needs.
- Build id maps and render embedded content through typed components.
- Resolve entry links through the route manifest in the current locale.
- Render unknown embedded types as nothing, with a logged warning.
- Restrict allowed nodes and marks in the content model.
- Test with a fixture covering every node and embed type.
Frequently Asked Questions
Can we render rich text to HTML strings instead?
Yes, with the HTML renderer, but React components give you typed embeds, framework features such as image optimization and easier testing.
Should embedded entries be allowed at all?
Yes, for editorial richness, but restrict them to a small set of content types the design supports and review the list when the design changes.
How do we handle very long documents?
Render on the server so no rich text JavaScript ships to the browser, keep embeds light, and lazy-load heavy embeds such as galleries or videos.
How do we style rich text consistently?
Style the semantic elements the renderer outputs in one scoped stylesheet for prose, and style embedded components with their own component styles, so editorial content and embeds stay visually consistent.
Does rich text work with live preview?
Yes; the preview SDK updates the document and links as editors type. Keep the renderer pure so updates render consistently.