| name | lexical-editor |
| description | This skill should be used when the user asks about "Payload rich text", "Lexical editor in Payload", "custom Lexical feature", "richText blocks", "richText link/upload/relationship", "custom Lexical node", "render Payload Lexical to JSX", "convert Lexical to HTML", or needs to customize the editor inside `richText` fields. |
PayloadCMS — Lexical Editor
richText fields in Payload v3 are backed by Lexical (Facebook/Meta's editor framework). The editor is configured per field, ships dozens of default features, and is fully extensible with custom features, blocks, and nodes.
Defaults
import { lexicalEditor } from '@payloadcms/richtext-lexical'
editor: lexicalEditor(),
{
name: 'content',
type: 'richText',
editor: lexicalEditor(),
}
Default features include: paragraph, heading (H1–H6), bold, italic, underline, strikethrough, code, link, list (unordered/ordered/check), block-quote, horizontal-rule, upload, indent, alignment, superscript, subscript.
Configuring Features
features is a function that receives the default feature list — return a new array:
import {
lexicalEditor,
HeadingFeature,
LinkFeature,
BlocksFeature,
UploadFeature,
FixedToolbarFeature,
InlineToolbarFeature,
HorizontalRuleFeature,
TreeViewFeature,
} from '@payloadcms/richtext-lexical'
editor: lexicalEditor({
features: ({ defaultFeatures, rootFeatures }) => [
...defaultFeatures,
FixedToolbarFeature(),
InlineToolbarFeature(),
HorizontalRuleFeature(),
HeadingFeature({ enabledHeadingSizes: ['h2', 'h3', 'h4'] }),
LinkFeature({
enabledCollections: ['posts', 'pages'],
fields: ({ defaultFields }) => [
...defaultFields,
{ name: 'rel', type: 'select', options: ['nofollow', 'noopener'], hasMany: true },
],
}),
UploadFeature({
collections: {
media: {
fields: [
{ name: 'caption', type: 'textarea' },
],
},
},
}),
BlocksFeature({
blocks: [HeroBlock, CalloutBlock, EmbedBlock],
}),
TreeViewFeature(),
],
})
Blocks Inside RichText
BlocksFeature lets editors drop any reusable Block definition inline:
import type { Block } from 'payload'
export const CalloutBlock: Block = {
slug: 'callout',
labels: { singular: 'Callout', plural: 'Callouts' },
fields: [
{ name: 'kind', type: 'select', options: ['info', 'warning', 'success'], defaultValue: 'info' },
{ name: 'title', type: 'text' },
{ name: 'body', type: 'richText' },
],
}
Reference the block from the field's editor:
{
name: 'content',
type: 'richText',
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
BlocksFeature({ blocks: [CalloutBlock] }),
],
}),
}
In the saved JSON, each block appears as a { type: 'block', fields: { blockType: 'callout', kind, title, body } } node.
Rendering Lexical to JSX (Frontend)
Use the official renderer:
pnpm add @payloadcms/richtext-lexical
import { RichText } from '@payloadcms/richtext-lexical/react'
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
export default async function PostPage({ params }) {
const post = await fetchPost(params.slug)
return (
<article>
<h1>{post.title}</h1>
<RichText data={post.content as SerializedEditorState} />
</article>
)
}
Custom render for specific node types:
<RichText
data={post.content}
converters={({ defaultConverters }) => ({
...defaultConverters,
blocks: {
callout: ({ node }) => (
<div className={`callout callout-${node.fields.kind}`}>
<h4>{node.fields.title}</h4>
<RichText data={node.fields.body} />
</div>
),
},
upload: ({ node }) => (
<img src={node.value.url} alt={node.value.alt} />
),
link: ({ node, nodesToJSX }) => {
const url = node.fields.linkType === 'custom'
? node.fields.url
: `/${node.fields.doc?.relationTo}/${node.fields.doc?.value?.slug ?? ''}`
return <a href={url}>{nodesToJSX({ nodes: node.children })}</>
},
})}
/>
Converting to HTML (Server-Side)
For RSS feeds, email, or static rendering:
import { convertLexicalToHTML } from '@payloadcms/richtext-lexical/html'
const html = convertLexicalToHTML({ data: post.content })
import { convertLexicalToHTMLAsync } from '@payloadcms/richtext-lexical/html-async'
import { getPayloadPopulateFn } from '@payloadcms/richtext-lexical'
const html = await convertLexicalToHTMLAsync({
data: post.content,
populate: await getPayloadPopulateFn({ currentDepth: 0, depth: 2, payload }),
})
Notes:
- Client-side population uses
getRestPopulateFn from @payloadcms/richtext-lexical/client instead of getPayloadPopulateFn.
- Custom block converters go in the
converters option — an HTMLConvertersFunction keying blocks / inlineBlocks by block slug (same shape as the JSX converters above).
lexicalHTMLField() exists to store auto-converted HTML on the document via an afterRead hook, but the docs call it "generally not recommended" — convert at render time instead.
Source: https://payloadcms.com/docs/rich-text/converting-html
Custom Features
A feature is a function that registers nodes, toolbar buttons, slash commands, and converters:
import { createServerFeature } from '@payloadcms/richtext-lexical'
export const CustomFeature = createServerFeature({
feature: () => ({
ClientFeature: '@/lexical/CustomFeature.client',
nodes: [],
sanitizedClientFeatureProps: null,
}),
key: 'custom',
})
Match it on the client:
'use client'
import { createClientFeature } from '@payloadcms/richtext-lexical/client'
export const CustomFeatureClient = createClientFeature({
})
export default CustomFeatureClient
Run pnpm payload generate:importmap after adding any string-path component.
Disabling Default Features
Filter defaultFeatures before returning:
features: ({ defaultFeatures }) =>
defaultFeatures.filter((f) => f.key !== 'horizontalrule'),
Working With Saved Data
A richText field stores a JSON tree:
{
"root": {
"type": "root",
"children": [
{ "type": "heading", "tag": "h2", "children": [{ "type": "text", "text": "Hello" }] }
]
}
}
Walk it for analysis (word count, table-of-contents extraction):
import type { SerializedEditorState, SerializedLexicalNode } from 'lexical'
function walk(node: SerializedLexicalNode, fn: (n: SerializedLexicalNode) => void) {
fn(node)
if ('children' in node && Array.isArray((node as any).children)) {
for (const c of (node as any).children) walk(c, fn)
}
}
function textOf(data: SerializedEditorState): string {
let out = ''
walk(data.root, (n) => {
if (n.type === 'text') out += (n as any).text
})
return out
}
Common Pitfalls
| Symptom | Cause | Fix |
|---|
RichText shows raw JSON | Wrong import — used data as a string | Pass the parsed object, not stringified JSON |
| Custom feature missing in admin | generate:importmap not run | pnpm payload generate:importmap and commit |
| Block fields lose values on save | Block slug collision with another collection | Rename the block slug; slugs are global |
Migration writes plain text into richText | Plain strings aren't valid Lexical state | Wrap them as { root: { type: 'root', children: [{ type: 'paragraph', children: [{ type: 'text', text: '…' }] }] } } |
See Also
- The
fields skill — richText field config.
- The
cms-migration skill — converting WordPress / Contentful rich text into Lexical state.
- The
nextjs-integration skill — rendering richText in server components.