Rich Text Editor Implementation on Website
A Rich Text Editor for admin content is a different task compared to a user editor. There's no XSS problem from untrusted HTML, but there are other requirements: support for complex nested structures, custom blocks, media work, versioning, localization.
Architectural Choice of Storage Format
The first decision — in what format to store content. Everything else depends on it.
HTML-string — simpler start, harder transformations. Can't easily extract clean text, count words, make diff.
JSON (Slate/Lexical/ProseMirror) — structured node tree. Easy to transform, render in different formats, make diff for versioning.
Portable Text (Sanity) — JSON-format, oriented at portability between renderers.
For a new project, JSON is better. For integration with existing Laravel + TinyMCE — HTML with a carefully described allowed tags schema.
Implementation with Lexical (Meta)
Lexical — native for React, supports concurrent mode, extends well:
npm install lexical @lexical/react @lexical/rich-text @lexical/selection @lexical/utils @lexical/html
// 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 // serialized Lexical state JSON
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
// 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()}
/>
)
}
}
Saving and Loading State
// 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)
onChange(stateJSON, html)
})
})
}, [editor, onChange])
return null
}
In the database, store JSON (editor_state) for editing and HTML (content_html) for rendering without loading the editor.
Integration with React Hook Form
// forms/PostForm.tsx
import { Controller, useForm } from 'react-hook-form'
import { RichTextEditor } from '@/components/RichTextEditor'
export function PostForm({ post }: { post?: Post }) {
const { control, handleSubmit } = useForm({
defaultValues: {
title: post?.title ?? '',
editorState: post?.editorState ?? null,
},
})
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
name="editorState"
control={control}
rules={{ required: 'Content is required' }}
render={({ field, fieldState }) => (
<div>
<RichTextEditor
initialState={field.value}
onChange={(state, html) => {
field.onChange(state)
// save html in separate field if needed
}}
/>
{fieldState.error && (
<p className="text-red-500 text-sm mt-1">{fieldState.error.message}</p>
)}
</div>
)}
/>
</form>
)
}
Frontend Rendering without Editor
// components/ContentRenderer.tsx
import { createHeadlessEditor } from '@lexical/headless'
import { $generateHtmlFromNodes } from '@lexical/html'
// Server-side: render from Lexical JSON to HTML
export async function renderLexicalToHtml(stateJson: string): Promise<string> {
return new Promise((resolve, reject) => {
const editor = createHeadlessEditor({ nodes: [/* all nodes */] })
const state = editor.parseEditorState(stateJson)
editor.setEditorState(state)
editor.read(() => {
resolve($generateHtmlFromNodes(editor))
})
})
}
// Client-side: for static HTML from DB
export function ContentRenderer({ html }: { html: string }) {
return (
<div
className="prose lg:prose-lg max-w-none"
dangerouslySetInnerHTML={{ __html: html }}
/>
)
}
Toolbar with Button Groups
// plugins/ToolbarPlugin.tsx — fragment
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { FORMAT_TEXT_COMMAND, FORMAT_ELEMENT_COMMAND } from 'lexical'
import { INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND } from '@lexical/list'
import { $createHeadingNode } from '@lexical/rich-text'
import { $setBlocksType } from '@lexical/selection'
import { $getSelection, $isRangeSelection } from 'lexical'
export function ToolbarPlugin() {
const [editor] = useLexicalComposerContext()
const [activeFormats, setActiveFormats] = useState<Set<string>>(new Set())
// Subscribe to selection changes
useEffect(() => {
return editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
const selection = $getSelection()
if ($isRangeSelection(selection)) {
const formats = new Set<string>()
if (selection.hasFormat('bold')) formats.add('bold')
if (selection.hasFormat('italic')) formats.add('italic')
setActiveFormats(formats)
}
})
})
}, [editor])
return (
<div className="flex flex-wrap gap-1 p-2 border-b bg-gray-50">
{/* Headings */}
<select onChange={e => {
editor.update(() => {
const selection = $getSelection()
if ($isRangeSelection(selection)) {
$setBlocksType(selection, () => $createHeadingNode(e.target.value as any))
}
})
}}>
<option value="h2">H2</option>
<option value="h3">H3</option>
</select>
{/* Text formatting */}
{['bold', 'italic', 'underline'].map(fmt => (
<button
key={fmt}
className={`px-2 py-1 rounded ${activeFormats.has(fmt) ? 'bg-blue-100' : ''}`}
onClick={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, fmt as any)}
>
{fmt[0].toUpperCase()}
</button>
))}
</div>
)
}
Timeline
Basic editor (Lexical/TipTap) with JSON + HTML saving, form integration: 3–4 days. Custom nodes (images, tables, embed), full toolbar, versioning: 8–12 days.







