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
1import { RichText as RichTextConverter } from '@payloadcms/richtext-lexical/react'
2import { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
3import { jsxConverter } from '@/components/RichText/converters'
6 data: SerializedEditorState
7} & React.HTMLAttributes<HTMLDivElement>
9export function RichText(props: Props) {
10 const { className, ...rest } = props
17 converters={jsxConverter}
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
2 TableOfContents as TableOfContentsProps,
3 ContentWithMedia as ContentWithMediaProps,
4} from '@/payload-types'
6import { DefaultNodeTypes, SerializedBlockNode } from '@payloadcms/richtext-lexical'
10} from '@payloadcms/richtext-lexical/react'
12import { ContentWithMedia } from '@/blocks/ContentWithMedia/Component'
13import { TableOfContents } from '@/blocks/TableOfContents/Component'
14import { internalDocToHref } from '@/components/RichText/converters/internalLink'
15import { headingConverter } from '@/components/RichText/converters/headingConverter'
17type NodeTypes = DefaultNodeTypes | SerializedBlockNode<TableOfContentsProps | ContentWithMediaProps>
19export const jsxConverter: JSXConvertersFunction<NodeTypes> = ({ defaultConverters }) => ({
21 ...LinkJSXConverter({ internalDocToHref }),
24 contentWithMedia: ({ node }) => <ContentWithMedia {...node.fields} />,
25 tableOfContents: ({ node }) => <TableOfContents {...node.fields} />,
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
1import { SerializedLinkNode } from '@payloadcms/richtext-lexical'
3export const internalDocToHref = ({ linkNode }: { linkNode: SerializedLinkNode }) => {
4 const { value, relationTo } = linkNode.fields.doc!
6 const slug = typeof value !== 'string' && value.slug
8 if (relationTo === 'posts') {
9 return `/posts/${slug}`
10 } else if (relationTo === 'users') {
11 return `/users/${slug}`
4. Add heading anchors (optional)
If you'd like your <h2>
elements to automatically generate id
s 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
1import { JSXConverters } from '@payloadcms/richtext-lexical/react'
2import { SerializedHeadingNode } from '@payloadcms/richtext-lexical'
5export const headingConverter: JSXConverters<SerializedHeadingNode> = {
6 heading: ({node, nodesToJSX}) => {
7 if (node.tag === 'h2') {
8 const text = nodesToJSX({ nodes: node.children })
10 const id = text.join("").toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
11 return <h2 id={id}>{text}</h2>
14 const text = nodesToJSX({ nodes: node.children }).join("")
16 return <Tag>{text}</Tag>
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.