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.

Converting HTML

Rich Text to HTML

There are two main approaches to convert your Lexical-based rich text to HTML:

  1. Generate HTML on-demand (Recommended): Convert JSON to HTML wherever you need it, on-demand.
  2. Generate HTML within your Collection: Create a new field that automatically converts your saved JSON content to HTML. This is not recommended because it adds overhead to the Payload API.

On-demand

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:

1
'use client'
2
3
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
4
import { convertLexicalToHTML } from '@payloadcms/richtext-lexical/html'
5
6
import React from 'react'
7
8
export const MyComponent = ({ data }: { data: SerializedEditorState }) => {
9
const html = convertLexicalToHTML({ data })
10
11
return <div dangerouslySetInnerHTML={{ __html: html }} />
12
}

Dynamic Population (Advanced)

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:

1
'use client'
2
3
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
4
5
import { getRestPopulateFn } from '@payloadcms/richtext-lexical/client'
6
import { convertLexicalToHTMLAsync } from '@payloadcms/richtext-lexical/html-async'
7
import React, { useEffect, useState } from 'react'
8
9
export const MyComponent = ({ data }: { data: SerializedEditorState }) => {
10
const [html, setHTML] = useState<null | string>(null)
11
useEffect(() => {
12
async function convert() {
13
const html = await convertLexicalToHTMLAsync({
14
data,
15
populate: getRestPopulateFn({
16
apiURL: `http://localhost:3000/api`,
17
}),
18
})
19
setHTML(html)
20
}
21
22
void convert()
23
}, [data])
24
25
return html && <div dangerouslySetInnerHTML={{ __html: html }} />
26
}

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:

1
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
2
3
import { getPayloadPopulateFn } from '@payloadcms/richtext-lexical'
4
import { convertLexicalToHTMLAsync } from '@payloadcms/richtext-lexical/html-async'
5
import { getPayload } from 'payload'
6
import React from 'react'
7
8
import config from '../../config.js'
9
10
export const MyRSCComponent = async ({
11
data,
12
}: {
13
data: SerializedEditorState
14
}) => {
15
const payload = await getPayload({
16
config,
17
})
18
19
const html = await convertLexicalToHTMLAsync({
20
data,
21
populate: await getPayloadPopulateFn({
22
currentDepth: 0,
23
depth: 1,
24
payload,
25
}),
26
})
27
28
return html && <div dangerouslySetInnerHTML={{ __html: html }} />
29
}

HTML field

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.

1
import type { HTMLConvertersFunction } from '@payloadcms/richtext-lexical/html'
2
import type { MyTextBlock } from '@/payload-types.js'
3
import type { CollectionConfig } from 'payload'
4
5
import {
6
BlocksFeature,
7
type DefaultNodeTypes,
8
lexicalEditor,
9
lexicalHTMLField,
10
type SerializedBlockNode,
11
} from '@payloadcms/richtext-lexical'
12
13
const Pages: CollectionConfig = {
14
slug: 'pages',
15
fields: [
16
{
17
name: 'nameOfYourRichTextField',
18
type: 'richText',
19
editor: lexicalEditor(),
20
},
21
lexicalHTMLField({
22
htmlFieldName: 'nameOfYourRichTextField_html',
23
lexicalFieldName: 'nameOfYourRichTextField',
24
}),
25
{
26
name: 'customRichText',
27
type: 'richText',
28
editor: lexicalEditor({
29
features: ({ defaultFeatures }) => [
30
...defaultFeatures,
31
BlocksFeature({
32
blocks: [
33
{
34
interfaceName: 'MyTextBlock',
35
slug: 'myTextBlock',
36
fields: [
37
{
38
name: 'text',
39
type: 'text',
40
},
41
],
42
},
43
],
44
}),
45
],
46
}),
47
},
48
lexicalHTMLField({
49
htmlFieldName: 'customRichText_html',
50
lexicalFieldName: 'customRichText',
51
// can pass in additional converters or override default ones
52
converters: (({ defaultConverters }) => ({
53
...defaultConverters,
54
blocks: {
55
myTextBlock: ({ node, providedCSSString }) =>
56
`<div style="background-color: red;${providedCSSString}">${node.fields.text}</div>`,
57
},
58
})) as HTMLConvertersFunction<
59
DefaultNodeTypes | SerializedBlockNode<MyTextBlock>
60
>,
61
}),
62
],
63
}

