dummytxt
← Blog
16 December 2025·7 min read

What Your Headless CMS Rich-Text Renderer Is Missing

Contentful Rich Text and Sanity Portable Text look simple. They're not. Here are the node types and edge cases most renderers quietly skip until an editor finds them in production.

ContentfulSanityHeadless CMS

Building a rich-text renderer for a headless CMS seems like a straightforward task on paper. Contentful gives you a document tree. Sanity gives you Portable Text. You write a component that maps each node type to a React element, run it against a few test entries, everything renders, and you ship it.

Then an editor discovers what yours is missing. The embedded asset that renders nothing because you handled images in BLOCKS.EMBEDDED_ASSET but not in INLINES.EMBEDDED_ASSET. The table that throws because you forgot tableRow and tableCell in your node map. The ordered list inside an unordered list that your renderer flattens because you assumed lists do not nest. The bold italic link that loses its italic because your mark renderer applies transforms sequentially and the last one wins.

These are not obscure edge cases. They are the predictable output of editors who have been given a rich-text field and told to write.

The nodes developers routinely miss

Embedded assets in inline position

Contentful's rich-text field distinguishes between block-level embedded entries and assets, and inline-level ones. Most renderers handle BLOCKS.EMBEDDED_ASSET (a standalone image block) but miss INLINES.EMBEDDED_ASSET (an image embedded within a paragraph). Editors use inline assets for icons, logos, and inline figures — if your renderer does not handle the inline variant, those assets simply disappear without any visible error.

Horizontal rules

The hr element is available in most rich-text editors and is used more often than developers expect. It tends to be the last node type added to a renderer and the first one forgotten. An unhandled HR node in Contentful's document format renders nothing — no error, no fallback, just a silent gap where a section divider should be.

Tables

Table support was added to Contentful's rich-text field gradually and is absent in many older renderers. Sanity's Portable Text does not include a native table type — tables are typically handled via custom block schemas — but the same problem applies: if your renderer was built before tables were part of the schema, they are likely unhandled. Unlike missing hr nodes, a missing table renderer often throws rather than failing silently, which is how most teams discover the gap.

Code blocks

Contentful's rich-text includes a code mark for inline code and a separate code block type. Many renderers handle the inline mark but miss the block variant. The block variant is what editors use when they paste in multi-line code samples, and it is the one that benefits most from monospace rendering and overflow handling.

The mark combination problem

Rich-text marks — bold, italic, underline, code, link — can be applied in any combination, and the order in which they are applied is not guaranteed. An editor can select text and apply bold, then italic, then link. Another editor applies italic, then bold, then link, and ends up with the same visual result but a different mark order in the document structure.

Renderers that apply marks by wrapping elements sequentially can produce incorrect output when marks nest in unexpected orders. The most common failure is a link losing its underline or colour because the em or strong wrapper resets the cascade. Testing with a single bold word or a single link never surfaces this. It only appears when all three marks are applied to the same text, which editors do constantly.

The adjacent node problem

Rich-text documents can place any node type adjacent to any other node type. Two unordered lists in sequence — because an editor added a new list without realising they were not extending the existing one — are a common example. In HTML these render as two separate ul elements with whatever margin is set on the element. In your React renderer, they both call the same list component, and if that component has a margin-top rule, the gap between them is doubled.

Other common adjacency issues: a blockquote followed immediately by another blockquote (editors quote multiple sources), an image block followed immediately by a heading (no paragraph between them), and a code block at the very start of the document before any paragraph has appeared.

The reliable way to find adjacency issues before editors do is to test your renderer against content where every block type appears at least twice in a row, and where block-level elements appear at the start and end of the document without surrounding paragraphs.

Using HTML as a spec

One practical approach to renderer testing is to use comprehensive placeholder HTML as the expected output and work backward from it. Generate a block of HTML that covers every element your schema supports — headings, lists, inline marks in combination, figures, blockquotes, tables, code blocks, horizontal rules — and treat it as the spec your renderer should produce.

Write your Contentful or Sanity content to reproduce that HTML structure, run it through your renderer, and compare the output. The gaps between the generated HTML and your renderer's actual output are exactly the node types and edge cases you have not handled yet. This is more reliable than trying to enumerate all possible node types from the documentation, because documentation tends to lag behind what editors can actually produce.

Rich-text renderers are one of those components that feel done long before they are. The only way to know what yours is missing is to give it the full range of content your editors can produce and see what breaks. Doing that before launch is considerably less stressful than doing it after.


From the developer

React, Next.js & building for the web

Maas Mirzaa — the developer behind dummytxt — writes about frontend architecture, ecommerce development, and lessons from 21+ years of shipping production web apps.

mirzaa.dev/blog