Simplify your stack and build anything. Or everything.
Build tomorrow’s web with a modern solution you truly own.
Code-based nature means you can build on top of it to power anything.
It’s time to take back your content infrastructure.
AuthorNick Vogel

How to render rich text from Payload in a Next.js frontend

Community Guide
AuthorNick Vogel

Payload's rich text editor is built on Lexical. To show this content in your Next.js app, you'll need to convert it into JSX so React can render it properly.

Payload's rich text editor is built on Lexical. To show this content in your Next.js app, you'll need to convert it into JSX so React can render it properly.

In this tutorial, we’ll walk through how to do exactly that, using Payload’s built-in utilities for React. By the end, you’ll be rendering formatted text, media blocks, and even internal links.

1. Create the RichText component

We’ll start by creating a reusable component to render Lexical content.

Path: src/components/RichText/index.tsx

1
import { RichText as RichTextConverter } from '@payloadcms/richtext-lexical/react'
2
import { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
3
import { jsxConverter } from '@/components/RichText/converters'
4
5
type Props = {
6
data: SerializedEditorState
7
} & React.HTMLAttributes<HTMLDivElement>
8
9
export function RichText(props: Props) {
10
const { className, ...rest } = props
11
12
return (
13
<RichTextConverter
14
{...rest}
15
className={className}
16
// @ts-ignore
17
converters={jsxConverter}
18
/>
19
)
20
}

Note: We’re importing a custom jsxConverter, which we’ll define in the next step. This will let us render custom blocks, internal links, and more.

2. Set up JSX converter

To render custom blocks and elements in your Lexical content, you'll define a converter.

Path: src/components/RichText/converters/index.ts

1
import type {
2
TableOfContents as TableOfContentsProps,
3
ContentWithMedia as ContentWithMediaProps,
4
} from '@/payload-types'
5
6
import { DefaultNodeTypes, SerializedBlockNode } from '@payloadcms/richtext-lexical'
7
import {
8
JSXConvertersFunction,
9
LinkJSXConverter
10
} from '@payloadcms/richtext-lexical/react'
11
12
import { ContentWithMedia } from '@/blocks/ContentWithMedia/Component'
13
import { TableOfContents } from '@/blocks/TableOfContents/Component'
14
import { internalDocToHref } from '@/components/RichText/converters/internalLink'
15
import { headingConverter } from '@/components/RichText/converters/headingConverter'
16
17
type NodeTypes = DefaultNodeTypes | SerializedBlockNode<TableOfContentsProps | ContentWithMediaProps>
18
19
export const jsxConverter: JSXConvertersFunction<NodeTypes> = ({ defaultConverters }) => ({
20
...defaultConverters,
21
...LinkJSXConverter({ internalDocToHref }),
22
...headingConverter,
23
blocks: {
24
contentWithMedia: ({ node }) => <ContentWithMedia {...node.fields} />,
25
tableOfContents: ({ node }) => <TableOfContents {...node.fields} />,
26
},
27
})

3. Enable internal linking

This tells Payload how to generate links to related documents, such as posts or users.

Path: src/components/RichText/converters/internalLink.tsx

1
import { SerializedLinkNode } from '@payloadcms/richtext-lexical'
2
3
export const internalDocToHref = ({ linkNode }: { linkNode: SerializedLinkNode }) => {
4
const { value, relationTo } = linkNode.fields.doc!
5
6
const slug = typeof value !== 'string' && value.slug
7
8
if (relationTo === 'posts') {
9
return `/posts/${slug}`
10
} else if (relationTo === 'users') {
11
return `/users/${slug}`
12
} else {
13
return `/${slug}`
14
}
15
}

4. Add heading anchors (optional)

If you'd like your <h2> elements to automatically generate ids based on their text (for anchor links, table of contents, or scroll behavior), you can add a heading converter.

Path: src/components/RichText/converters/headingConverter.tsx

1
import { JSXConverters } from '@payloadcms/richtext-lexical/react'
2
import { SerializedHeadingNode } from '@payloadcms/richtext-lexical'
3
4
5
export const headingConverter: JSXConverters<SerializedHeadingNode> = {
6
heading: ({node, nodesToJSX}) => {
7
if (node.tag === 'h2') {
8
const text = nodesToJSX({ nodes: node.children })
9
10
const id = text.join("").toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
11
return <h2 id={id}>{text}</h2>
12
}
13
else {
14
const text = nodesToJSX({ nodes: node.children }).join("")
15
const Tag = node.tag
16
return <Tag>{text}</Tag>
17
}
18
}
19
}

Add more customizations

In the full video, you'll also see examples of:

  • Using conditional rendering based on block structure
  • Structuring your converters into separate files

You can extend this process and setup as needed, all the while keeping your rendering logic React-driven and type-safe.

Final thoughts

You’re now rendering Payload's Lexical content on your frontend using JSX—fully supporting custom blocks, internal links, and safe, flexible formatting.