Lexical is Meta’s extensible JavaScript text editor framework — LexicalComposer wraps the editor with initialConfig. RichTextPlugin with contentEditable renders the editing surface. HistoryPlugin adds undo/redo. OnChangePlugin captures EditorState as JSON for persistence. Custom nodes extend DecoratorNode or ElementNode with static getType(), clone(), importJSON(), and exportJSON(). $getSelection() and $isRangeSelection() read editor state. FORMAT_TEXT_COMMAND dispatches bold/italic/underline. editor.update(() => { $getRoot().clear() }) mutates state in a transaction. AutoLinkPlugin converts URLs to links automatically. MarkdownShortcuts enables **bold** syntax. Lexical’s architecture separates the DOM from state for predictable server-side rendering and collaborative editing. Claude Code generates Lexical editor setups, custom node types, toolbar components, state serialization, and plugin compositions.
CLAUDE.md for Lexical
## Lexical Stack
- Version: lexical >= 0.16, @lexical/react >= 0.16
- Setup: <LexicalComposer initialConfig={config}><RichTextPlugin /><HistoryPlugin /></LexicalComposer>
- Change: <OnChangePlugin onChange={(editorState) => editorState.toJSON()} />
- Commands: editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold") — from LexicalEditor
- Selection: editor.update(() => { const sel = $getSelection(); ... })
- Custom node: class ImageNode extends DecoratorNode — static getType/clone/importJSON/exportJSON
- Serialize: editor.getEditorState().toJSON() / editor.parseEditorState(json)
- Plugins: AutoLinkPlugin, MarkdownShortcutPlugin, CheckListPlugin, ListPlugin
Editor Setup
// components/editor/RichEditor.tsx — full Lexical editor
"use client"
import { useCallback, useRef } from "react"
import { LexicalComposer } from "@lexical/react/LexicalComposer"
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"
import { ContentEditable } from "@lexical/react/LexicalContentEditable"
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"
import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"
import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin"
import { LinkPlugin } from "@lexical/react/LexicalLinkPlugin"
import { ListPlugin } from "@lexical/react/LexicalListPlugin"
import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin"
import { AutoLinkPlugin } from "@lexical/react/LexicalAutoLinkPlugin"
import { CheckListPlugin } from "@lexical/react/LexicalCheckListPlugin"
import { TRANSFORMERS } from "@lexical/markdown"
import { HeadingNode, QuoteNode } from "@lexical/rich-text"
import { ListNode, ListItemNode } from "@lexical/list"
import { LinkNode, AutoLinkNode } from "@lexical/link"
import { CodeNode, CodeHighlightNode } from "@lexical/code"
import type { EditorState, SerializedEditorState } from "lexical"
import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"
import { EditorToolbar } from "./EditorToolbar"
const URL_REGEX = /((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*)/
const EMAIL_REGEX = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/
const AUTO_LINK_MATCHERS = [
(text: string) => {
const match = URL_REGEX.exec(text)
if (match === null) return null
return { index: match.index, length: match[0].length, text: match[0], url: match[0] }
},
(text: string) => {
const match = EMAIL_REGEX.exec(text)
if (match === null) return null
return { index: match.index, length: match[0].length, text: match[0], url: `mailto:${match[0]}` }
},
]
interface RichEditorProps {
initialContent?: SerializedEditorState
onChange?: (state: SerializedEditorState) => void
placeholder?: string
className?: string
}
export function RichEditor({
initialContent,
onChange,
placeholder = "Start writing...",
className,
}: RichEditorProps) {
const initialConfig = {
namespace: "RichEditor",
theme: editorTheme,
onError: (error: Error) => console.error("[Lexical]", error),
nodes: [
HeadingNode,
QuoteNode,
ListNode,
ListItemNode,
LinkNode,
AutoLinkNode,
CodeNode,
CodeHighlightNode,
],
// Restore from saved state
...(initialContent && { editorState: JSON.stringify(initialContent) }),
}
const handleChange = useCallback(
(editorState: EditorState) => {
onChange?.(editorState.toJSON())
},
[onChange],
)
return (
<LexicalComposer initialConfig={initialConfig}>
<div className={`lexical-editor ${className ?? ""}`}>
<EditorToolbar />
<div className="relative">
<RichTextPlugin
contentEditable={
<ContentEditable
className="min-h-[200px] outline-none px-4 py-3 text-sm leading-relaxed"
aria-placeholder={placeholder}
/>
}
placeholder={
<div className="absolute top-3 left-4 text-sm text-muted-foreground pointer-events-none">
{placeholder}
</div>
}
ErrorBoundary={LexicalErrorBoundary}
/>
</div>
<HistoryPlugin />
<AutoFocusPlugin />
<ListPlugin />
<CheckListPlugin />
<LinkPlugin />
<AutoLinkPlugin matchers={AUTO_LINK_MATCHERS} />
<MarkdownShortcutPlugin transformers={TRANSFORMERS} />
<OnChangePlugin onChange={handleChange} />
</div>
</LexicalComposer>
)
}
// Theme maps node types to CSS classes
const editorTheme = {
heading: {
h1: "text-3xl font-bold mb-4",
h2: "text-2xl font-semibold mb-3",
h3: "text-xl font-semibold mb-2",
},
quote: "border-l-4 border-muted-foreground pl-4 italic text-muted-foreground my-4",
list: {
ul: "list-disc ml-6 my-2 space-y-1",
ol: "list-decimal ml-6 my-2 space-y-1",
listitem: "my-1",
listitemChecked: "line-through opacity-50",
listitemUnchecked: "",
},
link: "text-primary underline hover:no-underline cursor-pointer",
code: "font-mono text-sm bg-muted px-1 py-0.5 rounded",
codeHighlight: {
atrule: "text-blue-600",
attr: "text-purple-600",
boolean: "text-red-600",
builtin: "text-cyan-600",
comment: "text-gray-500 italic",
function: "text-yellow-600",
keyword: "text-blue-700",
string: "text-green-600",
},
text: {
bold: "font-bold",
italic: "italic",
underline: "underline",
strikethrough: "line-through",
code: "font-mono text-sm bg-muted px-1 py-0.5 rounded",
},
}
Toolbar Plugin
// components/editor/EditorToolbar.tsx — formatting toolbar
"use client"
import { useCallback, useEffect, useState } from "react"
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
import {
FORMAT_TEXT_COMMAND,
FORMAT_ELEMENT_COMMAND,
$getSelection,
$isRangeSelection,
UNDO_COMMAND,
REDO_COMMAND,
TextFormatType,
} from "lexical"
import { $isHeadingNode } from "@lexical/rich-text"
import { $isListNode, ListNode } from "@lexical/list"
import { $getNearestNodeOfType } from "@lexical/utils"
import { INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND } from "@lexical/list"
type FormatState = {
isBold: boolean
isItalic: boolean
isUnderline: boolean
isStrikethrough: boolean
isCode: boolean
blockType: string
canUndo: boolean
canRedo: boolean
}
export function EditorToolbar() {
const [editor] = useLexicalComposerContext()
const [formats, setFormats] = useState<FormatState>({
isBold: false,
isItalic: false,
isUnderline: false,
isStrikethrough: false,
isCode: false,
blockType: "paragraph",
canUndo: false,
canRedo: false,
})
const updateFormats = useCallback(() => {
const selection = $getSelection()
if (!$isRangeSelection(selection)) return
const anchorNode = selection.anchor.getNode()
const element = anchorNode.getKey() === "root"
? anchorNode
: anchorNode.getTopLevelElementOrThrow()
let blockType = "paragraph"
if ($isHeadingNode(element)) blockType = element.getTag()
else if ($isListNode(element)) {
const parentList = $getNearestNodeOfType<ListNode>(anchorNode, ListNode)
blockType = parentList ? parentList.getListType() : element.getListType()
}
setFormats({
isBold: selection.hasFormat("bold"),
isItalic: selection.hasFormat("italic"),
isUnderline: selection.hasFormat("underline"),
isStrikethrough: selection.hasFormat("strikethrough"),
isCode: selection.hasFormat("code"),
blockType,
canUndo: editor.canUndo(),
canRedo: editor.canRedo(),
})
}, [editor])
useEffect(() => {
return editor.registerUpdateListener(({ editorState }) => {
editorState.read(updateFormats)
})
}, [editor, updateFormats])
const format = (type: TextFormatType) => editor.dispatchCommand(FORMAT_TEXT_COMMAND, type)
return (
<div className="flex flex-wrap items-center gap-1 border-b p-2">
<ToolbarButton
onClick={() => editor.dispatchCommand(UNDO_COMMAND, undefined)}
disabled={!formats.canUndo}
title="Undo"
>↩</ToolbarButton>
<ToolbarButton
onClick={() => editor.dispatchCommand(REDO_COMMAND, undefined)}
disabled={!formats.canRedo}
title="Redo"
>↪</ToolbarButton>
<div className="w-px h-5 bg-border mx-1" />
<ToolbarButton onClick={() => format("bold")} active={formats.isBold} title="Bold">
<strong>B</strong>
</ToolbarButton>
<ToolbarButton onClick={() => format("italic")} active={formats.isItalic} title="Italic">
<em>I</em>
</ToolbarButton>
<ToolbarButton onClick={() => format("underline")} active={formats.isUnderline} title="Underline">
<span className="underline">U</span>
</ToolbarButton>
<ToolbarButton onClick={() => format("strikethrough")} active={formats.isStrikethrough} title="Strikethrough">
<span className="line-through">S</span>
</ToolbarButton>
<ToolbarButton onClick={() => format("code")} active={formats.isCode} title="Code">
{"<>"}
</ToolbarButton>
<div className="w-px h-5 bg-border mx-1" />
<ToolbarButton
onClick={() => editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined)}
active={formats.blockType === "bullet"}
title="Bullet list"
>• List</ToolbarButton>
<ToolbarButton
onClick={() => editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined)}
active={formats.blockType === "number"}
title="Ordered list"
>1. List</ToolbarButton>
</div>
)
}
function ToolbarButton({
onClick,
active = false,
disabled = false,
title,
children,
}: {
onClick: () => void
active?: boolean
disabled?: boolean
title?: string
children: React.ReactNode
}) {
return (
<button
onClick={onClick}
disabled={disabled}
title={title}
className={`px-2 py-1 rounded text-sm transition-colors ${
active ? "bg-primary text-primary-foreground" : "hover:bg-muted"
} ${disabled ? "opacity-40 cursor-not-allowed" : "cursor-pointer"}`}
>
{children}
</button>
)
}
State Serialization and Custom Nodes
// lib/lexical-utils.ts — serialize/deserialize and custom ImageNode
import { DecoratorNode, type LexicalNode, type NodeKey, type SerializedLexicalNode, type Spread } from "lexical"
// Custom image node
type SerializedImageNode = Spread<
{ src: string; alt: string; width?: number; height?: number },
SerializedLexicalNode
>
export class ImageNode extends DecoratorNode<React.JSX.Element> {
__src: string
__alt: string
__width: number | undefined
__height: number | undefined
static getType(): string { return "image" }
static clone(node: ImageNode): ImageNode {
return new ImageNode(node.__src, node.__alt, node.__width, node.__height, node.__key)
}
constructor(src: string, alt: string, width?: number, height?: number, key?: NodeKey) {
super(key)
this.__src = src
this.__alt = alt
this.__width = width
this.__height = height
}
static importJSON(data: SerializedImageNode): ImageNode {
return new ImageNode(data.src, data.alt, data.width, data.height)
}
exportJSON(): SerializedImageNode {
return {
type: "image",
version: 1,
src: this.__src,
alt: this.__alt,
width: this.__width,
height: this.__height,
}
}
createDOM(): HTMLElement {
const span = document.createElement("span")
return span
}
updateDOM(): boolean { return false }
decorate(): React.JSX.Element {
return (
<img
src={this.__src}
alt={this.__alt}
width={this.__width}
height={this.__height}
className="max-w-full rounded-lg my-4"
/>
)
}
}
For the TipTap alternative when a headless rich text editor with an extensive extension ecosystem, ProseMirror under the hood, and @tiptap/react integration with Slash commands, collaboration, and Notion-style blocks are preferred — TipTap has more pre-built extensions than Lexical’s plugin system, see the TipTap guide. For the Quill alternative when a mature, simpler rich text editor with a stable API, Delta format, and a broad browser support matrix is preferred over the newer Lexical architecture — Quill is more appropriate for traditional CMS integrations without React, see the rich text editor guide. The Claude Skills 360 bundle includes Lexical skill sets covering plugins, custom nodes, and collaboration. Start with the free tier to try editor generation.