Skip to main content
Lexical’s document is built from a tree of nodes. Every piece of content—from a character of text to a table cell—is represented by a node. Understanding the node system is essential to working with Lexical.

Node Hierarchy

All nodes extend from the base LexicalNode class:
LexicalNode (abstract base)
├── TextNode (text content)
├── LineBreakNode (line breaks)
├── TabNode (tab characters)
├── ElementNode (container nodes)
│   ├── RootNode (editor root)
│   ├── ParagraphNode
│   ├── HeadingNode
│   ├── QuoteNode
│   └── ... custom element nodes
└── DecoratorNode (custom UI)
    ├── ImageNode
    ├── TwitterNode
    └── ... custom decorators

The LexicalNode Base Class

Every node inherits from LexicalNode:
class LexicalNode {
  /** Unique identifier for this node */
  __key: string;
  
  /** Parent node key */
  __parent: null | NodeKey;
  
  /** Previous sibling key */
  __prev: null | NodeKey;
  
  /** Next sibling key */
  __next: null | NodeKey;
  
  /** Node type identifier */
  __type: string;
}

Required Static Methods

Every node class must implement:

getType()

Returns a unique string identifier:
class CustomNode extends ElementNode {
  static getType(): string {
    return 'custom';
  }
}
The type string must be unique across all nodes registered in the editor.

clone()

Creates a copy of the node:
static clone(node: CustomNode): CustomNode {
  return new CustomNode(node.__key);
}

importJSON()

Deserializes JSON to create a node instance:
static importJSON(serializedNode: SerializedCustomNode): CustomNode {
  const node = $createCustomNode();
  return node.updateFromJSON(serializedNode);
}

Required Instance Methods

createDOM()

Creates the DOM element for this node:
createDOM(config: EditorConfig): HTMLElement {
  const element = document.createElement('div');
  element.className = config.theme.custom || '';
  return element;
}

updateDOM()

Updates an existing DOM element. Return true to force recreation:
updateDOM(
  prevNode: CustomNode,
  dom: HTMLElement,
  config: EditorConfig
): boolean {
  // Return true if we need to recreate the element
  // Return false if we can update it in place
  return false;
}

exportJSON()

Serializes the node to JSON:
exportJSON(): SerializedCustomNode {
  return {
    ...super.exportJSON(),
    type: 'custom',
    version: 1,
    customProperty: this.__customProperty,
  };
}

Node Types

TextNode

Represents text content with formatting:
import { $createTextNode } from 'lexical';

editor.update(() => {
  const textNode = $createTextNode('Hello, world!');
  
  // Apply formatting
  textNode.setFormat('bold');
  textNode.setStyle('color: red');
  
  // Modify text
  textNode.setTextContent('Updated text');
  
  // Split at offset
  const [before, after] = textNode.splitText(5);
});

Text Formatting

Available formats (can be combined):
  • bold
  • italic
  • underline
  • strikethrough
  • code
  • subscript
  • superscript
textNode.toggleFormat('bold');
textNode.hasFormat('italic'); // boolean

ElementNode

Container nodes that can have children:
import { ElementNode } from 'lexical';

class CustomBlock extends ElementNode {
  static getType() {
    return 'custom-block';
  }
  
  static clone(node: CustomBlock): CustomBlock {
    return new CustomBlock(node.__key);
  }
  
  createDOM(config: EditorConfig): HTMLElement {
    const dom = document.createElement('div');
    dom.className = 'custom-block';
    return dom;
  }
  
  updateDOM(): boolean {
    return false;
  }
  
  // Element nodes can have children
  canBeEmpty(): boolean {
    return false; // This node must have at least one child
  }
  
  canInsertTextBefore(): boolean {
    return true;
  }
  
  canInsertTextAfter(): boolean {
    return true;
  }
}

Working with Children

editor.update(() => {
  const element = $createParagraphNode();
  
  // Append children
  element.append(
    $createTextNode('First '),
    $createTextNode('Second')
  );
  
  // Get children
  const children = element.getChildren();
  const firstChild = element.getFirstChild();
  const lastChild = element.getLastChild();
  
  // Insert at index
  element.splice(1, 0, [$createTextNode('Inserted')]);
  
  // Clear all children
  element.clear();
});

