Creating Lexical Plugins: A Complete Guide to Extending Your Rich Text Editor


Lexical’s plugin system is one of its most powerful features, allowing you to extend your rich text editor with custom functionality. Whether you need a toolbar, custom node types, or advanced features like collaboration, plugins give you the flexibility to build exactly what you need.

In this post, we’ll explore how to create various types of Lexical plugins, from simple utility plugins to complex custom nodes. We’ll build real examples that you can use in your projects.


Understanding Lexical Plugins

Lexical plugins are React components that hook into the editor’s lifecycle and state. They can:

  • Listen to editor changes and react accordingly
  • Modify editor state programmatically
  • Add UI elements like toolbars and menus
  • Register custom nodes for new content types
  • Handle external events and integrate with your app

Plugin Architecture Basics

Every Lexical plugin follows a simple pattern:

import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { useEffect } from 'react';

function MyPlugin() {
  const [editor] = useLexicalComposerContext();
  
  useEffect(() => {
    // Plugin logic here
    const removeUpdateListener = editor.registerUpdateListener(({editorState}) => {
      // Handle updates
    });
    
    return removeUpdateListener;
  }, [editor]);
  
  return null; // or JSX for UI elements
}

Building a Formatting Toolbar Plugin

Let’s start with a practical example: a formatting toolbar that provides buttons for bold, italic, underline, and other text styles.

import React from 'react';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $getSelection, $isRangeSelection, FORMAT_TEXT_COMMAND } from 'lexical';

function ToolbarPlugin() {
  const [editor] = useLexicalComposerContext();
  
  const formatText = (format) => {
    editor.dispatchCommand(FORMAT_TEXT_COMMAND, format);
  };
  
  const isFormatActive = (format) => {
    const selection = $getSelection();
    if (!$isRangeSelection(selection)) return false;
    
    return selection.hasFormat(format);
  };
  
  return (
    <div className="toolbar">
      <button
        className={`toolbar-button ${isFormatActive('bold') ? 'active' : ''}`}
        onClick={() => formatText('bold')}
        aria-label="Bold"
      >
        <strong>B</strong>
      </button>
      
      <button
        className={`toolbar-button ${isFormatActive('italic') ? 'active' : ''}`}
        onClick={() => formatText('italic')}
        aria-label="Italic"
      >
        <em>I</em>
      </button>
      
      <button
        className={`toolbar-button ${isFormatActive('underline') ? 'active' : ''}`}
        onClick={() => formatText('underline')}
        aria-label="Underline"
      >
        <u>U</u>
      </button>
      
      <button
        className={`toolbar-button ${isFormatActive('strikethrough') ? 'active' : ''}`}
        onClick={() => formatText('strikethrough')}
        aria-label="Strikethrough"
      >
        <s>S</s>
      </button>
    </div>
  );
}

export default ToolbarPlugin;

Styling the Toolbar

.toolbar {
  display: flex;
  gap: 8px;
  padding: 12px;
  border-bottom: 1px solid #e1e5e9;
  background: #f8f9fa;
}

.toolbar-button {
  padding: 8px 12px;
  border: 1px solid #d1d5db;
  border-radius: 6px;
  background: white;
  cursor: pointer;
  transition: all 0.2s;
}

.toolbar-button:hover {
  background: #f3f4f6;
  border-color: #9ca3af;
}

.toolbar-button.active {
  background: #3b82f6;
  color: white;
  border-color: #2563eb;
}

Creating a Heading Plugin

Next, let’s build a plugin that allows users to convert text to different heading levels:

import React from 'react';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $getSelection, $isRangeSelection, $setBlocksType } from 'lexical';
import { $createHeadingNode, $isHeadingNode } from '@lexical/rich-text';

function HeadingPlugin() {
  const [editor] = useLexicalComposerContext();
  
  const convertToHeading = (level) => {
    editor.update(() => {
      const selection = $getSelection();
      if (!$isRangeSelection(selection)) return;
      
      $setBlocksType(selection, () => $createHeadingNode(`h${level}`));
    });
  };
  
  const getCurrentHeadingLevel = () => {
    let level = 0;
    editor.getEditorState().read(() => {
      const selection = $getSelection();
      if (!$isRangeSelection(selection)) return;
      
      const anchorNode = selection.anchor.getNode();
      const element = anchorNode.getKey() === 'root' 
        ? anchorNode 
        : anchorNode.getTopLevelElement();
      
      if ($isHeadingNode(element)) {
        level = parseInt(element.getTag());
      }
    });
    return level;
  };
  
  const currentLevel = getCurrentHeadingLevel();
  
  return (
    <div className="heading-controls">
      <select 
        value={currentLevel || 'p'} 
        onChange={(e) => {
          const value = e.target.value;
          if (value === 'p') {
            // Convert to paragraph
            editor.update(() => {
              const selection = $getSelection();
              if (!$isRangeSelection(selection)) return;
              $setBlocksType(selection, () => $createParagraphNode());
            });
          } else {
            convertToHeading(parseInt(value));
          }
        }}
      >
        <option value="p">Paragraph</option>
        <option value="1">Heading 1</option>
        <option value="2">Heading 2</option>
        <option value="3">Heading 3</option>
        <option value="4">Heading 4</option>
        <option value="5">Heading 5</option>
        <option value="6">Heading 6</option>
      </select>
    </div>
  );
}

