Quick answer: Payload stores rich text as structured blocks, not HTML, so rendering is the job of a small React converter that walks the block tree and maps each node type to real components. For Lexical-rich collections, Payload’s generated types describe the exact block shapes your content can take, which means the renderer can be typed against your own schema instead of parsing a fragile HTML string. The reward is components you control — custom blocks, related-document cards, inline images with your own layout — that the editor already assembled in the admin panel.
Table of Contents
Part 5 of our Payload CMS series. Start with what Payload CMS is, then install Payload 3 and build your first collection, then authentication and role-based access control, then deploying Payload to production before rendering.
1. Why rich text is not “just HTML”
In a traditional CMS, the editor emits HTML and the front end echoes it. That works until you want a related-author card, an inline image with a caption component, or a callout block that should look different on the site than in the editor. Then the HTML is a tangle of classes and embedded markup you did not fully own.
Payload’s Lexical-rich text is stored as structured data. The editor builds a tree of nodes; Payload persists that tree; your renderer decides what each node becomes on the site. That is more work up front than dumping HTML, but it turns rich text from a string you trust to a data structure you can render deliberately.
Two consequences matter immediately:
- The editor and the site can diverge visually on purpose. A callout block can be plain text in the editor and a styled card on the site.
- Custom blocks are first-class content. A block is not a plugin you install into the CMS — it is a node type you define in the field and render yourself.
2. What Payload actually stores for Lexical content
When a collection field uses type: 'richText' with the Lexical editor, the stored value is a serialized node tree, not a <p>-filled string. The generated TypeScript types from payload.config.ts describe that shape, which is the important part: your renderer can import those types instead of guessing.
At a high level, the tree is made of root, paragraph, text, and whatever custom or feature nodes you enabled — headings, lists, images, links, and any custom block you registered. The renderer’s job is to walk that tree and return React for each node.
The useful mental model is: the editor owns structure and intent; the renderer owns presentation. The same block can be a simple paragraph on one page and a styled component on another if you want it to be.
3. The core render pattern
A Lexical-rich field value is a tree. Rendering it is a recursive walk where each node type maps to a component. The exact shape and helpers come from Payload’s generated types and the rich-text utilities the framework ships for Lexical, but the structure of the renderer is always the same:
- Receive the field value.
- Return nothing for empty values.
- Walk the root and its children.
- Dispatch on node type: paragraph, heading, list, list item, image, link, custom block, text.
- Let unknown nodes fall back to a safe default or error.
That sounds more formal than it is. The renderer is usually a small component with a switch or map of node handlers.
// components/PayloadRenderer.tsx
import type { PayloadDocs } from '@payloadcms/lexical-editing-block-renderer'
import { LexicalEditor } from '@payloadcms/richtext-lexical/renderer'
type Props = {
value: PayloadDocs['myRichField'] | null | undefined
}
export function PayloadRenderer({ value }: Props) {
if (!value?.root) return null
return <LexicalEditor content={value} />
}
The point of showing this is not the exact import path — those evolve — but the shape: the renderer is a component that receives structured content and returns React. Once you have that component, the rest is deciding what each node should look like.
4. Custom blocks are where this gets useful
Hooks decide what turns into structured content over time; for the automation path around insertion and updates, see hooks and automation.
Custom blocks are the reason many teams choose Lexical over a plain WYSIWYG field. You define a block in the collection schema, the editor gets a palette of content types, and the stored value includes those blocks as structured nodes. The renderer then renders each block as a real React component.
A simple example: a post content field that can contain text, headings, and an “Author spotlight” block carrying a relationship to a user plus a short quote.
// collections/Posts.ts — excerpt
import type { CollectionConfig } from 'payload'
const AuthorSpotlight = {
slug: 'authorSpotlight',
fields: [
{ name: 'quote', type: 'text' },
{ name: 'author', type: 'relationship', relationTo: 'users' },
],
}
export const Posts: CollectionConfig = {
slug: 'posts',
fields: [
{ name: 'title', type: 'text', required: true },
{
name: 'content',
type: 'richText',
editor: lexicalEditor({
blocks: [AuthorSpotlight],
}),
},
],
}
In the renderer, that block becomes a component you fully control:
function AuthorSpotlightBlock({ block }: { block: AuthorSpotlightBlockType }) {
return (
<blockquote className="author-spotlight">
<p>{block.quote}</p>
{block.author && (
<cite>
<a href={`/authors/${block.author.slug}`}>{block.author.name}</a>
</cite>
)}
</blockquote>
)
}
That is far more useful than embedding an author caption as inline text that later has to be parsed out of HTML. The relationship is already a real field with its own access rules and types.
5. Links, images, and relationships inside rich text
A rich-text field is not only paragraphs. Links can point at internal documents or external URLs; images can be uploads with alt text and captions; relationships can be embedded directly when the editor supports them. Each of those is a node the renderer should treat intentionally.
For links, the renderer should distinguish internal from external and decide whether internal links become <Link> components that drive client navigation, or plain anchors for external targets. For images, the renderer should render the component you want — with your own caption markup, lazy loading, and layout — rather than trusting whatever markup the editor emitted.
For embedded relationships, the renderer should respect access control. A rich-text block that references a document the current viewer cannot read should either omit the sensitive parts or fail safely, depending on the product decision. This is one of the places where “render everything the editor saved” is not always the right default.
6. Nested content and depth
Rich text can contain more than top-level blocks. Lists contain list items; definition lists contain terms and descriptions; custom blocks can themselves contain arrays of fields. The renderer has to handle nesting, not just top-level nodes.
The practical rule: write a recursive or stack-based walker that descends into children, and keep node handlers small. A handler for a list should render its items, not try to know every possible child. A handler for a custom block should render that block’s own fields and delegate anything nested to the generic rich-text renderer.
This is the same discipline as any component tree: each node renders its own slice and hands the rest down.
7. Server components, client components, and where rendering lives
Payload content is usually read on the server with the Local API, so the natural home for the renderer is a React Server Component. That keeps the content fetch and render on the server, with no client bundle needed for the basic walk.
Custom blocks can push you toward client components when they need interaction — a tab switcher, an image lightbox, an accordion. The clean pattern is:
- The server component fetches the content.
- The server renderer walks the tree and returns React.
- Interactive blocks are isolated client components that the renderer imports only where needed.
That keeps the rich-text renderer itself server-side while letting individual blocks own their interactivity.
8. Performance: depth, size, and what to watch
Rich text is usually small, but it can grow: long posts, many custom blocks, nested lists, embedded relationships expanded with depth. A few things are worth watching:
- Depth on the query. Fetching relationships inside rich content can pull more than you need if depth is set too high. Fetch what the renderer actually uses.
- Large blocks. A custom block that embeds a lot of nested data can bloat the response. Model blocks with the fields they actually need.
- Rendering cost. A simple recursive renderer is cheap. A renderer that loads data for every block on the fly can become slow; prefer fetching relationships up front when possible.
The goal is not to over-optimize before you have content. The goal is to avoid a renderer that silently does extra work for every block it walks.
9. The errors you will actually hit
| Symptom | Cause and fix |
|---|---|
| Rich text renders as empty | The value is null/undefined, or the field type does not match what the renderer expects — check the generated types and the stored shape |
| Custom block is missing in the renderer | The block was added to the config but the renderer does not handle its slug — add a handler for the new block type |
| A link renders but does not navigate internally | The renderer treats all links as plain anchors — distinguish internal document links from external URLs |
| Image is missing alt text or caption | The renderer does not read the image node’s metadata fields — map the upload node’s alt and caption into the component |
| Rendering slows down on long posts | Too much depth or per-block fetching — fetch relationships up front and keep handlers focused |
| Generated types and stored content disagree | The config changed but types were not regenerated, or content was created before the current schema — regenerate types and review stale content |
| Editor content looks fine but the site looks different | The renderer and editor are styled differently on purpose — decide whether that is intended and document it |
10. Key takeaways and challenge
- Payload stores rich text as structured nodes, not raw HTML, so rendering is a deliberate React walk over a tree.
- Lexical is the current recommended editor; the generated types describe the content shape your renderer should expect.
- Custom blocks turn rich text into real components — author spotlights, callouts, embeds — that the editor already assembled.
- Links, images, and embedded relationships should each be rendered intentionally, not as generic markup.
- Keep the core renderer server-side; push interactivity to isolated client components when a block needs it.
- Fetch only the depth you render; a renderer that lazily loads data per block can get slow.
- Treat editor appearance and site appearance as separate decisions — they can differ on purpose.
Challenge: extend the Posts collection from the earlier articles with a content rich-text field that includes paragraph, heading, list, and a custom Tip block containing a short label text field and a body rich-text field of its own. Write a typed renderer that renders each node and nests the Tip.body through the same renderer so nested rich text does not get a separate, duplicated handler. Then add one client-only interactive block — for example, a foldable “Read more” block — and keep the rest of the renderer in a server component. Write down which node types your renderer handles and which ones would silently drop content if you forgot them.
Building a content site and want help designing the block model and renderer together? Ampersand Academy offers one-to-one mentoring on TypeScript and Next.js backends — from Mahadhi, a development and digital marketing company.
Does Payload store rich text as HTML?
No. With Lexical, Payload stores a structured node tree. That is what lets the renderer render custom blocks and embedded relationships as real React components instead of parsing markup.
Do I have to write my own renderer?
You do. Payload gives you the editor and the stored shape, and the site decides how nodes become React. That is the tradeoff for owning the presentation.
What is a custom block in this context?
A block is a node type you define in the rich-text field. It carries its own fields such as text, relationships, nested rich text or uploads, and the editor treats it as a content type the author can insert. The renderer renders it as a React component you control.
Can rich text reference other documents?
Yes, when the field and editor support embedded relationships or links. The renderer should handle those nodes intentionally, including whether internal links become client navigation or external links stay plain anchors.
Should rendering happen on the server or the client?
Start on the server. Fetch content with the Local API in a server component and walk the tree there. Move only interactive blocks such as a tab switcher or accordion to client components when they need it.
How do I keep the renderer fast?
Fetch only the relationships and depth the renderer actually uses, keep node handlers small, and avoid per-block lazy fetching for large content. A simple walker is cheap; the cost usually comes from over-fetching or per-block queries.
What if the editor and the site look different?
That can be intentional. The editor owns structure and the site owns presentation. If the difference is deliberate, document it so future editors and developers do not treat it as a bug.

