dummytxt
← Blog
By Maas Mirzaa·7 min read

Contentful vs. Sanity vs. Payload: How Each Structures Rich Text

Three headless CMS platforms, three different rich-text document shapes. A side-by-side reference for anyone building a renderer against more than one of them.

ContentfulSanityPayload

"Rich text" means something different in each of these three platforms, and the difference is not cosmetic. It determines how you write the component that turns stored content into rendered HTML, and it is the single most common source of a renderer that works in testing and breaks the first time an editor uses a feature the developer never saw in a demo entry.

Contentful: a typed document tree

Contentful's rich text field stores a JSON document tree with an explicit nodeType on every node: paragraph, heading-1 through heading-6, unordered-list, blockquote, and so on. Marks like bold, italic, and code are applied as an array on text nodes rather than as nested node types, so a bold italic word is one text node with a marks array containing both, not two nested wrapper nodes.

The distinction developers most often miss is between block-level and inline embedded content: BLOCKS.EMBEDDED_ASSET for a standalone image and INLINES.EMBEDDED_ASSET for one embedded mid-paragraph. Most renderers handle the first and silently drop the second. Tables arrived in Contentful's schema later than the other node types, so older renderers frequently have no case for table, table-row, or table-cell at all.

Sanity: Portable Text, block-based and extensible

Sanity's Portable Text is a flatter array of block objects, most commonly type: "block" for text content, where each block has a style (normal, h1, blockquote, and so on) and a children array of text spans, each span carrying its own marks array. Lists are represented via a listItem property on a block rather than a distinct list node wrapping several items, which trips up renderers that expect an explicit list container.

The bigger difference is that Portable Text has no built-in table type. Tables, and genuinely any custom content block, are added by the content modeler as a custom object type in the schema, meaning the renderer needs a case for whatever the schema defines, not a fixed set of node types from the platform. A renderer copied from another Sanity project will silently miss any custom block type unless it is explicitly extended to handle it.

Payload: a Lexical-based JSON tree

Payload's rich text editor is built on Meta's Lexical framework, and the stored format is Lexical's own JSON node tree: a root node containing a nested structure of paragraph, heading, list, and listitem nodes, with formatting stored as a numeric bitmask on text nodes rather than a named marks array. Bold and italic together is a single number, not two flags, which is a common source of bugs when a renderer written against Contentful or Sanity's conventions is ported to Payload without adjusting for the bitmask.

Payload's advantage is that Lexical nodes are extensible at the schema level, so blocks like embedded relationships, layout components, or custom formatting are added as first-class node types rather than bolted on. The renderer needs an explicit case for every custom node type the Payload config defines, in the same way a Sanity renderer needs a case for every custom object type.

The practical takeaway

All three ultimately render to HTML, and a comprehensive block of target HTML, headings, lists, inline marks in combination, a table, a blockquote, a code block, is a genuinely useful reference regardless of which platform you are on. Write your test content in each platform's editor to reproduce that HTML structure, run it through your renderer, and compare the output. The gaps are exactly the node types your renderer has not handled yet.

If you maintain renderers for more than one of these platforms, keep a single shared HTML fixture and treat it as the spec both renderers should reproduce. It is far easier to diagnose "the Payload renderer is missing table support" against a known-good HTML target than to compare two renderers' output against each other.

Quick reference

Contentful: typed nodeType tree, marks as an array on text nodes, explicit block vs. inline distinction for embeds. Sanity: flat block array, style per block, no native table type, custom blocks defined per schema. Payload: Lexical JSON tree, formatting as a numeric bitmask, custom node types defined per config.