docs/rich-text/converting-html.mdx
There are two main approaches to convert your Lexical-based rich text to HTML:
To convert JSON to HTML on-demand, use the convertLexicalToHTML function from @payloadcms/richtext-lexical/html. Here's an example of how to use it in a React component in your frontend:
'use client'
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
import { convertLexicalToHTML } from '@payloadcms/richtext-lexical/html'
import React from 'react'
export const MyComponent = ({ data }: { data: SerializedEditorState }) => {
const html = convertLexicalToHTML({ data })
return <div dangerouslySetInnerHTML={{ __html: html }} />
}
By default, convertLexicalToHTML expects fully populated data (e.g. uploads, links, etc.). If you need to dynamically fetch and populate those nodes, use the async variant, convertLexicalToHTMLAsync, from @payloadcms/richtext-lexical/html-async. You must provide a populate function:
'use client'
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
import { getRestPopulateFn } from '@payloadcms/richtext-lexical/client'
import { convertLexicalToHTMLAsync } from '@payloadcms/richtext-lexical/html-async'
import React, { useEffect, useState } from 'react'
export const MyComponent = ({ data }: { data: SerializedEditorState }) => {
const [html, setHTML] = useState<null | string>(null)
useEffect(() => {
async function convert() {
const html = await convertLexicalToHTMLAsync({
data,
populate: getRestPopulateFn({
apiURL: `http://localhost:3000/api`,
}),
})
setHTML(html)
}
void convert()
}, [data])
return html && <div dangerouslySetInnerHTML={{ __html: html }} />
}
Using the REST populate function will send a separate request for each node. If you need to populate a large number of nodes, this may be slow. For improved performance on the server, you can use the getPayloadPopulateFn function:
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
import { getPayloadPopulateFn } from '@payloadcms/richtext-lexical'
import { convertLexicalToHTMLAsync } from '@payloadcms/richtext-lexical/html-async'
import { getPayload } from 'payload'
import React from 'react'
import config from '../../config.js'
export const MyRSCComponent = async ({
data,
}: {
data: SerializedEditorState
}) => {
const payload = await getPayload({
config,
})
const html = await convertLexicalToHTMLAsync({
data,
populate: await getPayloadPopulateFn({
currentDepth: 0,
depth: 1,
payload,
}),
})
return html && <div dangerouslySetInnerHTML={{ __html: html }} />
}
The lexicalHTMLField() helper converts JSON to HTML and saves it in a field that is updated every time you read it via an afterRead hook. It's generally not recommended, as it creates a column with duplicate content in another format.
Consider using the on-demand HTML converter above or the JSX converter unless you have a good reason.
import type { HTMLConvertersFunction } from '@payloadcms/richtext-lexical/html'
import type { MyTextBlock } from '@/payload-types.js'
import type { CollectionConfig } from 'payload'
import {
BlocksFeature,
type DefaultNodeTypes,
lexicalEditor,
lexicalHTMLField,
type SerializedBlockNode,
} from '@payloadcms/richtext-lexical'
const Pages: CollectionConfig = {
slug: 'pages',
fields: [
{
name: 'nameOfYourRichTextField',
type: 'richText',
editor: lexicalEditor(),
},
lexicalHTMLField({
htmlFieldName: 'nameOfYourRichTextField_html',
lexicalFieldName: 'nameOfYourRichTextField',
}),
{
name: 'customRichText',
type: 'richText',
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
BlocksFeature({
blocks: [
{
interfaceName: 'MyTextBlock',
slug: 'myTextBlock',
fields: [
{
name: 'text',
type: 'text',
},
],
},
],
}),
],
}),
},
lexicalHTMLField({
htmlFieldName: 'customRichText_html',
lexicalFieldName: 'customRichText',
// can pass in additional converters or override default ones
converters: (({ defaultConverters }) => ({
...defaultConverters,
blocks: {
myTextBlock: ({ node, providedCSSString }) =>
`<div style="background-color: red;${providedCSSString}">${node.fields.text}</div>`,
},
})) as HTMLConvertersFunction<
DefaultNodeTypes | SerializedBlockNode<MyTextBlock>
>,
}),
],
}
If your rich text includes Lexical blocks, you need to provide a way to convert them to HTML. For example:
'use client'
import type { MyInlineBlock, MyTextBlock } from '@/payload-types'
import type {
DefaultNodeTypes,
SerializedBlockNode,
SerializedInlineBlockNode,
} from '@payloadcms/richtext-lexical'
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
import {
convertLexicalToHTML,
type HTMLConvertersFunction,
} from '@payloadcms/richtext-lexical/html'
import React from 'react'
type NodeTypes =
| DefaultNodeTypes
| SerializedBlockNode<MyTextBlock>
| SerializedInlineBlockNode<MyInlineBlock>
const htmlConverters: HTMLConvertersFunction<NodeTypes> = ({
defaultConverters,
}) => ({
...defaultConverters,
blocks: {
// Each key should match your block's slug
myTextBlock: ({ node, providedCSSString }) =>
`<div style="background-color: red;${providedCSSString}">${node.fields.text}</div>`,
},
inlineBlocks: {
// Each key should match your inline block's slug
myInlineBlock: ({ node, providedStyleTag }) =>
`<span${providedStyleTag}>${node.fields.text}</span$>`,
},
})
export const MyComponent = ({ data }: { data: SerializedEditorState }) => {
const html = convertLexicalToHTML({
converters: htmlConverters,
data,
})
return <div dangerouslySetInnerHTML={{ __html: html }} />
}
If you need to convert raw HTML into a Lexical editor state, use convertHTMLToLexical from @payloadcms/richtext-lexical, along with the editorConfigFactory to retrieve the editor config:
import {
convertHTMLToLexical,
editorConfigFactory,
} from '@payloadcms/richtext-lexical'
// Make sure you have jsdom and @types/jsdom installed
import { JSDOM } from 'jsdom'
const lexicalJSON = convertHTMLToLexical({
editorConfig: await editorConfigFactory.default({
config, // Your Payload Config
}),
html: '<p>text</p>',
JSDOM, // Pass in the JSDOM import; it's not bundled to keep package size small
})
When converting HTML to Lexical, `` tags require special handling because Payload does not automatically upload images to prevent unintended database modifications. Here are three approaches to handle images during HTML-to-Lexical conversion:
The most reliable approach is to upload images to Payload first, then reference them in your HTML using special data attributes before conversion.
import {
convertHTMLToLexical,
editorConfigFactory,
} from '@payloadcms/richtext-lexical'
import { getPayload } from 'payload'
import { JSDOM } from 'jsdom'
import config from '@payload-config'
const payload = await getPayload({ config })
// Step 1: Upload the image to Payload
const uploadedMedia = await payload.create({
collection: 'media', // Your upload collection slug
data: {
alt: 'My image description',
},
filePath: '/path/to/local/image.jpg', // or file: fileData for file uploads
})
// Step 2: Construct HTML with the proper data attributes
const htmlWithImage = `
<p>Some text content</p>
<p>More content</p>
`
// Step 3: Convert to Lexical
const lexicalJSON = convertHTMLToLexical({
editorConfig: await editorConfigFactory.default({ config }),
html: htmlWithImage,
JSDOM,
})
// The lexicalJSON will now contain a proper upload node
Required data attributes:
data-lexical-upload-id: The ID of the uploaded documentdata-lexical-upload-relation-to: The collection slug (e.g., 'media')For bulk content migration or when dealing with external image URLs, parse the HTML first, upload images, then replace the image tags with proper attributes.
import {
convertHTMLToLexical,
editorConfigFactory,
} from '@payloadcms/richtext-lexical'
import { getPayload } from 'payload'
import { JSDOM } from 'jsdom'
import config from '@payload-config'
async function convertHTMLWithImageUpload(htmlString: string) {
const payload = await getPayload({ config })
// Step 1: Parse HTML to find all images
const dom = new JSDOM(htmlString)
const images = dom.window.document.querySelectorAll('img')
// Step 2: Upload each image and update the DOM
for (const img of Array.from(images)) {
const imageUrl = img.getAttribute('src')
if (!imageUrl) continue
try {
// Download the image (if external URL)
const response = await fetch(imageUrl)
const arrayBuffer = await response.arrayBuffer()
const buffer = Buffer.from(arrayBuffer)
// Upload to Payload
const uploadedMedia = await payload.create({
collection: 'media',
data: {
alt: img.getAttribute('alt') || '',
},
file: {
data: buffer,
mimetype: response.headers.get('content-type') || 'image/jpeg',
name: imageUrl.split('/').pop() || 'image.jpg',
size: buffer.length,
},
})
// Update the img tag with data attributes
img.setAttribute('data-lexical-upload-id', String(uploadedMedia.id))
img.setAttribute('data-lexical-upload-relation-to', 'media')
img.setAttribute('src', uploadedMedia.url)
} catch (error) {
console.error(`Failed to upload image: ${imageUrl}`, error)
// Optionally remove the img tag if upload fails
img.remove()
}
}
// Step 3: Convert the updated HTML to Lexical
const updatedHTML = dom.window.document.body.innerHTML
return convertHTMLToLexical({
editorConfig: await editorConfigFactory.default({ config }),
html: updatedHTML,
JSDOM,
})
}
// Usage
const lexicalJSON = await convertHTMLWithImageUpload(
'<p>Text</p>',
)
If you already have upload IDs and want to build the Lexical JSON structure, use the buildEditorState helper for a type-safe, simplified approach. This helper requires less boilerplate and eliminates the need to manually construct the root node.
import { buildEditorState } from '@payloadcms/richtext-lexical'
import { v4 as uuid } from 'uuid'
// Build Lexical JSON with upload nodes using the helper
const lexicalJSON = buildEditorState({
text: 'Some text content',
nodes: [
{
type: 'upload',
format: '',
version: 3,
relationTo: 'media',
value: 'your-upload-id-here', // ID of the uploaded document
fields: {}, // Any additional fields configured for the upload feature
id: uuid(), // Unique ID for this node instance (not the upload document ID)
},
],
})
// Save to your collection
await payload.create({
collection: 'pages',
data: {
title: 'My Page',
content: lexicalJSON,
},
})
Images disappear during conversion:
data-lexical-upload-id and data-lexical-upload-relation-to attributesrelationTo value matches your upload collection slug"Cannot read property 'id' of undefined" errors:
Images work in the admin UI but not in conversion: