obsidian/wiki/payloadcms/rich-text-converting-html.md
2026-05-15 16:38:56 +01:00

5.4 KiB

title aliases tags sources created updated
Rich Text — Converting HTML (Lexical)
lexical-html-conversion
convert-lexical-to-html
convert-html-to-lexical
payloadcms
lexical
rich-text
html
conversion
raw/rich-text__converting-html.md
2026-05-15 2026-05-15

Overview

Payload CMS Lexical rich text stores content as JSON (SerializedEditorState). Converting to/from HTML requires explicit functions — nothing happens automatically.

Rich Text → HTML

Use convertLexicalToHTML from @payloadcms/richtext-lexical/html. Synchronous, expects fully-populated data.

import { convertLexicalToHTML } from '@payloadcms/richtext-lexical/html'

const html = convertLexicalToHTML({ data })

On-Demand with Dynamic Population (Advanced)

If data contains un-populated nodes (uploads, links), use the async variant with a populate function:

import { convertLexicalToHTMLAsync } from '@payloadcms/richtext-lexical/html-async'
import { getRestPopulateFn } from '@payloadcms/richtext-lexical/client'

// Client-side (REST): one request per node — slow for many nodes
const html = await convertLexicalToHTMLAsync({
  data,
  populate: getRestPopulateFn({ apiURL: `http://localhost:3000/api` }),
})

// Server-side RSC (recommended for performance)
import { getPayloadPopulateFn } from '@payloadcms/richtext-lexical'
const html = await convertLexicalToHTMLAsync({
  data,
  populate: await getPayloadPopulateFn({ currentDepth: 0, depth: 1, payload }),
})

lexicalHTMLField() saves converted HTML into a dedicated field via afterRead hook. Creates duplicate content column — avoid unless needed for external consumers.

lexicalHTMLField({
  htmlFieldName: 'content_html',
  lexicalFieldName: 'content',
})

Custom Block Converters

When rich text includes wiki/payloadcms/rich-text-blocks, provide per-slug converters:

const htmlConverters: HTMLConvertersFunction<NodeTypes> = ({ defaultConverters }) => ({
  ...defaultConverters,
  blocks: {
    myTextBlock: ({ node, providedCSSString }) =>
      `<div style="background-color: red;${providedCSSString}">${node.fields.text}</div>`,
  },
  inlineBlocks: {
    myInlineBlock: ({ node, providedStyleTag }) =>
      `<span${providedStyleTag}>${node.fields.text}</span>`,
  },
})

HTML → Rich Text

Use convertHTMLToLexical from @payloadcms/richtext-lexical. Requires jsdom installed separately (not bundled).

import { convertHTMLToLexical, editorConfigFactory } from '@payloadcms/richtext-lexical'
import { JSDOM } from 'jsdom'

const lexicalJSON = convertHTMLToLexical({
  editorConfig: await editorConfigFactory.default({ config }),
  html: '<p>text</p>',
  JSDOM,
})

Warning: <img> tags are NOT automatically uploaded during HTML→Lexical conversion. Images are omitted unless you provide the correct data attributes.

Converting HTML with Images

  1. Upload image to Payload first
  2. Add data-lexical-upload-id and data-lexical-upload-relation-to attributes to <img>
  3. Run convertHTMLToLexical
<img
  src="/media/image.jpg"
  data-lexical-upload-id="abc123"
  data-lexical-upload-relation-to="media"
/>

Approach 2: Parse → Upload → Convert (Bulk Migration)

Parse HTML with JSDOM, upload each image, update DOM attributes, then convert. Suitable for content migrations.

Approach 3: buildEditorState Helper

When you already have upload IDs, build Lexical JSON directly without HTML parsing:

import { buildEditorState } from '@payloadcms/richtext-lexical'

const lexicalJSON = buildEditorState({
  text: 'Some text content',
  nodes: [
    {
      type: 'upload',
      format: '',
      version: 3,
      relationTo: 'media',
      value: 'your-upload-id-here',
      fields: {},
      id: uuid(),
    },
  ],
})

Key Takeaways

  • Prefer on-demand conversion (convertLexicalToHTML) over lexicalHTMLField — no duplicate DB column
  • convertLexicalToHTML is sync and requires pre-populated data; use convertLexicalToHTMLAsync with a populate function if nodes are un-populated
  • Server-side: use getPayloadPopulateFn (batched); client-side: getRestPopulateFn (1 request/node)
  • <img> tags are silently dropped during HTML→Lexical unless data-lexical-upload-id and data-lexical-upload-relation-to attributes are present
  • jsdom must be installed manually — it is not bundled with @payloadcms/richtext-lexical
  • Custom blocks need custom converters — each block slug must map to a converter function
  • buildEditorState is the cleanest approach when you already have upload IDs and want to skip HTML parsing entirely

Sources