---
title: "Rich Text — Converting HTML (Lexical)"
aliases: [lexical-html-conversion, convert-lexical-to-html, convert-html-to-lexical]
tags: [payloadcms, lexical, rich-text, html, conversion]
sources: [raw/rich-text__converting-html.md]
created: 2026-05-15
updated: 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

### On-Demand (Recommended)

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

```tsx
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:

```tsx
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 }),
})
```

### HTML Field (Not Recommended)

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

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

## Custom Block Converters

When rich text includes [[wiki/payloadcms/rich-text-blocks|Lexical Blocks]], provide per-slug converters:

```tsx
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).

```ts
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

### Approach 1: Pre-upload + Data Attributes (Recommended)

1. Upload image to Payload first
2. Add `data-lexical-upload-id` and `data-lexical-upload-relation-to` attributes to `<img>`
3. Run `convertHTMLToLexical`

```html
<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:

```ts
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

## Related

- [[wiki/payloadcms/rich-text-converters|Rich Text — Converters (Lexical)]] — editorConfigFactory, JSX/Markdown/Plaintext converters
- [[wiki/payloadcms/rich-text-blocks|Rich Text — Blocks (Lexical BlocksFeature)]] — block data structure, rendering
- [[wiki/payloadcms/fields-rich-text|Rich Text Field]] — field config, Lexical editor setup
- [[wiki/payloadcms/upload|Upload & Media]] — upload collection config, storage adapters
- [[wiki/payloadcms/local-api|Local API]] — `payload.create` for uploading media during migration

## Sources

- `raw/rich-text__converting-html.md`
- https://payloadcms.com/docs/rich-text/converting-html