DecoratorNode

For embedding custom UI components:
import { DecoratorNode } from 'lexical';

class ImageNode extends DecoratorNode<JSX.Element> {
  __src: string;
  __altText: string;
  
  static getType(): string {
    return 'image';
  }
  
  static clone(node: ImageNode): ImageNode {
    return new ImageNode(node.__src, node.__altText, node.__key);
  }
  
  constructor(src: string, altText: string, key?: NodeKey) {
    super(key);
    this.__src = src;
    this.__altText = altText;
  }
  
  createDOM(): HTMLElement {
    const span = document.createElement('span');
    return span;
  }
  
  updateDOM(): boolean {
    return false;
  }
  
  // The decorator is the React component to render
  decorate(): JSX.Element {
    return <img src={this.__src} alt={this.__altText} />;
  }
  
  isInline(): boolean {
    return true; // or false for block-level
  }
}

Node Keys

Every node has a unique key that persists across clones:
editor.update(() => {
  const node = $createTextNode('Hello');
  const key = node.getKey(); // e.g., "4"
  
  // Later, retrieve by key
  const retrieved = $getNodeByKey(key);
  
  // Keys are stable across mutations
  const writable = node.getWritable();
  console.log(writable.getKey() === key); // true
});
Keys are runtime-only identifiers. They’re not stable across sessions—use JSON serialization for persistence.

Node Manipulation

Getting Nodes

import { $getNodeByKey, $getRoot } from 'lexical';

editor.read(() => {
  // Get root node
  const root = $getRoot();
  
  // Get by key
  const node = $getNodeByKey('5');
  
  // Navigate tree
  const parent = node.getParent();
  const nextSibling = node.getNextSibling();
  const prevSibling = node.getPreviousSibling();
  
  // Get all ancestors
  const ancestors = node.getParents();
  
  // Get top-level block
  const topBlock = node.getTopLevelElement();
});

Inserting Nodes

editor.update(() => {
  const node = $createTextNode('New');
  const existingNode = $getRoot().getFirstChild();
  
  // Insert before/after
  existingNode.insertBefore(node);
  existingNode.insertAfter(node);
  
  // Append to parent
  const paragraph = $createParagraphNode();
  paragraph.append(
    $createTextNode('Text 1'),
    $createTextNode('Text 2')
  );
});

Removing Nodes

editor.update(() => {
  const node = $getRoot().getFirstChild();
  
  // Remove node
  node.remove();
  
  // Remove with preserving empty parent
  node.remove(true);
});

Replacing Nodes

editor.update(() => {
  const oldNode = $getRoot().getFirstChild();
  const newNode = $createParagraphNode();
  
  // Replace without moving children
  oldNode.replace(newNode);
  
  // Replace and move children to new node
  oldNode.replace(newNode, true);
});

Node Properties

Checking Node Type

import { 
  $isTextNode, 
  $isElementNode, 
  $isParagraphNode,
  $isRootNode
} from 'lexical';

editor.read(() => {
  const node = $getRoot().getFirstChild();
  
  if ($isTextNode(node)) {
    console.log('Text:', node.getTextContent());
  } else if ($isParagraphNode(node)) {
    console.log('Paragraph with', node.getChildrenSize(), 'children');
  }
});

Node State

editor.read(() => {
  const node = $getRoot().getFirstChild();
  
  // Is this node attached to the editor?
  node.isAttached(); // boolean
  
  // Is this node in the current selection?
  node.isSelected(); // boolean
  
  // Has this node been marked dirty?
  node.isDirty(); // boolean
  
  // Get the latest version of this node
  const latest = node.getLatest();
  
  // Get a writable version (for mutations)
  const writable = node.getWritable();
});

Mutable vs Immutable