export default HeadingPlugin;

Advanced Plugin: Image Upload Plugin

Let’s create a more complex plugin that handles image uploads:

import React, { useCallback } from 'react';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $getSelection, $isRangeSelection, $createParagraphNode } from 'lexical';
import { $createImageNode } from '@lexical/image';

function ImageUploadPlugin() {
  const [editor] = useLexicalComposerContext();
  
  const insertImage = useCallback((url, altText) => {
    editor.update(() => {
      const selection = $getSelection();
      if (!$isRangeSelection(selection)) return;
      
      const imageNode = $createImageNode({
        src: url,
        altText: altText || 'Image',
        width: '100%',
        height: 'auto',
      });
      
      selection.insertNodes([imageNode]);
      
      // Add a new paragraph after the image
      const paragraphNode = $createParagraphNode();
      imageNode.insertAfter(paragraphNode);
      paragraphNode.select();
    });
  }, [editor]);
  
  const handleFileUpload = useCallback(async (event) => {
    const file = event.target.files[0];
    if (!file) return;
    
    // In a real app, you'd upload to your server/CDN
    // For demo purposes, we'll create a local URL
    const url = URL.createObjectURL(file);
    const altText = file.name;
    
    insertImage(url, altText);
    
    // Clean up the object URL
    setTimeout(() => URL.revokeObjectURL(url), 1000);
  }, [insertImage]);
  
  return (
    <div className="image-upload">
      <input
        type="file"
        accept="image/*"
        onChange={handleFileUpload}
        id="image-upload"
        style={{ display: 'none' }}
      />
      <button
        onClick={() => document.getElementById('image-upload').click()}
        className="toolbar-button"
        aria-label="Insert image"
      >
        📷
      </button>
    </div>
  );
}

export default ImageUploadPlugin;

Custom Node Plugin: Callout Boxes

Let’s create a custom node type for callout boxes (info, warning, error boxes):

import React from 'react';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $getSelection, $isRangeSelection, $setBlocksType } from 'lexical';
import { $createParagraphNode } from '@lexical/rich-text';

// First, we need to register our custom node
import { $createCalloutNode, $isCalloutNode } from './CalloutNode';

function CalloutPlugin() {
  const [editor] = useLexicalComposerContext();
  
  const insertCallout = useCallback((type) => {
    editor.update(() => {
      const selection = $getSelection();
      if (!$isRangeSelection(selection)) return;
      
      const calloutNode = $createCalloutNode(type);
      const paragraphNode = $createParagraphNode();
      
      calloutNode.append(paragraphNode);
      selection.insertNodes([calloutNode]);
      paragraphNode.select();
    });
  }, [editor]);
  
  return (
    <div className="callout-controls">
      <button
        onClick={() => insertCallout('info')}
        className="callout-button info"
        aria-label="Insert info callout"
      >
        ℹ️ Info
      </button>
      
      <button
        onClick={() => insertCallout('warning')}
        className="callout-button warning"
        aria-label="Insert warning callout"
      >
        ⚠️ Warning
      </button>
      
      <button
        onClick={() => insertCallout('error')}
        className="callout-button error"
        aria-label="Insert error callout"
      >
        ❌ Error
      </button>
    </div>
  );
}

export default CalloutPlugin;

The Custom Callout Node

import { ElementNode, NodeKey, SerializedElementNode } from 'lexical';

export class CalloutNode extends ElementNode {
  constructor(type, key) {
    super(key);
    this.__type = type;
  }
  
  static getType() {
    return 'callout';
  }
  
  static clone(node) {
    return new CalloutNode(node.__type, node.__key);
  }
  
  createDOM() {
    const dom = document.createElement('div');
    dom.className = `callout callout-${this.__type}`;
    return dom;
  }
  
  updateDOM(prevNode, dom) {
    if (prevNode.__type !== this.__type) {
      dom.className = `callout callout-${this.__type}`;
    }
    return false;
  }
  
  static importJSON(serializedNode) {
    const node = $createCalloutNode(serializedNode.type);
    node.setFormat(serializedNode.format);
    node.setIndent(serializedNode.indent);
    node.setDirection(serializedNode.direction);
    return node;
  }
  
  exportJSON() {
    return {
      ...super.exportJSON(),
      type: this.__type,
      version: 1,
    };
  }
}

