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

Build docs developers (and LLMs) love