Overview
The @lexical/headless package allows you to use Lexical in environments without a DOM, such as Node.js servers, build scripts, or testing environments. This is perfect for server-side rendering, content processing, or automated testing.
Headless mode provides all of Lexical’s core functionality except DOM-related features like rendering, selection, and mutations.
Installation
npm install @lexical/headless
# or
pnpm add @lexical/headless
# or
yarn add @lexical/headless
Quick Start
import { createHeadlessEditor } from '@lexical/headless' ;
import { $createParagraphNode , $createTextNode , $getRoot } from 'lexical' ;
const editor = createHeadlessEditor ({
namespace: 'ServerEditor' ,
onError : ( error ) => {
console . error ( error );
},
});
editor . update (() => {
const root = $getRoot ();
const paragraph = $createParagraphNode ();
const text = $createTextNode ( 'Hello from the server!' );
paragraph . append ( text );
root . append ( paragraph );
});
const editorState = editor . getEditorState ();
const json = editorState . toJSON ();
console . log ( json );
API Differences
Supported Methods
Fully Supported
Not Supported
// State management
editor . update ()
editor . read ()
editor . getEditorState ()
editor . setEditorState ()
editor . parseEditorState ()
// Listeners
editor . registerUpdateListener ()
editor . registerTextContentListener ()
editor . registerCommand ()
// Transforms
editor . registerNodeTransform ()
// Utility
editor . dispatchCommand ()
// DOM-related (throw errors)
editor . registerDecoratorListener () ❌
editor . registerRootListener () ❌
editor . registerMutationListener () ❌
editor . getRootElement () ❌
editor . setRootElement () ❌
editor . getElementByKey () ❌
editor . focus () ❌
editor . blur () ❌
Attempting to use unsupported methods will throw an error: “[method] is not supported in headless mode”
Implementation Details
From packages/lexical-headless/src/index.ts:
export function createHeadlessEditor (
editorConfig ?: CreateEditorArgs
) : LexicalEditor {
const editor = createEditor ( editorConfig );
editor . _headless = true ;
const unsupportedMethods = [
'registerDecoratorListener' ,
'registerRootListener' ,
'registerMutationListener' ,
'getRootElement' ,
'setRootElement' ,
'getElementByKey' ,
'focus' ,
'blur' ,
] as const ;
unsupportedMethods . forEach (( method ) => {
editor [ method ] = () => {
throw new Error ( ` ${ method } is not supported in headless mode` );
};
});
return editor ;
}
Common Use Cases
1. Server-Side Rendering
import { createHeadlessEditor } from '@lexical/headless' ;
import { $generateHtmlFromNodes } from '@lexical/html' ;
import { withDOM } from '@lexical/headless/dom' ;
function generateHTML ( contentJSON : string ) : string {
const editor = createHeadlessEditor ({
nodes: [ /* your custom nodes */ ],
});
editor . setEditorState (
editor . parseEditorState ( contentJSON )
);
// Generate HTML using a temporary DOM
return withDOM (() =>
editor . getEditorState (). read (() =>
$generateHtmlFromNodes ( editor )
)
);
}
// Usage in your server
const html = generateHTML ( savedEditorState );
res . send ( `<div> ${ html } </div>` );
2. Content Processing
import { createHeadlessEditor } from '@lexical/headless' ;
import { $getRoot , $isTextNode } from 'lexical' ;
function extractText ( editorStateJSON : string ) : string {
const editor = createHeadlessEditor ();
editor . setEditorState ( editor . parseEditorState ( editorStateJSON ));
return editor . getEditorState (). read (() => {
return $getRoot (). getTextContent ();
});
}
function countWords ( editorStateJSON : string ) : number {
const text = extractText ( editorStateJSON );
return text . split ( / \s + / ). filter ( Boolean ). length ;
}
3. Content Migration
import { createHeadlessEditor } from '@lexical/headless' ;
import { $convertFromMarkdownString } from '@lexical/markdown' ;
import { TRANSFORMERS } from '@lexical/markdown' ;
function migrateMarkdownToLexical ( markdown : string ) {
const editor = createHeadlessEditor ({
nodes: [ /* required nodes for markdown */ ],
});
editor . update (() => {
$convertFromMarkdownString ( markdown , TRANSFORMERS );
});
return editor . getEditorState (). toJSON ();
}
4. Validation
import { createHeadlessEditor } from '@lexical/headless' ;
import { $getRoot , $isElementNode } from 'lexical' ;
function validateEditorState ( json : string ) : {
valid : boolean ;
errors : string [];
} {
const editor = createHeadlessEditor ();
const errors : string [] = [];
try {
const state = editor . parseEditorState ( json );
state . read (() => {
const root = $getRoot ();
// Custom validation rules
if ( root . getChildrenSize () === 0 ) {
errors . push ( 'Content cannot be empty' );
}
root . getAllTextNodes (). forEach ( node => {
if ( node . getTextContent (). length > 10000 ) {
errors . push ( 'Text node exceeds maximum length' );
}
});
});
} catch ( error ) {
errors . push ( `Parse error: ${ error . message } ` );
}
return {
valid: errors . length === 0 ,
errors ,
};
}
Using withDOM
For operations that require a DOM (like HTML generation), use withDOM:
import { withDOM } from '@lexical/headless/dom' ;
import { $generateHtmlFromNodes } from '@lexical/html' ;
const html = withDOM (() => {
// DOM is available here
return editor . getEditorState (). read (() =>
$generateHtmlFromNodes ( editor )
);
});
// DOM is cleaned up after the callback
withDOM uses happy-dom in Node.js environments to provide a lightweight DOM implementation.
Selection Handling
In headless mode, selection is preserved but not tied to the DOM:
import { createHeadlessEditor } from '@lexical/headless' ;
import { $createRangeSelection , $getSelection } from 'lexical' ;
const editor = createHeadlessEditor ();
editor . update (() => {
const paragraph = $createParagraphNode ();
const text = $createTextNode ( 'Hello world' );
paragraph . append ( text );
$getRoot (). append ( paragraph );
// Set selection programmatically
text . select ( 0 , 5 );
});
// Selection persists across updates
editor . update (() => {
const selection = $getSelection ();
console . log ( selection . getTextContent ()); // "Hello"
});
Testing with Headless Mode
import { createHeadlessEditor } from '@lexical/headless' ;
import { describe , it , expect } from 'vitest' ;
describe ( 'Content Processing' , () => {
it ( 'should extract text correctly' , () => {
const editor = createHeadlessEditor ();
editor . update (() => {
const root = $getRoot ();
root . append (
$createParagraphNode (). append (
$createTextNode ( 'First paragraph' )
),
$createParagraphNode (). append (
$createTextNode ( 'Second paragraph' )
)
);
});
const text = editor . getEditorState (). read (() =>
$getRoot (). getTextContent ()
);
expect ( text ). toBe ( 'First paragraph \n\n Second paragraph' );
});
it ( 'should handle transforms' , () => {
const editor = createHeadlessEditor ();
const transformCalls : string [] = [];
editor . registerNodeTransform ( TextNode , ( node ) => {
transformCalls . push ( node . getTextContent ());
});
editor . update (() => {
$getRoot (). append (
$createParagraphNode (). append (
$createTextNode ( 'Test' )
)
);
});
expect ( transformCalls ). toContain ( 'Test' );
});
});
Faster Updates No DOM reconciliation overhead - updates complete in microseconds
Lower Memory No DOM nodes or mutation observers to track
Deterministic Same input always produces same output - perfect for testing
Parallel Processing Run multiple editors simultaneously without DOM conflicts
Benchmarks
import { createHeadlessEditor } from '@lexical/headless' ;
import { performance } from 'perf_hooks' ;
function benchmark () {
const editor = createHeadlessEditor ();
const start = performance . now ();
for ( let i = 0 ; i < 1000 ; i ++ ) {
editor . update (() => {
const paragraph = $createParagraphNode ();
paragraph . append ( $createTextNode ( `Item ${ i } ` ));
$getRoot (). append ( paragraph );
});
}
const end = performance . now ();
console . log ( `1000 updates: ${ end - start } ms` );
// Typically < 100ms in headless mode
// vs ~500ms+ with DOM reconciliation
}
Listeners in Headless Mode
Update Listeners
editor . registerUpdateListener (({ editorState , prevEditorState , tags }) => {
console . log ( 'State updated' );
console . log ( 'Tags:' , tags );
// Access the new state
editorState . read (() => {
const text = $getRoot (). getTextContent ();
console . log ( 'Content:' , text );
});
});
Text Content Listeners
editor . registerTextContentListener (( text ) => {
console . log ( 'Text changed:' , text );
// Perfect for search indexing, word counts, etc.
if ( text . length > 1000 ) {
console . warn ( 'Content exceeds recommended length' );
}
});
Command Listeners
import { CONTROLLED_TEXT_INSERTION_COMMAND } from 'lexical' ;
editor . registerCommand (
CONTROLLED_TEXT_INSERTION_COMMAND ,
( payload ) => {
console . log ( 'Text inserted:' , payload );
// Your custom logic
return false ; // Let other handlers run
},
COMMAND_PRIORITY_NORMAL
);
Complete Example: Content API
import { createHeadlessEditor } from '@lexical/headless' ;
import { $generateHtmlFromNodes } from '@lexical/html' ;
import { withDOM } from '@lexical/headless/dom' ;
import express from 'express' ;
const app = express ();
app . post ( '/api/render' , async ( req , res ) => {
const { editorState } = req . body ;
try {
const editor = createHeadlessEditor ({
nodes: [ /* your custom nodes */ ],
});
// Parse the editor state
editor . setEditorState (
editor . parseEditorState ( editorState )
);
// Extract metadata
const metadata = editor . getEditorState (). read (() => {
const text = $getRoot (). getTextContent ();
return {
wordCount: text . split ( / \s + / ). length ,
charCount: text . length ,
preview: text . slice ( 0 , 200 ),
};
});
// Generate HTML
const html = withDOM (() =>
editor . getEditorState (). read (() =>
$generateHtmlFromNodes ( editor )
)
);
res . json ({ html , metadata });
} catch ( error ) {
res . status ( 400 ). json ({ error: error . message });
}
});
app . listen ( 3000 );
Best Practices
Reuse Editors When Possible
// Bad - creates new editor for each request
app . post ( '/render' , ( req , res ) => {
const editor = createHeadlessEditor ();
// ...
});
// Good - reuse editor instance
const editor = createHeadlessEditor ();
app . post ( '/render' , ( req , res ) => {
editor . setEditorState (
editor . parseEditorState ( req . body . state )
);
// ...
});
// Prefer read() for queries
const text = editor . getEditorState (). read (() =>
$getRoot (). getTextContent ()
);
// Only use update() when modifying state
editor . update (() => {
$getRoot (). clear ();
});
function processContent ( json : string ) {
const editor = createHeadlessEditor ();
try {
const state = editor . parseEditorState ( json );
editor . setEditorState ( state );
return editor . getEditorState (). read (() =>
$getRoot (). getTextContent ()
);
} catch ( error ) {
console . error ( 'Failed to process content:' , error );
return null ;
}
}
Testing Use headless mode for fast, reliable tests
Serialization Working with JSON editor states
HTML Generation Generate HTML from editor content
Performance Optimize headless operations