Skip to main content

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
editorConfig
CreateEditorArgs
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
() => never
Decorator listeners require DOM rendering
registerRootListener
() => never
Root element listeners require DOM
registerMutationListener
() => never
Mutation listeners require DOM observation
getRootElement
() => never
No root element in headless mode
setRootElement
() => never
Cannot set root element in headless mode
getElementByKey
() => never
No DOM elements in headless mode
focus
() => never
Cannot focus without DOM
blur
() => never
Cannot blur without DOM

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.

Performance Benefits

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

FeatureRegular EditorHeadless Editor
DOM RenderingYesNo
User InputYesNo
State ManagementYesYes
CommandsYesYes
TransformsYesYes
SerializationYesYes
CollaborationYesYes
SSRNoYes
Testing SpeedSlowerFaster
Memory UsageHigherLower