Overview
The @lexical/headless package provides a headless version of the Lexical editor that can run without a DOM environment, perfect for server-side rendering, testing, or Node.js applications.
Installation
npm install @lexical/headless
For DOM operations, the headless package includes happy-dom as a dependency.
Core Function
createHeadlessEditor
Creates a Lexical editor that runs without requiring a browser DOM.
function createHeadlessEditor(
editorConfig?: CreateEditorArgs
): LexicalEditor
Optional editor configuration (same as createEditor)
Returns: LexicalEditor instance with DOM methods disabled
Example:
import { createHeadlessEditor } from '@lexical/headless';
import { $createParagraphNode, $createTextNode, $getRoot } from 'lexical';
const editor = createHeadlessEditor({
namespace: 'ServerEditor',
nodes: [/* your nodes */],
onError: (error) => console.error(error)
});
editor.update(() => {
const root = $getRoot();
const paragraph = $createParagraphNode();
paragraph.append($createTextNode('Hello from server!'));
root.append(paragraph);
});
Disabled Methods
The following methods are disabled in headless mode and will throw an error if called:
registerDecoratorListener
Decorator listeners require DOM rendering
Root element listeners require DOM
Mutation listeners require DOM observation
No root element in headless mode
Cannot set root element in headless mode
No DOM elements in headless mode
Supported Operations
Headless editors support all non-DOM operations:
- State manipulation:
editor.update(), editor.read(), editor.getEditorState()
- Node operations: Creating, modifying, and querying nodes
- Selection: Creating and manipulating selections programmatically
- Commands: Dispatching and handling commands
- Serialization:
toJSON(), fromJSON(), exportJSON(), importJSON()
- Transforms: Registering node transforms
- Listeners: Update listeners, command listeners
- Collaboration: Yjs integration works in headless mode
Use Cases
Server-Side Rendering (SSR)
import { createHeadlessEditor } from '@lexical/headless';
import { $generateHtmlFromNodes } from '@lexical/html';
import { JSDOM } from 'jsdom';
// Set up JSDOM for HTML generation
const jsdom = new JSDOM('<!DOCTYPE html>');
global.document = jsdom.window.document;
global.window = jsdom.window as any;
function renderEditorContent(jsonState: string): string {
const editor = createHeadlessEditor({
nodes: [/* your nodes */]
});
// Load state from JSON
const state = editor.parseEditorState(jsonState);
editor.setEditorState(state);
// Generate HTML
return state.read(() => {
return $generateHtmlFromNodes(editor);
});
}
const html = renderEditorContent(savedJsonState);
console.log(html);
Content Processing
import { createHeadlessEditor } from '@lexical/headless';
import { $convertFromMarkdownString, $convertToMarkdownString } from '@lexical/markdown';
function convertMarkdownToJson(markdown: string): string {
const editor = createHeadlessEditor({
nodes: [/* markdown-compatible nodes */]
});
editor.update(() => {
$convertFromMarkdownString(markdown);
});
return JSON.stringify(editor.getEditorState().toJSON());
}
const jsonState = convertMarkdownToJson('# Hello\n\nThis is **markdown**.');
Testing
import { describe, it, expect } from 'vitest';
import { createHeadlessEditor } from '@lexical/headless';
import { $getRoot, $createParagraphNode, $createTextNode } from 'lexical';
describe('Editor Tests', () => {
it('should create and modify content', () => {
const editor = createHeadlessEditor();
editor.update(() => {
const root = $getRoot();
const paragraph = $createParagraphNode();
const text = $createTextNode('Test content');
paragraph.append(text);
root.append(paragraph);
});
editor.read(() => {
const root = $getRoot();
expect(root.getTextContent()).toBe('Test content');
});
});
it('should handle transforms', () => {
const editor = createHeadlessEditor({
nodes: [CustomNode]
});
const transformFn = vi.fn();
editor.registerNodeTransform(CustomNode, transformFn);
editor.update(() => {
$getRoot().append(new CustomNode());
});
expect(transformFn).toHaveBeenCalled();
});
});
Batch Content Conversion
import { createHeadlessEditor } from '@lexical/headless';
import { $generateHtmlFromNodes } from '@lexical/html';
interface Document {
id: string;
lexicalState: string;
}
async function batchConvertToHtml(documents: Document[]): Promise<Map<string, string>> {
const editor = createHeadlessEditor({
nodes: [/* your nodes */]
});
const results = new Map<string, string>();
for (const doc of documents) {
const state = editor.parseEditorState(doc.lexicalState);
editor.setEditorState(state);
const html = state.read(() => $generateHtmlFromNodes(editor));
results.set(doc.id, html);
}
return results;
}
Content Validation
import { createHeadlessEditor } from '@lexical/headless';
import { $getRoot } from 'lexical';
function validateEditorContent(jsonState: string): {
isValid: boolean;
errors: string[];
} {
const errors: string[] = [];
const editor = createHeadlessEditor({
onError: (error) => {
errors.push(error.message);
},
nodes: [/* your nodes */]
});
try {
const state = editor.parseEditorState(jsonState);
editor.setEditorState(state);
state.read(() => {
const root = $getRoot();
// Custom validation logic
if (root.getChildrenSize() === 0) {
errors.push('Content is empty');
}
// Validate specific nodes
const children = root.getAllTextNodes();
children.forEach(node => {
const text = node.getTextContent();
if (text.includes('forbidden-word')) {
errors.push('Content contains forbidden words');
}
});
});
} catch (error) {
errors.push(`Parse error: ${error.message}`);
}
return {
isValid: errors.length === 0,
errors
};
}
With Markdown
import { createHeadlessEditor } from '@lexical/headless';
import {
$convertFromMarkdownString,
$convertToMarkdownString,
TRANSFORMERS
} from '@lexical/markdown';
import { HeadingNode, QuoteNode } from '@lexical/rich-text';
import { CodeNode } from '@lexical/code';
import { LinkNode } from '@lexical/link';
import { ListNode, ListItemNode } from '@lexical/list';
function processMarkdown(input: string): string {
const editor = createHeadlessEditor({
nodes: [
HeadingNode,
QuoteNode,
CodeNode,
LinkNode,
ListNode,
ListItemNode
]
});
// Convert markdown to Lexical
editor.update(() => {
$convertFromMarkdownString(input, TRANSFORMERS);
});
// Process/modify content
editor.update(() => {
const root = $getRoot();
// ... make modifications ...
});
// Convert back to markdown
return editor.getEditorState().read(() => {
return $convertToMarkdownString(TRANSFORMERS);
});
}
const processed = processMarkdown('# Title\n\nContent');
With Yjs Collaboration
import { createHeadlessEditor } from '@lexical/headless';
import { createBinding } from '@lexical/yjs';
import * as Y from 'yjs';
const doc = new Y.Doc();
const docMap = new Map([['main', doc]]);
const editor = createHeadlessEditor({
nodes: [/* your nodes */]
});
// Collaboration works in headless mode!
const binding = createBinding(
editor,
provider,
'main',
doc,
docMap
);
Important Notes
Headless editors cannot:
- Render to the DOM
- Handle user input events
- Use decorators that require rendering
- Access or manipulate DOM elements
- Use plugins that depend on DOM APIs
For HTML generation in headless mode, use @lexical/html with a DOM implementation like jsdom or happy-dom.
Headless editors are ideal for:
- Server-side processing: No browser overhead
- Batch operations: Process multiple documents efficiently
- Testing: Fast unit tests without DOM rendering
- Build-time generation: Pre-render content during build
- API endpoints: Convert/validate content in backend services
Comparison with Regular Editor
| Feature | Regular Editor | Headless Editor |
|---|
| DOM Rendering | Yes | No |
| User Input | Yes | No |
| State Management | Yes | Yes |
| Commands | Yes | Yes |
| Transforms | Yes | Yes |
| Serialization | Yes | Yes |
| Collaboration | Yes | Yes |
| SSR | No | Yes |
| Testing Speed | Slower | Faster |
| Memory Usage | Higher | Lower |