export function $createCalloutNode(type) {
  return new CalloutNode(type);
}

export function $isCalloutNode(node) {
  return node instanceof CalloutNode;
}

Plugin Composition and Organization

As your editor grows, you’ll want to organize plugins effectively:

// plugins/index.js
export { default as ToolbarPlugin } from './ToolbarPlugin';
export { default as HeadingPlugin } from './HeadingPlugin';
export { default as ImageUploadPlugin } from './ImageUploadPlugin';
export { default as CalloutPlugin } from './CalloutPlugin';

// Main editor component
import {
  ToolbarPlugin,
  HeadingPlugin,
  ImageUploadPlugin,
  CalloutPlugin,
} from './plugins';

function Editor() {
  return (
    <LexicalComposer initialConfig={editorConfig}>
      <div className="editor-container">
        <ToolbarPlugin />
        <div className="editor-inner">
          <RichTextPlugin
            contentEditable={<ContentEditable className="editor-input" />}
            placeholder={<div className="editor-placeholder">Start writing...</div>}
            ErrorBoundary={LexicalErrorBoundary}
          />
          <HistoryPlugin />
          <AutoFocusPlugin />
          <HeadingPlugin />
          <ImageUploadPlugin />
          <CalloutPlugin />
        </div>
      </div>
    </LexicalComposer>
  );
}

Best Practices for Plugin Development

1. Keep Plugins Focused

Each plugin should have a single responsibility. Don’t create monolithic plugins that do everything.

2. Use Commands for State Changes

Always use Lexical commands to modify editor state:

// Good
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold');

// Bad - directly manipulating state
editor.update(() => {
  // Direct state manipulation
});

3. Clean Up Listeners

Always return cleanup functions from your effects:

useEffect(() => {
  const removeListener = editor.registerUpdateListener(callback);
  return removeListener;
}, [editor]);

4. Handle Edge Cases

Consider what happens when:

  • Selection is null
  • Editor is not focused
  • Content is being loaded
  • Multiple plugins interact

5. Performance Considerations

  • Debounce expensive operations
  • Use useCallback and useMemo appropriately
  • Avoid unnecessary re-renders

Testing Your Plugins

Here’s how to test your Lexical plugins:

import { render, screen, fireEvent } from '@testing-library/react';
import { LexicalComposer } from '@lexical/react/LexicalComposer';
import ToolbarPlugin from './ToolbarPlugin';

const TestWrapper = ({ children }) => (
  <LexicalComposer
    initialConfig={{
      namespace: 'Test',
      onError: () => {},
      nodes: [],
    }}
  >
    {children}
  </LexicalComposer>
);

test('ToolbarPlugin formats text correctly', () => {
  render(
    <TestWrapper>
      <ToolbarPlugin />
    </TestWrapper>
  );
  
  const boldButton = screen.getByLabelText('Bold');
  fireEvent.click(boldButton);
  
  // Test that bold formatting was applied
  // You'll need to check the editor state
});

Advanced Plugin Patterns

Plugin Communication

Plugins can communicate through the editor’s command system:

// Define a custom command
export const SHOW_TOOLTIP_COMMAND = createCommand('SHOW_TOOLTIP');

// In one plugin
editor.dispatchCommand(SHOW_TOOLTIP_COMMAND, { text: 'Help text', position: { x: 100, y: 100 } });

// In another plugin
useEffect(() => {
  return editor.registerCommand(
    SHOW_TOOLTIP_COMMAND,
    (payload) => {
      // Show tooltip
      return true; // Command handled
    },
    COMMAND_PRIORITY_EDITOR
  );
}, [editor]);

Plugin State Management

Use React state or external state management for complex plugin state:

function AdvancedPlugin() {
  const [editor] = useLexicalComposerContext();
  const [pluginState, setPluginState] = useState({});
  
  // Share state between plugin instances
  useEffect(() => {
    editor._pluginState = pluginState;
  }, [editor, pluginState]);
  
  // ... rest of plugin
}

Common Plugin Use Cases

Here are some popular plugin types you might want to build:

  • Auto-save: Save content periodically
  • Word count: Track document statistics
  • Spell check: Integrate with spell checking services
  • Version history: Track content changes
  • Export plugins: Convert to PDF, Markdown, etc.
  • Collaboration: Real-time editing features
  • Accessibility: Screen reader support, keyboard shortcuts

Resources and Next Steps


Conclusion

Lexical’s plugin system gives you the power to build rich text editors that perfectly fit your needs. Start with simple plugins and gradually build more complex functionality. Remember to keep plugins focused, test thoroughly, and follow best practices for performance and maintainability.

The examples in this post should give you a solid foundation for building your own plugins. Experiment, iterate, and don’t be afraid to dive into the Lexical source code to understand how things work under the hood.

Happy plugin building!