---
title: "PayloadCMS — Rich Text (Lexical)"
aliases: [lexical-editor, rich-text-lexical, payload-rich-text]
tags: [payloadcms, rich-text, lexical, tech-patterns]
sources:
  - raw/rich-text__overview.md
  - https://payloadcms.com/docs/rich-text/overview
  - https://payloadcms.com/docs/rich-text/official-features
  - https://payloadcms.com/docs/rich-text/custom-features
  - https://payloadcms.com/docs/rich-text/blocks
  - https://payloadcms.com/docs/rich-text/converters
  - https://payloadcms.com/docs/rich-text/converting-html
  - https://payloadcms.com/docs/rich-text/converting-jsx
  - https://payloadcms.com/docs/rich-text/converting-markdown
  - https://payloadcms.com/docs/rich-text/converting-plaintext
  - https://payloadcms.com/docs/rich-text/rendering-on-demand
  - https://payloadcms.com/docs/rich-text/views
created: 2026-05-15
updated: 2026-05-15
---

# PayloadCMS — Rich Text (Lexical)

## Overview

- Rich text is powered by **Lexical** (Meta's framework), exposed via `@payloadcms/richtext-lexical`.
- Data is stored as JSON; convert to HTML/JSX/Markdown/Plaintext on-demand.
- Fully typed via `TypedEditorState<NodeUnion>` or `DefaultTypedEditorState`.
- See also: [[wiki/payloadcms/fields-complex|Fields Complex]], [[wiki/payloadcms/configuration|Configuration]].

## Setup

```ts
// payload.config.ts
import { lexicalEditor } from '@payloadcms/richtext-lexical'

export default buildConfig({
  editor: lexicalEditor({}),   // global default
})
```

**Field-level override:**
```ts
{
  name: 'content',
  type: 'richText',
  editor: lexicalEditor({
    features: ({ defaultFeatures }) => [
      ...defaultFeatures,
      // add / remove features here
    ],
  }),
}
```

**Admin options** (all optional):
- `placeholder` — empty-state text
- `hideGutter` — hide left padding line
- `hideInsertParagraphAtEnd` — hide trailing "+" button
- `hideDraggableBlockElement` — hide drag handle on hover
- `hideAddBlockButton` — hide inline "+" button

**Detect empty state:**
```ts
import { hasText } from '@payloadcms/richtext-lexical/shared'
hasText(richtextData) // false for null or empty paragraph
```

## Built-in Features

| Feature | Default | Notes |
|---|---|---|
| `BoldFeature` | Yes | Ctrl+B, `**bold**` |
| `ItalicFeature` | Yes | Ctrl+I, `*italic*` |
| `UnderlineFeature` | Yes | Ctrl+U |
| `StrikethroughFeature` | Yes | `~~text~~` |
| `SubscriptFeature` | Yes | |
| `SuperscriptFeature` | Yes | |
| `InlineCodeFeature` | Yes | `` `code` `` |
| `ParagraphFeature` | Yes | slash menu + toolbar |
| `HeadingFeature` | Yes | H1–H6, `# heading`, configurable |
| `AlignFeature` | Yes | Ctrl+Shift+L/E/R/J |
| `IndentFeature` | Yes | Tab / Shift+Tab |
| `UnorderedListFeature` | Yes | `- item` |
| `OrderedListFeature` | Yes | `1. item` |
| `ChecklistFeature` | Yes | `- [ ]` / `- [x]` |
| `LinkFeature` | Yes | internal + external, `[text](url)` |
| `RelationshipFeature` | Yes | block-level doc relationships |
| `BlockquoteFeature` | Yes | `> quote` |
| `UploadFeature` | Yes | all file types, `![media:id]()` in MD |
| `HorizontalRuleFeature` | Yes | `---` |
| `InlineToolbarFeature` | Yes | floating toolbar on selection |
| `FixedToolbarFeature` | **No** | persistent top toolbar |
| `BlocksFeature` | **No** | embed Payload blocks in editor |
| `TreeViewFeature` | **No** | debug panel (dev only) |
| `EXPERIMENTAL_TableFeature` | **No** | unstable, may break in minor releases |
| `TextStateFeature` | **No** | key-value inline styles on text nodes |

**HeadingFeature config example:**
```ts
HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3'] })
```

**LinkFeature config example:**
```ts
LinkFeature({
  fields: ({ defaultFields }) => [...defaultFields, { name: 'rel', type: 'select', options: ['noopener', 'noreferrer', 'nofollow'] }],
  enabledCollections: ['pages', 'posts'],
  disableAutoLinks: false,
})
```

**TextStateFeature** stores state under `"$"` key in serialized nodes; requires a custom `text` JSX converter to apply CSS at render time (see official-features docs for full pattern).

## Custom Features

- Two files per feature: `feature.server.ts` (entry-point) + `feature.client.ts` (`'use client'`).
- **Never import from core lexical packages directly** — use re-exports from `@payloadcms/richtext-lexical` / `@payloadcms/richtext-lexical/client`.

**Minimal server feature:**
```ts
import { createServerFeature } from '@payloadcms/richtext-lexical'

export const MyFeature = createServerFeature({
  feature: {
    ClientFeature: './feature.client#MyClientFeature',
  },
  key: 'myFeature',
})
```

**Minimal client feature:**
```ts
'use client'
import { createClientFeature } from '@payloadcms/richtext-lexical/client'

export const MyClientFeature = createClientFeature({
  nodes: [MyNode],
  plugins: [MyPlugin],
  toolbarFixed: { groups: [...] },
  slashMenu: { groups: [...] },
})
```

**Feature load order options:** `dependencies`, `dependenciesPriority`, `dependenciesSoft`.

**Server props → client:** only serializable values; pass via `clientFeatureProps` in server feature.

**Node structure:** add same node class to both client and server feature arrays. Use `createNode()` helper on server to attach `converters`, `hooks`, `validations`, `getSubFields`, `getSubFieldsData`.

**Toolbar helpers** (exported from `@payloadcms/richtext-lexical/client`):
- `toolbarAddDropdownGroupWithItems` — vertical dropdown
- `toolbarFormatGroupWithItems` — horizontal icon buttons

**Slash menu helpers:** `slashMenuBasicGroupWithItems` — groups with same key are merged.

## Blocks in Rich Text

```ts
BlocksFeature({
  blocks: [
    { slug: 'banner', fields: [{ name: 'style', type: 'select', options: ['info', 'warning', 'error'] }] },
  ],
  inlineBlocks: [
    { slug: 'mention', fields: [{ name: 'user', type: 'relationship', relationTo: 'users' }] },
  ],
})
```

- **Block-level:** full-width, standalone (CTAs, code snippets, embeds).
- **Inline blocks:** flow within text (@mentions, badges, variable placeholders).
- Data stored in Lexical JSON with `blockType`, `id`, and custom fields.
- Custom editor components: `admin.components.Block` / `admin.components.Label`.
- Block component primitives: `BlockCollapsible`, `BlockEditButton`, `BlockRemoveButton`.
- Inline primitives: `InlineBlockContainer`, `InlineBlockEditButton`, `InlineBlockLabel`, `InlineBlockRemoveButton`.
- Pre-built **CodeBlock** with syntax highlight + TS IntelliSense support:
  ```ts
  import { CodeBlock } from '@payloadcms/richtext-lexical'
  BlocksFeature({ blocks: [CodeBlock({ defaultLanguage: 'ts', languages: { ts: 'TypeScript' } })] })
  ```

## Converters (HTML, JSX, Markdown, Plaintext)

### Retrieve Editor Config (required for Markdown/HTML server conversion)

```ts
import { editorConfigFactory } from '@payloadcms/richtext-lexical'

const cfg = await editorConfigFactory.default({ config })          // global default
const cfg = editorConfigFactory.fromField({ field })               // from specific field
const cfg = await editorConfigFactory.fromFeatures({ config, features: ... })
```

### JSX (recommended for React/Next.js)

```tsx
import { RichText } from '@payloadcms/richtext-lexical/react'

<RichText data={lexicalData} />
```

Custom converters (blocks, internal links, overrides):
```tsx
import { JSXConvertersFunction, LinkJSXConverter, RichText } from '@payloadcms/richtext-lexical/react'

const jsxConverters: JSXConvertersFunction = ({ defaultConverters }) => ({
  ...defaultConverters,
  ...LinkJSXConverter({ internalDocToHref: ({ linkNode }) => `/${linkNode.fields.doc.value.slug}` }),
  blocks: {
    myBlock: ({ node }) => <div>{node.fields.text}</div>,
  },
  inlineBlocks: {
    myInline: ({ node }) => <span>{node.fields.label}</span>,
  },
})
```

- Requires fully populated data (`depth` must be sufficient).
- Override `upload` converter to use `next/image`.

### HTML

**On-demand (recommended):**
```ts
import { convertLexicalToHTML } from '@payloadcms/richtext-lexical/html'
const html = convertLexicalToHTML({ data })
```

**Async (unpopulated data, server-side):**
```ts
import { convertLexicalToHTMLAsync } from '@payloadcms/richtext-lexical/html-async'
import { getPayloadPopulateFn } from '@payloadcms/richtext-lexical'

const html = await convertLexicalToHTMLAsync({
  data,
  populate: await getPayloadPopulateFn({ currentDepth: 0, depth: 1, payload }),
})
```

**HTML → Lexical:**
```ts
import { convertHTMLToLexical } from '@payloadcms/richtext-lexical'
import { JSDOM } from 'jsdom'   // must install jsdom separately

const json = convertHTMLToLexical({ editorConfig: cfg, html: '<p>text</p>', JSDOM })
```

Images in HTML→Lexical require `data-lexical-upload-id` + `data-lexical-upload-relation-to` attributes; `<img>` tags without them are dropped.

### Markdown

```ts
import { convertLexicalToMarkdown, convertMarkdownToLexical } from '@payloadcms/richtext-lexical'

const md = convertLexicalToMarkdown({ data, editorConfig: cfg })
const json = convertMarkdownToLexical({ editorConfig: cfg, markdown: '# Hello' })
```

- Upload nodes in Markdown use special syntax: `![media:documentId]()`
- MDX blocks: define `jsx.export` / `jsx.import` on block config to convert to/from JSX tags.

### Plaintext

```ts
import { convertLexicalToPlaintext } from '@payloadcms/richtext-lexical/plaintext'

const text = convertLexicalToPlaintext({ data })
// with custom converters:
const text = convertLexicalToPlaintext({ data, converters: { blocks: { myBlock: ({ node }) => node.fields.text } } })
```

No default converters — heuristic fallback: `text` field → `children` recursion → ignore.

## Rendering on Demand

`RenderLexical` (experimental) renders the Lexical editor as an RSC without needing a full Form setup. Calls a server action on mount.

```tsx
import { RenderLexical, buildEditorState } from '@payloadcms/richtext-lexical/client'

// Inside existing Form:
<RenderLexical
  field={{ name: 'myFieldName' }}
  initialValue={buildEditorState({ text: 'default' })}
  schemaPath="collection.posts.richText"
/>

// Standalone with controlled state:
<RenderLexical
  field={{ name: 'myField' }}
  schemaPath="collection.posts.richText"
  value={value}
  setValue={setValue}
/>
```

`schemaPath` format: `collection.<slug>.<fieldPath>` or `global.<slug>.<fieldPath>`. If the field is deeply nested, create a hidden top-level `richText` anchor field and reference its path.

## Custom Views

Views (experimental) share rendering logic between the admin panel and JSX converters.

**Define a view map:**
```tsx
// collections/Posts/views.tsx
'use client'
import type { LexicalEditorViewMap } from '@payloadcms/richtext-lexical'

export const postViews: LexicalEditorViewMap = {
  default: { nodes: { heading: { createDOM({ node }) { ... } } } },
  preview: {
    admin: { hideGutter: true },
    filterFeatures: (f) => { const { toolbarFixed, toolbarInline, ...rest } = f; return rest },
    lexical: (cfg) => ({ ...cfg, theme: { ...cfg.theme, paragraph: 'preview-p' } }),
    nodes: {
      blocks: { myBlock: { Component: ({ node, isEditor }) => <div>{isEditor ? node.__fields?.text : node.fields?.text}</div> } },
    },
  },
}
```

**Reference in collection:**
```ts
editor: lexicalEditor({ views: './views#postViews' })
```

**Use in JSX converter:**
```tsx
<RichText data={post.content} nodeMap={postViews.preview.nodes} />
```

- Node map wins over `converters` per-node-type when both provided.
- `useRichTextView()` hook → `{ currentView, views, currentViewMap }`.
- `isEditor` prop distinguishes admin panel (`node.__fields`) vs. JSX converter (`node.fields`).
- `Block` override wraps in default Form (collapsible + edit/remove); `Label` override is editor-only.

## Gotchas

- **Never import Lexical core packages directly** (e.g. `@lexical/utils`); use re-exports from `@payloadcms/richtext-lexical/lexical/utils`. Breaking changes happen on minor Payload bumps.
- **`<img>` in HTML→Lexical conversion is silently dropped** unless `data-lexical-upload-id` and `data-lexical-upload-relation-to` attributes are present.
- **Markdown image syntax `![alt](url)` is not auto-converted** to Upload nodes — use `![media:id]()` placeholder instead.
- **Empty editor ≠ `null`**: after first edit and full delete, value becomes `{ root: { children: [{ type: "paragraph", ... }] } }`. Use `hasText()` to check.
- **Automatic type generation is incomplete**: Lexical outputs only rough JSON shape; use type assertions with `TypedEditorState<...>` manually.
- **`RenderLexical` and Views are experimental** — may have breaking changes in minor releases.
- **Client feature imports**: use `@payloadcms/richtext-lexical/client`, never `@payloadcms/richtext-lexical` in client files (causes webpack/turbopack errors). Type imports are exempt.
- **`EXPERIMENTAL_TableFeature`** may receive breaking changes even in stable Lexical releases.
- **`lexicalHTMLField()` helper** (auto-saves HTML in an `afterRead` hook) is generally discouraged — creates duplicate data in DB.

## Related

- [[wiki/payloadcms/fields-complex|Fields Complex]]
- [[wiki/payloadcms/configuration|Configuration]]
