Site icon Ampersand Tutorials

Rendering Payload Rich Text in React with Lexical (2026)

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.

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:

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:

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.

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:

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:

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

SymptomCause and fix
Rich text renders as emptyThe 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 rendererThe 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 internallyThe renderer treats all links as plain anchors — distinguish internal document links from external URLs
Image is missing alt text or captionThe 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 postsToo much depth or per-block fetching — fetch relationships up front and keep handlers focused
Generated types and stored content disagreeThe 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 differentThe renderer and editor are styled differently on purpose — decide whether that is intended and document it

10. Key takeaways and challenge

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.

Exit mobile version