Nodes are immutable after reconciliation. During an update, use getWritable():
editor.update(() => {
  const node = $getRoot().getFirstChild();
  
  // Most mutation methods call getWritable() internally
  node.append($createTextNode('Added'));
  
  // Manually get writable version
  const writable = node.getWritable();
  writable.__somePrivateProperty = 'value';
});

Creating Custom Nodes

Here’s a complete example:
import {
  ElementNode,
  type EditorConfig,
  type LexicalNode,
  type NodeKey,
  type SerializedElementNode,
} from 'lexical';

export type SerializedCalloutNode = SerializedElementNode;

export class CalloutNode extends ElementNode {
  static getType(): string {
    return 'callout';
  }
  
  static clone(node: CalloutNode): CalloutNode {
    return new CalloutNode(node.__key);
  }
  
  createDOM(config: EditorConfig): HTMLElement {
    const dom = document.createElement('div');
    dom.className = 'callout';
    return dom;
  }
  
  updateDOM(prevNode: CalloutNode, dom: HTMLElement): boolean {
    // No updates needed to the element itself
    return false;
  }
  
  static importJSON(serializedNode: SerializedCalloutNode): CalloutNode {
    return $createCalloutNode();
  }
  
  exportJSON(): SerializedCalloutNode {
    return {
      ...super.exportJSON(),
      type: 'callout',
      version: 1,
    };
  }
  
  // ElementNode specific
  canBeEmpty(): boolean {
    return false;
  }
  
  isShadowRoot(): boolean {
    return false;
  }
}

// Factory function
export function $createCalloutNode(): CalloutNode {
  return new CalloutNode();
}

// Type guard
export function $isCalloutNode(
  node: LexicalNode | null | undefined
): node is CalloutNode {
  return node instanceof CalloutNode;
}

Registering Custom Nodes

import { createEditor } from 'lexical';
import { CalloutNode } from './CalloutNode';

const editor = createEditor({
  nodes: [CalloutNode],
  // ... other config
});

Node Transforms

Transforms run automatically when nodes are marked dirty:
editor.registerNodeTransform(TextNode, (node) => {
  // This runs whenever a TextNode is marked dirty
  const text = node.getTextContent();
  
  if (text.includes('@')) {
    // Convert @mentions to custom nodes
    node.setFormat('code');
  }
});
See Transforms for details.

Best Practices

Create nodes using $create* functions:
// ✅ Good
const node = $createParagraphNode();

// ❌ Bad (missing $ prefix convention)
const node = new ParagraphNode();
Always check node types before accessing type-specific properties:
if ($isTextNode(node)) {
  const text = node.getTextContent();
}
Store keys, not references:
// ✅ Good
const key = node.getKey();
// Later...
const node = $getNodeByKey(key);

// ❌ Bad
let savedNode = node;
// savedNode may be stale later
Custom nodes must implement:
  • static getType()
  • static clone()
  • static importJSON()
  • createDOM()
  • updateDOM()
  • exportJSON()
Ensure your type string is unique:
// ✅ Good
static getType() {
  return 'my-plugin-custom-node';
}

// ❌ Bad (conflicts with built-in)
static getType() {
  return 'paragraph';
}

API Reference

Common Node Methods

class LexicalNode {
  // Identity
  getKey(): NodeKey;
  getType(): string;
  
  // Tree navigation
  getParent(): ElementNode | null;
  getNextSibling(): LexicalNode | null;
  getPreviousSibling(): LexicalNode | null;
  getTopLevelElement(): ElementNode | null;
  
  // State
  isAttached(): boolean;
  isSelected(): boolean;
  isDirty(): boolean;
  getLatest(): this;
  getWritable(): this;
  
  // Manipulation
  remove(preserveEmptyParent?: boolean): void;
  replace<N extends LexicalNode>(node: N, includeChildren?: boolean): N;
  insertBefore(node: LexicalNode): LexicalNode;
  insertAfter(node: LexicalNode): LexicalNode;
  
  // Serialization
  exportJSON(): SerializedLexicalNode;
  
  // DOM
  createDOM(config: EditorConfig, editor: LexicalEditor): HTMLElement;
  updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig): boolean;
}

Build docs developers (and LLMs) love