Introduction
You're developing an admin panel for a CMS, and you need to integrate a WYSIWYG editor. An error at the architecture selection stage leads to years of security, performance, and content maintenance issues. We implement the rich text editor on Lexical, which avoids these problems. As the Lexical team notes: Lexical is built on an immutable tree data structure, ensuring predictability and performance. Over 5 years of development, we've seen that the right choice of storage format and tech stack ensures significant cost savings and reduces layout time by 50%. Our clients typically save $3,000–$10,000 in development costs, with an ROI of 200% within the first year. For large documents, Lexical is 4 times better than TinyMCE in terms of performance.
Our custom content editor development focuses on Lexical editor integration with JSON content storage for a seamless WYSIWYG implementation. This approach ensures that the text editor for website becomes a powerful CMS editor with custom Lexical nodes.
Problems We Solve
Choosing the Storage Format
The first and most important decision — how to store content: HTML string or structured JSON. We strongly recommend JSON (Lexical State). It offers an order of magnitude more possibilities — from simple plain text extraction to line-by-line diff for versioning. For example, custom nodes (images, tables) are easily serialized and deserialized.
| Criterion | HTML | JSON (Lexical/ProseMirror) | Portable Text (Sanity) |
|---|---|---|---|
| Transformation flexibility | Low | High | Medium |
| Security (XSS) | Requires sanitization | Native isolation | Native isolation |
| Rendering performance | High | Depends on size | Depends on size |
| Development complexity | Low | Medium | Medium |
| Versioning support | Difficult | Built-in (diff) | Built-in (diff) |
Performance on Large Documents
Editors based on contentEditable (TinyMCE, CKEditor) slow down on documents over 10,000 words. Lexical uses a virtual DOM and updates only changed nodes — delivering stable 60 fps even on 50,000 words. In testing, Lexical achieved 60 fps, while TinyMCE ran at 15 fps — that's 4x higher performance. Lexical also reduces bundle size by 40% through tree-shaking and lazy loading of plugins. On documents of 100,000 words, Lexical maintains 60 fps, while competitors often crash.
Form Integration and Validation
A common pain point is syncing editor state with React Hook Form or Formik. We solved this with the OnChangePlugin, which outputs both JSON state and ready HTML on every change. This eliminates the need to load the editor on the view page — page load speed increases by 30–50%.
How We Implement the Lexical Editor
Core stack: React 18, Next.js 14, Lexical 0.12+. Editor configuration with support for headings, lists, links, code, and custom images:
// components/RichTextEditor/index.tsx
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 { AutoFocusPlugin } from '@lexical/react/LexicalAutoFocusPlugin'
import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin'
import { ListPlugin } from '@lexical/react/LexicalListPlugin'
import { TabIndentationPlugin } from '@lexical/react/LexicalTabIndentationPlugin'
import { HeadingNode, QuoteNode } from '@lexical/rich-text'
import { ListItemNode, ListNode } from '@lexical/list'
import { LinkNode, AutoLinkNode } from '@lexical/link'
import { CodeHighlightNode, CodeNode } from '@lexical/code'
import { ImageNode } from './nodes/ImageNode'
import { ToolbarPlugin } from './plugins/ToolbarPlugin'
import { ImagesPlugin } from './plugins/ImagesPlugin'
import { OnChangePlugin } from './plugins/OnChangePlugin'
const editorConfig = {
namespace: 'RichTextEditor',
nodes: [
HeadingNode, QuoteNode,
ListNode, ListItemNode,
LinkNode, AutoLinkNode,
CodeNode, CodeHighlightNode,
ImageNode,
],
onError: (error: Error) => console.error(error),
theme: {
heading: {
h1: 'text-3xl font-bold mb-4',
h2: 'text-2xl font-semibold mb-3',
h3: 'text-xl font-medium mb-2',
},
text: {
bold: 'font-bold',
italic: 'italic',
underline: 'underline',
strikethrough: 'line-through',
code: 'font-mono bg-gray-100 px-1 rounded text-sm',
},
link: 'text-blue-600 underline cursor-pointer',
list: {
ul: 'list-disc list-inside mb-4',
ol: 'list-decimal list-inside mb-4',
listitem: 'mb-1',
},
quote: 'border-l-4 border-gray-300 pl-4 italic text-gray-600 my-4',
},
}
interface RichTextEditorProps {
initialState?: string
onChange: (state: string, html: string) => void
}
export function RichTextEditor({ initialState, onChange }: RichTextEditorProps) {
return (
<LexicalComposer initialConfig={{ ...editorConfig, editorState: initialState }}>
<div className="border rounded-lg overflow-hidden">
<ToolbarPlugin />
<div className="relative">
<RichTextPlugin
contentEditable={
<ContentEditable className="min-h-[300px] p-4 outline-none prose max-w-none" />
}
placeholder={
<div className="absolute top-4 left-4 text-gray-400 pointer-events-none">
Start typing...
</div>
}
ErrorBoundary={LexicalErrorBoundary}
/>
</div>
</div>
<HistoryPlugin />
<AutoFocusPlugin />
<ListPlugin />
<LinkPlugin />
<TabIndentationPlugin />
<ImagesPlugin />
<OnChangePlugin onChange={onChange} />
</LexicalComposer>
)
}
Custom Image Node
Images are a typical custom block. We extend DecoratorNode and implement serialization/deserialization:
// nodes/ImageNode.tsx
import { DecoratorNode, LexicalNode, NodeKey } from 'lexical'
export class ImageNode extends DecoratorNode<React.ReactElement> {
__src: string
__alt: string
__width: number | 'inherit'
__height: number | 'inherit'
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 | 'inherit', height?: number | 'inherit', key?: NodeKey) {
super(key)
this.__src = src
this.__alt = alt
this.__width = width ?? 'inherit'
this.__height = height ?? 'inherit'
}
createDOM(): HTMLElement {
const div = document.createElement('div')
div.className = 'editor-image'
return div
}
updateDOM(): false { return false }
exportJSON() {
return {
type: 'image',
src: this.__src,
alt: this.__alt,
width: this.__width,
height: this.__height,
version: 1,
}
}
static importJSON(data: any): ImageNode {
return new ImageNode(data.src, data.alt, data.width, data.height)
}
decorate(): React.ReactElement {
return (
<ImageComponent
src={this.__src}
alt={this.__alt}
width={this.__width}
height={this.__height}
nodeKey={this.getKey()}
/>
)
}
}
This architecture allows adding any custom blocks: tables, embed video, pull quotes, galleries. We support over 20 custom node types.
Optimization tip
For a large number of custom nodes, use lazy-loading for decorators.Why JSON is the Best Choice for Admin Panels
The JSON state is stored in a single DB column. On editing, we save it as is; for client-side rendering, we convert to HTML using $generateHtmlFromNodes. This eliminates the need to load the editor on view — page load speed increases by 30–50%. Comparison of formats by key parameters:
| Parameter | JSON | HTML |
|---|---|---|
| Versioning | Built-in (diff) | Manual |
| XSS safety | Native isolation | Requires sanitization |
| Customization flexibility | High | Low |
Our rich text editor implementation service includes integration with React Hook Form, Formik, and Redux Form.
How to Customize the Editor for Your Tasks
We use a plugin system. Each custom task has its own plugin, providing isolation and easy maintenance. For example, for images we wrote ImagesPlugin that adds an upload button and inserts an ImageNode.
The OnChangePlugin tracks changes and passes JSON + HTML outward:
// plugins/OnChangePlugin.tsx
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { $generateHtmlFromNodes } from '@lexical/html'
import { useEffect } from 'react'
export function OnChangePlugin({ onChange }: { onChange: (state: string, html: string) => void }) {
const [editor] = useLexicalComposerContext()
useEffect(() => {
return editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
const stateJSON = JSON.stringify(editorState.toJSON())
const html = $generateHtmlFromNodes(editor, null)
onChange(stateJSON, html)
})
})
}, [editor, onChange])
return null
}
Integration with React Hook Form — via Controller, passing initialState and handling onChange.
Work Process
- Analysis — we study your current content, structure, block requirements.
- Design — choose storage format, node list, toolbar design.
- Implementation — environment setup, custom nodes, plugins, form and API integration.
- Testing — check on large documents (up to 100,000 words), cross-browser, performance.
- Deployment and documentation — publish, train editors, hand over instructions.
Timeline
- Basic editor (Lexical + JSON + HTML, toolbar, integration): 3–4 days (from $1,500).
- With custom nodes, versioning, and extended toolbar: 8–12 days (from $5,000).
What's Included
- Source code of components (React/Next.js) with comments.
- Configured saving of JSON in DB and rendering HTML on frontend.
- API documentation for components and adding new nodes.
- Training for editors (1 hour).
- Support guarantee for 2 weeks after delivery.
- Starting from $1,500 for basic implementation, full customization from $5,000.
Common Implementation Mistakes
- HTML sanitization — when rendering from JSON, always use DOMPurify if content is user-generated.
- Too large initial bundle — split editor and renderer (code splitting).
- Ignoring mobile keyboard — in iOS Safari, contentEditable can behave unstable; test on real devices.
Our team has over 5 years and 200+ projects experience implementing content editors for large projects. Get a consultation on your project — we'll estimate the task within 1 business day. Order turnkey rich text editor implementation — we'll audit and propose the optimal solution.







