Draft.js is archived. No maintenance, no security patches, 820K weekly downloads running on momentum. Meta says use Lexical, but never shipped a migration path — issues #1641 and #2197 have been open since 2022.
I built draft-to-lexical because I needed it and it didn't exist.
The problem
Draft.js content (RawDraftContentState) is a flat list of blocks with style ranges and an entity map. Lexical (SerializedEditorState) is a nested node tree. You can't rename a few fields and call it done — the structures are incompatible.
Draft.js:
{
"blocks": [
{
"text": "Hello bold world",
"type": "unstyled",
"inlineStyleRanges": [
{ "offset": 6, "length": 4, "style": "BOLD" }
]
}
],
"entityMap": {}
}
Lexical:
{
"root": {
"type": "root",
"children": [
{
"type": "paragraph",
"children": [
{ "type": "text", "text": "Hello ", "format": 0 },
{ "type": "text", "text": "bold", "format": 1 },
{ "type": "text", "text": " world", "format": 0 }
]
}
]
}
}
The differences that matter:
Flat vs. nested: Draft.js blocks are a flat array. Lexical nodes form a tree.
Style ranges vs. format flags: Draft.js uses offset/length ranges. Lexical uses bitwise flags on each text node.
Entity map vs. wrapper nodes: Draft.js stores links in a separate entityMap. Lexical wraps text inside LinkNode.
List items: Draft.js keeps them as flat blocks with a depth field. Lexical nests listitem nodes inside list nodes.
Install
npm install draft-to-lexical
As a library
import { convert } from 'draft-to-lexical';
const draftContent = {
blocks: [
{
key: 'a1',
text: 'Hello bold world',
type: 'unstyled',
depth: 0,
inlineStyleRanges: [{ offset: 6, length: 4, style: 'BOLD' }],
entityRanges: [],
data: {},
},
],
entityMap: {},
};
const lexicalState = convert(draftContent);
As a CLI
Single file
draft-to-lexical convert input.json -o output.json --pretty
Pipe from stdin
cat draft-content.json | draft-to-lexical convert --pretty
Batch convert a directory
draft-to-lexical batch ./draft-files/ -o ./lexical-files/ --pretty
What gets converted
Block types
Draft.js Lexical
unstyled paragraph
header-one ... header-six heading (h1-h6)
blockquote quote
code-block code
unordered-list-item listitem inside list (bullet)
ordered-list-item listitem inside list (number)
atomic Depends on entity
Inline styles
All standard styles map to Lexical's bitwise format flags: BOLD (1), ITALIC (2), UNDERLINE (4), STRIKETHROUGH (8), CODE (16), HIGHLIGHT (32), SUBSCRIPT (64), SUPERSCRIPT (128).
Overlapping styles get OR'd together — bold + italic = format flag 3.
Entities
Links become Lexical LinkNode wrapping text children. Images become ImageNode with src, alt, width, height.
Custom entities
Every Draft.js project has custom entities — mentions, embeds, file attachments. The converter lets you write handlers for those:
import { convert } from 'draft-to-lexical';
const result = convert(draftContent, {
entityHandlers: {
MENTION: (entity, text, children) => ({
type: 'mention',
version: 1,
mentionName: entity.data.name,
detail: 0,
format: 0,
mode: 'normal',
style: '',
text,
}),
VIDEO: (entity) => ({
type: 'video',
version: 1,
src: entity.data.src,
}),
},
});
Migrating a database
If you have thousands of documents stored as Draft.js JSON, use the batch CLI or write a migration script:
import { convert } from 'draft-to-lexical';
import { db } from './your-database';
const documents = await db.query('SELECT id, content FROM documents');
for (const doc of documents) {
const draftContent = JSON.parse(doc.content);
const lexicalState = convert(draftContent);
await db.query(
'UPDATE documents SET content = $1 WHERE id = $2',
[JSON.stringify(lexicalState), doc.id]
);
}
Links
https://github.com/dragogargo/draft-to-lexical
https://www.npmjs.com/package/draft-to-lexical
Top comments (0)