Blocks to HTML

If your rich text includes Lexical blocks, you need to provide a way to convert them to HTML. For example:

1
'use client'
2
3
import type { MyInlineBlock, MyTextBlock } from '@/payload-types'
4
import type {
5
DefaultNodeTypes,
6
SerializedBlockNode,
7
SerializedInlineBlockNode,
8
} from '@payloadcms/richtext-lexical'
9
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
10
11
import {
12
convertLexicalToHTML,
13
type HTMLConvertersFunction,
14
} from '@payloadcms/richtext-lexical/html'
15
import React from 'react'
16
17
type NodeTypes =
18
| DefaultNodeTypes
19
| SerializedBlockNode<MyTextBlock>
20
| SerializedInlineBlockNode<MyInlineBlock>
21
22
const htmlConverters: HTMLConvertersFunction<NodeTypes> = ({
23
defaultConverters,
24
}) => ({
25
...defaultConverters,
26
blocks: {
27
// Each key should match your block's slug
28
myTextBlock: ({ node, providedCSSString }) =>
29
`<div style="background-color: red;${providedCSSString}">${node.fields.text}</div>`,
30
},
31
inlineBlocks: {
32
// Each key should match your inline block's slug
33
myInlineBlock: ({ node, providedStyleTag }) =>
34
`<span${providedStyleTag}>${node.fields.text}</span$>`,
35
},
36
})
37
38
export const MyComponent = ({ data }: { data: SerializedEditorState }) => {
39
const html = convertLexicalToHTML({
40
converters: htmlConverters,
41
data,
42
})
43
44
return <div dangerouslySetInnerHTML={{ __html: html }} />
45
}

HTML to Richtext

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:

1
import {
2
convertHTMLToLexical,
3
editorConfigFactory,
4
} from '@payloadcms/richtext-lexical'
5
// Make sure you have jsdom and @types/jsdom installed
6
import { JSDOM } from 'jsdom'
7
8
const lexicalJSON = convertHTMLToLexical({
9
editorConfig: await editorConfigFactory.default({
10
config, // Your Payload Config
11
}),
12
html: '<p>text</p>',
13
JSDOM, // Pass in the JSDOM import; it's not bundled to keep package size small
14
})

Converting HTML with Images

When converting HTML to Lexical, <img> 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:

Approach 1: Pre-upload Images and Use Data Attributes (Recommended)

The most reliable approach is to upload images to Payload first, then reference them in your HTML using special data attributes before conversion.

1
import {
2
convertHTMLToLexical,
3
editorConfigFactory,
4
} from '@payloadcms/richtext-lexical'
5
import { getPayload } from 'payload'
6
import { JSDOM } from 'jsdom'
7
import config from '@payload-config'
8
9
const payload = await getPayload({ config })
10
11
// Step 1: Upload the image to Payload
12
const uploadedMedia = await payload.create({
13
collection: 'media', // Your upload collection slug
14
data: {
15
alt: 'My image description',
16
},
17
filePath: '/path/to/local/image.jpg', // or file: fileData for file uploads
18
})
19
20
// Step 2: Construct HTML with the proper data attributes
21
const htmlWithImage = `
22
<p>Some text content</p>
23
<img
24
src="${uploadedMedia.url}"
25
data-lexical-upload-id="${uploadedMedia.id}"
26
data-lexical-upload-relation-to="media"
27
alt="My image description"
28
/>
29
<p>More content</p>
30
`
31
32
// Step 3: Convert to Lexical
33
const lexicalJSON = convertHTMLToLexical({
34
editorConfig: await editorConfigFactory.default({ config }),
35
html: htmlWithImage,
36
JSDOM,
37
})
38
39
// The lexicalJSON will now contain a proper upload node

