docs/rich-text/migration.mdx
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:
afterRead hook that converts data on-the-flyFirst, 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:
You can verify that:
Example:
import type { CollectionConfig } from 'payload'
import { SlateToLexicalFeature } from '@payloadcms/richtext-lexical/migrate'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
const Pages: CollectionConfig = {
slug: 'pages',
fields: [
{
name: 'content',
type: 'richText',
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
SlateToLexicalFeature({}),
],
}),
},
],
}
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.
Once you've tested the migration in preview mode and verified the results, you can permanently migrate all data in your database.
While the SlateToLexicalFeature works well for testing, running the migration script has important benefits:
afterRead hook converts data on-the-fly, adding overhead to every read operationpayload.db.find instead of payload.find) bypass hooks and return unconverted Slate dataCRITICAL: This will permanently overwrite all Slate data. Follow these steps carefully:
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.
Convert All richText Fields: Update your config to use lexicalEditor() for all richText fields. The script only converts fields that:
SlateToLexicalFeature addedTest 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.
Disable Hooks: Once testing is complete, add disableHooks: true to all SlateToLexicalFeature instances:
SlateToLexicalFeature({ disableHooks: true })
This prevents the afterRead hook from running during migration, improving performance and ensuring clean data writes.
Create a migration script and run it:
import { getPayload } from 'payload'
import config from '@payload-config'
import { migrateSlateToLexical } from '@payloadcms/richtext-lexical/migrate'
const payload = await getPayload({ config })
await migrateSlateToLexical({ payload })
The migration will:
Depending on your database size, this may take considerable time. The script provides detailed progress updates as it runs.
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.
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.
Here's the built-in Upload converter as an example:
import type { SerializedUploadNode } from '@payloadcms/richtext-lexical'
import type { SlateNodeConverter } from '@payloadcms/richtext-lexical'
export const SlateUploadConverter: SlateNodeConverter = {
converter({ slateNode }) {
return {
type: 'upload',
fields: {
...slateNode.fields,
},
format: '',
relationTo: slateNode.relationTo,
type: 'upload',
value: {
id: slateNode.value?.id || '',
},
version: 1,
} as const as SerializedUploadNode
},
nodeTypes: ['upload'],
}
For nodes that contain child nodes (like links), recursively convert the children:
import type { SerializedLinkNode } from '@payloadcms/richtext-lexical'
import type { SlateNodeConverter } from '@payloadcms/richtext-lexical'
import { convertSlateNodesToLexical } from '@payloadcms/richtext-lexical/migrate'
export const SlateLinkConverter: SlateNodeConverter = {
converter({ converters, slateNode }) {
return {
type: 'link',
children: convertSlateNodesToLexical({
canContainParagraphs: false,
converters,
parentNodeType: 'link',
slateNodes: slateNode.children || [],
}),
direction: 'ltr',
fields: {
doc: slateNode.doc || null,
linkType: slateNode.linkType || 'custom',
newTab: slateNode.newTab || false,
url: slateNode.url || '',
},
format: '',
indent: 0,
version: 2,
} as const as SerializedLinkNode
},
nodeTypes: ['link'],
}
Your converter function receives these parameters:
{
slateNode: SlateNode, // The Slate node to convert
converters: SlateNodeConverter[], // All available converters (for recursive conversion)
parentNodeType: string, // The Lexical node type of the parent
childIndex: number, // Index of this node in parent's children array
}
You can add custom converters by passing an array of converters to the converters property of the SlateToLexicalFeature props:
import type { CollectionConfig } from 'payload'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import {
SlateToLexicalFeature,
defaultSlateConverters,
} from '@payloadcms/richtext-lexical/migrate'
import { MyCustomConverter } from './converters/MyCustomConverter'
const Pages: CollectionConfig = {
slug: 'pages',
fields: [
{
name: 'content',
type: 'richText',
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
SlateToLexicalFeature({
converters: [...defaultSlateConverters, MyCustomConverter],
}),
],
}),
},
],
}
If the migration encounters a Slate node without a converter, it:
unknownConverted node that preserves the original dataThis ensures your migration completes even if some converters are missing, allowing you to handle edge cases later.
<Banner type="info"> The migration script automatically traverses all collections and fields, retrieves converters from the `SlateToLexicalFeature` on each field, and converts the data using those converters.If you're manually calling convertSlateToLexical, you can pass converters directly:
import { convertSlateToLexical } from '@payloadcms/richtext-lexical/migrate'
const lexicalData = convertSlateToLexical({
slateData: mySlateData,
converters: [...defaultSlateConverters, MyCustomConverter],
})
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.
import { upgradeLexicalData } from '@payloadcms/richtext-lexical'
await upgradeLexicalData({ payload })
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.