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.

Lexical Migration

Migrating from Slate

While both Slate and Lexical save the editor state in JSON, the structure of the JSON is different. Payload provides a two-phase migration approach that allows you to safely migrate from Slate to Lexical:

  1. Preview Phase: Test the migration with an afterRead hook that converts data on-the-fly
  2. Migration Phase: Run a script to permanently migrate all data in the database

Phase 1: Preview & Test

First, add the SlateToLexicalFeature to every richText field you want to migrate. By default, this feature converts your data from Slate to Lexical format on-the-fly through an afterRead hook. If the data is already in Lexical format, it passes through unchanged.

This allows you to test the migration without modifying your database. The on-the-fly conversion happens server-side through the afterRead hook, which means:

  • In the Admin Panel: Preview how your content will look in the Lexical editor
  • In your API: All read operations (REST, GraphQL, Local API) return converted Lexical data instead of Slate data
  • In your application: Your frontend receives Lexical data, allowing you to test if your app correctly handles the new format

You can verify that:

  • All content converts correctly
  • Custom nodes are handled properly
  • Formatting is preserved
  • Your application displays the Lexical data as expected
  • Any custom converters work as expected

Example:

1
import type { CollectionConfig } from 'payload'
2
3
import { SlateToLexicalFeature } from '@payloadcms/richtext-lexical/migrate'
4
import { lexicalEditor } from '@payloadcms/richtext-lexical'
5
6
const Pages: CollectionConfig = {
7
slug: 'pages',
8
fields: [
9
{
10
name: 'content',
11
type: 'richText',
12
editor: lexicalEditor({
13
features: ({ defaultFeatures }) => [
14
...defaultFeatures,
15
SlateToLexicalFeature({}),
16
],
17
}),
18
},
19
],
20
}

Important: In preview mode, if you save a document in the Admin Panel, it will overwrite the Slate data with the converted Lexical data in the database. Only save if you've verified the conversion is correct.

Each richText field has its own SlateToLexicalFeature instance because each field may require different converters. For example, one field might contain custom Slate nodes that need custom converters.

Phase 2: Running the Migration Script

Once you've tested the migration in preview mode and verified the results, you can permanently migrate all data in your database.

Why Run the Migration Script?

While the SlateToLexicalFeature works well for testing, running the migration script has important benefits:

  • Performance: The afterRead hook converts data on-the-fly, adding overhead to every read operation
  • Database Consistency: Direct database operations (e.g., payload.db.find instead of payload.find) bypass hooks and return unconverted Slate data
  • Production Ready: After migration, your data is fully converted and you can remove the migration feature

Migration Prerequisites

CRITICAL: This will permanently overwrite all Slate data. Follow these steps carefully:

  1. Backup Your Database: Create a complete backup of your database before proceeding. If anything goes wrong without a backup, data recovery may not be possible.
  2. Convert All richText Fields: Update your config to use lexicalEditor() for all richText fields. The script only converts fields that:
  • Use the Lexical editor
  • Have SlateToLexicalFeature added
  • Contain Slate data in the database
  1. Test the Preview: Add SlateToLexicalFeature to every richText field (as shown in Phase 1) and thoroughly test in the Admin Panel. Build custom converters for any custom Slate nodes before proceeding.
  2. Disable Hooks: Once testing is complete, add disableHooks: true to all SlateToLexicalFeature instances:
1
SlateToLexicalFeature({ disableHooks: true })

This prevents the afterRead hook from running during migration, improving performance and ensuring clean data writes.

Running the Migration

Create a migration script and run it:

1
import { getPayload } from 'payload'
2
import config from '@payload-config'
3
import { migrateSlateToLexical } from '@payloadcms/richtext-lexical/migrate'
4
5
const payload = await getPayload({ config })
6
7
await migrateSlateToLexical({ payload })

The migration will:

  • Process all collections and globals
  • Handle all locales (if localization is enabled)
  • Migrate both published and draft documents
  • Recursively process nested fields (arrays, blocks, tabs, groups)
  • Log progress for each collection and document
  • Collect and report any errors at the end

Depending on your database size, this may take considerable time. The script provides detailed progress updates as it runs.

Converting Custom Slate Nodes

If your Slate editor includes custom nodes, you'll need to create custom converters for them. A converter transforms a Slate node structure into its Lexical equivalent.

How Converters Work

Each converter receives the Slate node and returns the corresponding Lexical node. The converter also specifies which Slate node types it handles via the nodeTypes array.