Required data attributes:

  • data-lexical-upload-id: The ID of the uploaded document
  • data-lexical-upload-relation-to: The collection slug (e.g., 'media')

Approach 2: Parse and Upload Images Before Conversion

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.

1
import {
2
convertHTMLToLexical,
3
editorConfigFactory,
4
} from '@payloadcms/richtext-lexical'
5
import { getPayload } from 'payload'
6
import { JSDOM } from 'jsdom'
7
import config from '@payload-config'
8
9
async function convertHTMLWithImageUpload(htmlString: string) {
10
const payload = await getPayload({ config })
11
12
// Step 1: Parse HTML to find all images
13
const dom = new JSDOM(htmlString)
14
const images = dom.window.document.querySelectorAll('img')
15
16
// Step 2: Upload each image and update the DOM
17
for (const img of Array.from(images)) {
18
const imageUrl = img.getAttribute('src')
19
20
if (!imageUrl) continue
21
22
try {
23
// Download the image (if external URL)
24
const response = await fetch(imageUrl)
25
const arrayBuffer = await response.arrayBuffer()
26
const buffer = Buffer.from(arrayBuffer)
27
28
// Upload to Payload
29
const uploadedMedia = await payload.create({
30
collection: 'media',
31
data: {
32
alt: img.getAttribute('alt') || '',
33
},
34
file: {
35
data: buffer,
36
mimetype: response.headers.get('content-type') || 'image/jpeg',
37
name: imageUrl.split('/').pop() || 'image.jpg',
38
size: buffer.length,
39
},
40
})
41
42
// Update the img tag with data attributes
43
img.setAttribute('data-lexical-upload-id', String(uploadedMedia.id))
44
img.setAttribute('data-lexical-upload-relation-to', 'media')
45
img.setAttribute('src', uploadedMedia.url)
46
} catch (error) {
47
console.error(`Failed to upload image: ${imageUrl}`, error)
48
// Optionally remove the img tag if upload fails
49
img.remove()
50
}
51
}
52
53
// Step 3: Convert the updated HTML to Lexical
54
const updatedHTML = dom.window.document.body.innerHTML
55
56
return convertHTMLToLexical({
57
editorConfig: await editorConfigFactory.default({ config }),
58
html: updatedHTML,
59
JSDOM,
60
})
61
}
62
63
// Usage
64
const lexicalJSON = await convertHTMLWithImageUpload(
65
'<p>Text</p><img src="https://example.com/image.jpg" />',
66
)

Approach 3: Construct Upload Nodes with buildEditorState

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.

1
import { buildEditorState } from '@payloadcms/richtext-lexical'
2
import { v4 as uuid } from 'uuid'
3
4
// Build Lexical JSON with upload nodes using the helper
5
const lexicalJSON = buildEditorState({
6
text: 'Some text content',
7
nodes: [
8
{
9
type: 'upload',
10
format: '',
11
version: 3,
12
relationTo: 'media',
13
value: 'your-upload-id-here', // ID of the uploaded document
14
fields: {}, // Any additional fields configured for the upload feature
15
id: uuid(), // Unique ID for this node instance (not the upload document ID)
16
},
17
],
18
})
19
20
// Save to your collection
21
await payload.create({
22
collection: 'pages',
23
data: {
24
title: 'My Page',
25
content: lexicalJSON,
26
},
27
})

Troubleshooting

Images disappear during conversion:

  • Ensure images have both data-lexical-upload-id and data-lexical-upload-relation-to attributes
  • Verify the upload ID exists in your upload collection
  • Check that the relationTo value matches your upload collection slug

"Cannot read property 'id' of undefined" errors:

  • The upload document may not exist - verify the ID is correct
  • Ensure the upload collection is properly configured
  • Check that the referenced upload hasn't been deleted

Images work in the admin UI but not in conversion:

  • The admin UI handles pending uploads differently than server-side conversion
  • Server-side conversion requires existing upload IDs, not pending uploads
  • Always upload images before converting HTML

Was this page helpful?

Next

Converting Markdown