---
title: "Rich Text — Converting Markdown"
aliases: [lexical-markdown, lexical-to-markdown, markdown-to-lexical, mdx-lexical]
tags: [payloadcms, lexical, rich-text, markdown, mdx]
sources: [raw/rich-text__converting-markdown.md]
created: 2026-05-15
updated: 2026-05-15
---

## Overview

Payload's Lexical editor supports bidirectional conversion between its `SerializedEditorState` JSON and Markdown / MDX. Both directions require the **editor config**, obtained via `editorConfigFactory`.

---

## Lexical → Markdown

```ts
import { convertLexicalToMarkdown, editorConfigFactory } from '@payloadcms/richtext-lexical'
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'

const markdown = convertLexicalToMarkdown({
  data,  // SerializedEditorState
  editorConfig: await editorConfigFactory.default({ config }),
})
```

### Hook pattern — sibling `markdown` textarea

A common pattern exposes a read-only `markdown` sibling field computed on `afterRead`, and deletes it on `beforeChange` so it is never persisted:

```ts
hooks: {
  afterRead: [({ siblingData, siblingFields }) => {
    const data: SerializedEditorState = siblingData['nameOfYourRichTextField']
    if (!data) return ''
    return convertLexicalToMarkdown({
      data,
      editorConfig: editorConfigFactory.fromField({
        field: siblingFields.find(f => 'name' in f && f.name === 'nameOfYourRichTextField') as RichTextField,
      }),
    })
  }],
  beforeChange: [({ siblingData }) => {
    delete siblingData['markdown']  // never persist
    return null
  }],
}
```

---

## Markdown → Lexical

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

const lexicalJSON = convertMarkdownToLexical({
  editorConfig: await editorConfigFactory.default({ config }),
  markdown: '# Hello world\n\nThis is a **test**.',
})
```

---

## Converting Uploads

Standard `![alt](url)` images are **not** automatically converted to Upload nodes — Payload cannot resolve a URL back to a Media document ID.

**Special placeholder format:**
```
![media:6507f7b9a4d3c2e1f0ab1234]()
```
- `media` = upload collection slug
- second segment = document ID

**Lexical → Markdown:** Upload nodes serialize to this placeholder (or standard `![alt](url)` if the document is already populated).

### Migrating existing Markdown with images

1. Upload images to the Media collection → get document IDs
2. Pre-process Markdown: replace `![alt](url)` → `![media:<id>]()`
3. Run `convertMarkdownToLexical`

---

## Converting MDX

MDX support is block-level: converters are defined on **blocks** passed to `BlocksFeature`, not on Lexical features.

### Block `jsx` property

Each block can define `jsx.export` (Lexical → MDX) and `jsx.import` (MDX → Lexical):

```ts
const BannerBlock: Block = {
  slug: 'Banner',
  fields: [/* ... */],
  jsx: {
    export: ({ fields, lexicalToMarkdown }) => ({
      props: { type: fields.type },
      children: lexicalToMarkdown({ editorState: fields.content }),
    }),
    import: ({ children, markdownToLexical, props }) => ({
      type: props?.type,
      content: markdownToLexical({ markdown: children }),
    }),
  },
}
```

### `export` return shape

| Property | Type | Description |
|----------|------|-------------|
| `children` | string | Text between opening/closing MDX tags |
| `props` | object | Props on the opening tag |

### `import` arguments

| Argument | Type | Description |
|----------|------|-------------|
| `children` | string | Text between opening/closing MDX tags |
| `props` | object | Parsed props from the opening tag |

Returns: block field data object.

---

## editorConfigFactory Methods

| Method | When to use |
|--------|-------------|
| `editorConfigFactory.default({ config })` | Top-level scripts, routes, server actions |
| `editorConfigFactory.fromField({ field })` | Inside hooks referencing a sibling `RichTextField` |
| `editorConfigFactory.fromFeatures(...)` | When you only have a feature list |
| `editorConfigFactory.fromEditor(...)` | When you have the raw Lexical editor instance |

See [[wiki/payloadcms/rich-text-converters|Rich Text — Converters]] for the full `editorConfigFactory` reference.

---

## Key Takeaways

- Both `convertLexicalToMarkdown` and `convertMarkdownToLexical` require an **editorConfig** — always obtain it via `editorConfigFactory`.
- The sibling `markdown` textarea pattern (afterRead + beforeChange `delete`) is the standard way to expose computed Markdown without polluting the DB.
- Standard `![alt](url)` images do **not** round-trip through `convertMarkdownToLexical` — use the `![media:<id>]()` placeholder format instead.
- MDX block converters live on the **block definition** (`jsx.export` / `jsx.import`), not on Lexical features.
- For MDX, `children` is the inner content string and `props` is the parsed props object — both are passed through `markdownToLexical` / `lexicalToMarkdown` helpers provided in the callback.

---

## Related

- [[wiki/payloadcms/rich-text-converters|Rich Text — Converters (Lexical)]]
- [[wiki/payloadcms/rich-text-converting-html|Rich Text — Converting HTML]]
- [[wiki/payloadcms/rich-text-converting-jsx|Rich Text — Converting to JSX]]
- [[wiki/payloadcms/rich-text-blocks|Rich Text — Blocks (Lexical BlocksFeature)]]
- [[wiki/payloadcms/rich-text|PayloadCMS — Rich Text (Lexical)]]
- [[wiki/payloadcms/hooks-fields|Field Hooks]]

---

## Sources

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