Example: Simple Node Converter

Here's the built-in Upload converter as an example:

1
import type { SerializedUploadNode } from '@payloadcms/richtext-lexical'
2
import type { SlateNodeConverter } from '@payloadcms/richtext-lexical'
3
4
export const SlateUploadConverter: SlateNodeConverter = {
5
converter({ slateNode }) {
6
return {
7
type: 'upload',
8
fields: {
9
...slateNode.fields,
10
},
11
format: '',
12
relationTo: slateNode.relationTo,
13
type: 'upload',
14
value: {
15
id: slateNode.value?.id || '',
16
},
17
version: 1,
18
} as const as SerializedUploadNode
19
},
20
nodeTypes: ['upload'],
21
}

Example: Node with Children

For nodes that contain child nodes (like links), recursively convert the children:

1
import type { SerializedLinkNode } from '@payloadcms/richtext-lexical'
2
import type { SlateNodeConverter } from '@payloadcms/richtext-lexical'
3
import { convertSlateNodesToLexical } from '@payloadcms/richtext-lexical/migrate'
4
5
export const SlateLinkConverter: SlateNodeConverter = {
6
converter({ converters, slateNode }) {
7
return {
8
type: 'link',
9
children: convertSlateNodesToLexical({
10
canContainParagraphs: false,
11
converters,
12
parentNodeType: 'link',
13
slateNodes: slateNode.children || [],
14
}),
15
direction: 'ltr',
16
fields: {
17
doc: slateNode.doc || null,
18
linkType: slateNode.linkType || 'custom',
19
newTab: slateNode.newTab || false,
20
url: slateNode.url || '',
21
},
22
format: '',
23
indent: 0,
24
version: 2,
25
} as const as SerializedLinkNode
26
},
27
nodeTypes: ['link'],
28
}

Converter API

Your converter function receives these parameters:

1
{
2
slateNode: SlateNode, // The Slate node to convert
3
converters: SlateNodeConverter[], // All available converters (for recursive conversion)
4
parentNodeType: string, // The Lexical node type of the parent
5
childIndex: number, // Index of this node in parent's children array
6
}

Adding Custom Converters

You can add custom converters by passing an array of converters to the converters property of the SlateToLexicalFeature props:

1
import type { CollectionConfig } from 'payload'
2
import { lexicalEditor } from '@payloadcms/richtext-lexical'
3
import {
4
SlateToLexicalFeature,
5
defaultSlateConverters,
6
} from '@payloadcms/richtext-lexical/migrate'
7
import { MyCustomConverter } from './converters/MyCustomConverter'
8
9
const Pages: CollectionConfig = {
10
slug: 'pages',
11
fields: [
12
{
13
name: 'content',
14
type: 'richText',
15
editor: lexicalEditor({
16
features: ({ defaultFeatures }) => [
17
...defaultFeatures,
18
SlateToLexicalFeature({
19
converters: [...defaultSlateConverters, MyCustomConverter],
20
}),
21
],
22
}),
23
},
24
],
25
}

Unknown Node Handling

If the migration encounters a Slate node without a converter, it:

  1. Logs a warning to the console
  2. Wraps it in an unknownConverted node that preserves the original data
  3. Continues migration without failing

This ensures your migration completes even if some converters are missing, allowing you to handle edge cases later.

Migrating lexical data from old version to new version

Each lexical node has a version property which is saved in the database. Every time we make a breaking change to the node's data, we increment the version. This way, we can detect an old version and automatically convert old data to the new format once you open up the editor.

The problem is, this migration only happens when you open the editor, modify the richText field (so that the field's setValue function is called) and save the document. Until you do that for all documents, some documents will still have the old data.

To solve this, we export an upgradeLexicalData function which goes through every single document in your Payload app and re-saves it, if it has a lexical editor. This way, the data is automatically converted to the new format, and that automatic conversion gets applied to every single document in your app.

IMPORTANT: Take a backup of your entire database. If anything goes wrong and you do not have a backup, you are on your own and will not receive any support.

1
import { upgradeLexicalData } from '@payloadcms/richtext-lexical'
2
3
await upgradeLexicalData({ payload })

Migrating from payload-plugin-lexical

Migrating from payload-plugin-lexical works similar to migrating from Slate.

Instead of a SlateToLexicalFeature there is a LexicalPluginToLexicalFeature you can use. And instead of convertSlateToLexical you can use convertLexicalPluginToLexical.

Next

Slate Editor