Skip to main content

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

// 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()
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\nSecond 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');
  });
});

Performance Benefits

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

// 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