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

Build docs developers (and LLMs) love