Building a Cross-Platform Rich Text Editor with Lexical
Building a rich text editor that works identically on web and mobile is one of the most challenging aspects of cross-platform development. At Storyie, we needed diary entries created on our Next.js web app to render perfectly in our React Native mobile app, and vice versa. Through a carefully designed three-package architecture using Meta's Lexical editor framework, we achieved true cross-platform editor parity. In this post, we'll show you how we built a platform-agnostic editor core that powers both web and mobile experiences.
The Cross-Platform Editor Challenge
Why Platform Parity Matters
When users create diary entries on their desktop and later open them on their phone, they expect the content to look identical. This seems obvious, but achieving it requires:
- Consistent rendering: Images, formatting, and custom elements must appear the same
- Identical editing behavior: Bold, italic, lists, and links work the same way
- Reliable serialization: Content must survive the round trip through the database without data loss
At Storyie, we store diary content as JSON in a PostgreSQL JSONB column (see our database schema design post). This JSON represents Lexical's EditorState, which must be interpretable by both web and mobile clients.
Common Approaches and Their Tradeoffs
We evaluated three approaches before settling on our architecture:
1. Native Editors (Platform-Specific)
- Separate editors for web (React) and mobile (React Native)
- Pro: Optimal performance for each platform
- Con: Maintaining two editors is expensive and error-prone
- Con: Content format mismatches lead to rendering bugs
2. WebView Wrappers
- Run web editor inside mobile WebView
- Pro: True code reuse - identical editor on both platforms
- Con: WebView overhead and performance concerns
- Pro: Battle-tested approach (used by Notion, Slack mobile)
3. Hybrid Solutions
- Shared editing logic, platform-specific renderers
- Pro: Good performance and code reuse
- Con: Complex to maintain, still requires platform-specific code for each feature
We chose approach #2 (WebView) with a twist: we split the editor into three packages to maintain clean architectural boundaries.
Why We Chose Lexical
Meta's Lexical framework proved ideal for our cross-platform needs:
- Framework-agnostic core: Lexical's core is pure JavaScript, not tied to React DOM
- Powerful plugin system: Add features without modifying core code
- Extensible node architecture: Custom nodes (images, code blocks) work across platforms
- Strong TypeScript support: Catch errors at build time, not runtime
- Battle-tested: Powers Facebook, Meta Workplace, and other production apps
Most importantly, Lexical's design philosophy aligns with cross-platform development: separate core logic from platform-specific rendering.
Three-Package Architecture
Our editor lives in three npm packages within our monorepo (see our monorepo architecture post):
packages/
├── lexical-common/ # Platform-agnostic core
├── lexical-editor/ # Web implementation (Next.js)
└── lexical-editor-expo/ # Mobile implementation (React Native)This architecture enforces separation of concerns:
lexical-commoncontains zero platform-specific codelexical-editorwraps the common package for Next.jslexical-editor-expowraps the common package for React Native
Package 1: lexical-common (Platform-Agnostic Core)
The most critical architectural decision: lexical-common has no platform directives.
From our CLAUDE.md guidelines:
CRITICAL: Must remain platform-agnostic - no platform-specific directives (e.g.,"use client","use dom").
Contains: ImageNode, ImagePlugin, ImageRenderer, common nodes, themes, configs.
Used by bothlexical-editor(Web) andlexical-editor-expo(Expo).
Here's what goes in lexical-common:
// packages/lexical-common/package.json
{
"name": "@storyie/lexical-common",
"version": "1.0.0",
"dependencies": {
"lexical": "^0.21.0",
"@lexical/react": "^0.21.0",
"@lexical/utils": "^0.21.0",
"@lexical/list": "^0.21.0",
"@lexical/link": "^0.21.0",
"@lexical/code": "^0.21.0"
},
"peerDependencies": {
"react": "^19.0.0"
}
}No Next.js imports. No React Native imports. No platform directives. Just Lexical and React.
Package 2: lexical-editor (Web Implementation)
The web package adds Next.js-specific requirements:
// packages/lexical-editor/src/LexicalEditor.tsx
'use client'; // Required for Next.js Server Components
import { LexicalComposer } from '@lexical/react/LexicalComposer';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import { ImageNode } from '@storyie/lexical-common/nodes/ImageNode';
import { DefaultTheme } from '@storyie/lexical-common/themes/DefaultTheme';
export function LexicalEditor({ initialContent, onChange }) {
const editorConfig = {
namespace: 'storyie-web',
theme: DefaultTheme, // From lexical-common
nodes: [ImageNode], // From lexical-common
onError: (error) => console.error(error),
};
return (
<LexicalComposer initialConfig={editorConfig}>
<RichTextPlugin
contentEditable={<ContentEditable className='editor-content' />}
placeholder={<div className='editor-placeholder'>Start writing...</div>}
/>
{/* Web-specific plugins: toolbar, image upload, etc. */}
</LexicalComposer>
);
}The "use client" directive tells Next.js this component runs on the client, not during server rendering.
Package 3: lexical-editor-expo (Mobile Implementation)
The mobile package uses WebView to run the same editor:
// packages/lexical-editor-expo/src/LexicalEditorExpo.tsx
import { WebView } from 'react-native-webview';
import type { SerializedEditorState } from 'lexical';
export function LexicalEditorExpo({
initialContent,
onContentChange,
}: LexicalEditorExpoProps) {
const htmlContent = `
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
/* Styles from lexical-common theme */
body { margin: 0; padding: 16px; }
.editor-content { min-height: 200px; }
</style>
<script src="lexical.min.js"></script>
</head>
<body>
<div id="editor" class="editor-content"></div>
<script>
// Initialize Lexical with shared nodes
const editor = createEditor({
namespace: "storyie-mobile",
nodes: [ImageNode], // Same node as web!
onError: console.error,
});
// Load initial content
const initialState = ${JSON.stringify(initialContent)};
editor.setEditorState(editor.parseEditorState(initialState));
// Send updates to React Native
editor.registerUpdateListener(({ editorState }) => {
window.ReactNativeWebView.postMessage(
JSON.stringify(editorState.toJSON())
);
});
</script>
</body>
</html>
`;
return (
<WebView
source={{ html: htmlContent }}
onMessage={(event) => {
const editorState = JSON.parse(event.nativeEvent.data);
onContentChange(editorState);
}}
style={{ flex: 1 }}
/>
);
}The WebView runs the exact same Lexical editor as the web app, with the same custom nodes and theme.
Platform-Agnostic Core (lexical-common)
The shared package contains all editor logic that works identically on both platforms.
Custom Nodes Design
Custom nodes extend Lexical's base node types. Here's our ImageNode:
// packages/lexical-common/src/nodes/ImageNode.ts
import { DecoratorNode } from 'lexical';
import type { SerializedLexicalNode, LexicalNode } from 'lexical';
export interface SerializedImageNode extends SerializedLexicalNode {
src: string;
alt: string;
width?: number;
height?: number;
}
export class ImageNode extends DecoratorNode<JSX.Element> {
__src: string;
__alt: string;
__width?: number;
__height?: number;
constructor(
src: string,
alt: string,
width?: number,
height?: number,
key?: string
) {
super(key);
this.__src = src;
this.__alt = alt;
this.__width = width;
this.__height = height;
}
static getType(): string {
return 'image';
}
static clone(node: ImageNode): ImageNode {
return new ImageNode(
node.__src,
node.__alt,
node.__width,
node.__height,
node.__key
);
}
exportJSON(): SerializedImageNode {
return {
src: this.__src,
alt: this.__alt,
width: this.__width,
height: this.__height,
type: 'image',
version: 1,
};
}
static importJSON(serializedNode: SerializedImageNode): ImageNode {
return $createImageNode({
src: serializedNode.src,
alt: serializedNode.alt,
width: serializedNode.width,
height: serializedNode.height,
});
}
// Renderer (works on both web and WebView)
decorate(): JSX.Element {
return (
<img
src={this.__src}
alt={this.__alt}
width={this.__width}
height={this.__height}
/>
);
}
}
export function $createImageNode({ src, alt, width, height }): ImageNode {
return new ImageNode(src, alt, width, height);
}Key points:
- No platform directives
exportJSON()andimportJSON()enable database storagedecorate()returns JSX that works in both React DOM and WebView- Helper function
$createImageNode()simplifies node creation
Shared Theme Configuration
Themes map Lexical node types to CSS classes:
// packages/lexical-common/src/themes/DefaultTheme.ts
export const DefaultTheme = {
paragraph: 'editor-paragraph',
heading: {
h1: 'editor-heading-h1',
h2: 'editor-heading-h2',
h3: 'editor-heading-h3',
},
text: {
bold: 'editor-text-bold',
italic: 'editor-text-italic',
underline: 'editor-text-underline',
strikethrough: 'editor-text-strikethrough',
code: 'editor-text-code',
},
link: 'editor-link',
list: {
ul: 'editor-list-ul',
ol: 'editor-list-ol',
listitem: 'editor-list-item',
},
code: 'editor-code-block',
};Both web and mobile apply CSS for these classes, ensuring consistent styling.
No Platform Dependencies
The golden rule for lexical-common:
// ❌ NEVER in lexical-common
"use client"
"use server"
"use dom"
import { ... } from "next/..."
import { ... } from "expo-..."
import { Platform } from "react-native"
// ✅ ALWAYS in lexical-common
import { ... } from "lexical"
import { ... } from "@lexical/react"
import { ... } from "@lexical/utils"This discipline ensures the package truly works everywhere.
Web Implementation (lexical-editor)
The web package adds Next.js-specific features.
Next.js Integration with Server Components
Next.js 16's Server Components require the "use client" directive for interactive components:
// packages/lexical-editor/src/LexicalEditor.tsx
'use client';
import { LexicalComposer } from '@lexical/react/LexicalComposer';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
import { AutoFocusPlugin } from '@lexical/react/LexicalAutoFocusPlugin';
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import { ImageNode } from '@storyie/lexical-common/nodes/ImageNode';
import { DefaultTheme } from '@storyie/lexical-common/themes/DefaultTheme';
export function LexicalEditor({ initialContent, onChange }) {
const editorConfig = {
namespace: 'storyie-web',
theme: DefaultTheme,
nodes: [ImageNode],
onError: console.error,
editorState: initialContent ? JSON.stringify(initialContent) : undefined,
};
return (
<LexicalComposer initialConfig={editorConfig}>
<div className='editor-container'>
<RichTextPlugin
contentEditable={<ContentEditable className='editor-content' />}
placeholder={
<div className='editor-placeholder'>Start writing...</div>
}
ErrorBoundary={LexicalErrorBoundary}
/>
<HistoryPlugin />
<AutoFocusPlugin />
<OnChangePlugin onChange={onChange} />
</div>
</LexicalComposer>
);
}The "use client" directive is isolated to the web package, keeping lexical-common platform-agnostic.
Web-Specific Plugins
We add web-only features through plugins:
// packages/lexical-editor/src/plugins/ImageUploadPlugin.tsx
'use client';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $createImageNode } from '@storyie/lexical-common/nodes/ImageNode';
export function ImageUploadPlugin() {
const [editor] = useLexicalComposerContext();
const handleImageUpload = async (file: File) => {
// Upload to Cloudflare R2 (web-specific)
const url = await uploadToR2(file);
editor.update(() => {
const imageNode = $createImageNode({ src: url, alt: file.name });
$insertNodes([imageNode]);
});
};
return <ImageUploadButton onUpload={handleImageUpload} />;
}This plugin uses web APIs (File, fetch) that don't exist in React Native.
Re-exporting from lexical-common
The web package simplifies imports by re-exporting shared code:
// packages/lexical-editor/src/index.ts
export * from '@storyie/lexical-common/nodes/ImageNode';
export * from '@storyie/lexical-common/themes/DefaultTheme';
export { LexicalEditor } from './LexicalEditor';Web consumers can import directly from @storyie/lexical-editor without knowing about the common package.
React Native Implementation (lexical-editor-expo)
The mobile package uses WebView to achieve platform parity.
Why WebView for Mobile?
React Native doesn't provide contentEditable or any rich text editing primitives. Building a custom editor from scratch would require:
- Implementing text selection
- Handling cursor positioning
- Supporting undo/redo
- Managing focus and keyboard
- Rendering formatted text
That's months of work and prone to bugs. Instead, we run the web editor inside a WebView, giving us:
- ✅ Identical editing behavior
- ✅ Same custom nodes
- ✅ Proven reliability
- ✅ Free updates when we improve the web editor
This approach is used by Notion, Slack, and other production apps.
WebView Setup
The WebView renders a complete HTML page with embedded Lexical:
// packages/lexical-editor-expo/src/LexicalEditorExpo.tsx
import { WebView } from 'react-native-webview';
export function LexicalEditorExpo({ initialContent, onContentChange }) {
const htmlContent = `
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
<style>
body {
margin: 0;
padding: 16px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.editor-content {
min-height: 200px;
outline: none;
}
/* Theme classes from lexical-common */
.editor-paragraph { margin: 0 0 8px 0; }
.editor-heading-h1 { font-size: 32px; font-weight: bold; margin: 24px 0 16px 0; }
.editor-text-bold { font-weight: bold; }
.editor-text-italic { font-style: italic; }
</style>
<script src="https://cdn.storyie.app/lexical.min.js"></script>
</head>
<body>
<div id="editor" class="editor-content"></div>
<script>
const { createEditor } = Lexical;
const { registerRichText } = LexicalRichText;
const editor = createEditor({
namespace: "storyie-mobile",
nodes: [ImageNode],
onError: (error) => console.error(error),
});
registerRichText(editor);
// Load initial content
const initialState = ${JSON.stringify(initialContent)};
if (initialState) {
editor.setEditorState(editor.parseEditorState(initialState));
}
// Send updates to React Native
editor.registerUpdateListener(({ editorState }) => {
const json = editorState.toJSON();
window.ReactNativeWebView.postMessage(JSON.stringify(json));
});
// Mount editor
const contentEditableElement = document.getElementById("editor");
editor.setRootElement(contentEditableElement);
</script>
</body>
</html>
`;
return (
<WebView
source={{ html: htmlContent }}
onMessage={(event) => {
try {
const editorState = JSON.parse(event.nativeEvent.data);
onContentChange(editorState);
} catch (error) {
console.error('Failed to parse editor state:', error);
}
}}
style={{ flex: 1 }}
scrollEnabled={true}
keyboardDisplayRequiresUserAction={false}
/>
);
}Native ↔ WebView Communication
WebView communication works in both directions:
WebView → React Native (editor state updates):
// Inside WebView HTML
window.ReactNativeWebView.postMessage(JSON.stringify(editorState.toJSON()));// React Native component
<WebView
onMessage={(event) => {
const editorState = JSON.parse(event.nativeEvent.data);
onContentChange(editorState);
}}
/>React Native → WebView (commands):
// React Native component
const webViewRef = useRef<WebView>(null);
const insertImage = (imageUrl: string) => {
webViewRef.current?.injectJavaScript(`
editor.update(() => {
const imageNode = $createImageNode({ src: "${imageUrl}" });
$insertNodes([imageNode]);
});
true; // Required to prevent warnings
`);
};
return <WebView ref={webViewRef} ... />;This bidirectional communication enables native UI (toolbars, image pickers) to control the WebView editor.
Mobile-Specific Features
We add native features through the React Native wrapper:
// packages/lexical-editor-expo/src/hooks/useImagePicker.ts
import * as ImagePicker from 'expo-image-picker';
import { uploadToR2 } from '@/services/imageUpload';
export function useImagePicker(webViewRef: React.RefObject<WebView>) {
const insertImage = async () => {
// Request permission
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!permission.granted) return;
// Pick image
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
quality: 0.8,
});
if (result.canceled) return;
// Upload to R2
const imageUrl = await uploadToR2(result.assets[0].uri);
// Insert into editor via WebView
webViewRef.current?.injectJavaScript(`
editor.update(() => {
const imageNode = $createImageNode({
src: "${imageUrl}",
alt: "Uploaded image"
});
$insertNodes([imageNode]);
});
true;
`);
};
return { insertImage };
}The native image picker provides a better mobile UX than an HTML file input would.
EditorState Serialization
Cross-platform compatibility relies on consistent JSON serialization.
JSON Format
Lexical's EditorState serializes to JSON:
{
"root": {
"children": [
{
"children": [
{
"detail": 0,
"format": 0,
"mode": "normal",
"style": "",
"text": "This is a diary entry with ",
"type": "text",
"version": 1
},
{
"detail": 0,
"format": 1,
"mode": "normal",
"style": "",
"text": "bold text",
"type": "text",
"version": 1
}
],
"direction": "ltr",
"format": "",
"indent": 0,
"type": "paragraph",
"version": 1
},
{
"src": "https://cdn.storyie.app/diary-123/image.jpg",
"alt": "Sunset photo",
"type": "image",
"version": 1
}
],
"direction": "ltr",
"format": "",
"indent": 0,
"type": "root",
"version": 1
}
}Notice how ImageNode serializes with our custom exportJSON() method.
Cross-Platform Compatibility
The same JSON works on web and mobile because:
- Both platforms use the same
ImageNodeclass (fromlexical-common) - Both platforms register the same custom nodes
- Both platforms use Lexical's built-in serialization
When a user creates a diary on web and opens it on mobile, this flow happens:
- Web: User edits → Lexical EditorState →
editorState.toJSON()→ Save to database (JSONB column) - Mobile: Load from database → Parse JSON →
editor.parseEditorState(json)→ Render in WebView
No conversion needed. The JSON is the source of truth.
Migration Strategy
When we change node schemas, we maintain backward compatibility:
// packages/lexical-common/src/nodes/ImageNode.ts
static importJSON(serializedNode: SerializedImageNode): ImageNode {
// Handle version 1 (old schema)
if (serializedNode.version === 1) {
return $createImageNode({
src: serializedNode.src,
alt: serializedNode.alt || "", // Default for missing field
});
}
// Handle version 2 (new schema with width/height)
return $createImageNode({
src: serializedNode.src,
alt: serializedNode.alt,
width: serializedNode.width,
height: serializedNode.height,
});
}This ensures old diary entries continue to render correctly after schema changes.
Performance Considerations
Cross-platform architecture requires performance optimization on both platforms.
Web Performance
For the Next.js web app:
- Code splitting: Import large plugins dynamically
- Debouncing: Only save to database every 2 seconds
- Memoization: Use
React.memo()for expensive components
const OnChangePlugin = React.memo(
({ onChange }: { onChange: (state: string) => void }) => {
const [editor] = useLexicalComposerContext();
useEffect(() => {
const debounced = debounce((editorState) => {
onChange(JSON.stringify(editorState.toJSON()));
}, 2000);
return editor.registerUpdateListener(({ editorState }) => {
debounced(editorState);
});
}, [editor, onChange]);
return null;
}
);Mobile Performance (WebView)
WebView introduces communication overhead. We optimize by:
- Minimizing message passing: Only send state on explicit save, not every keystroke
- Batching updates: Combine multiple commands into single
injectJavaScript()call - Optimizing HTML bundle: Minify Lexical JavaScript bundle
// Batch multiple commands
const batchCommands = (commands: string[]) => {
const batched = commands.join(';\n');
webViewRef.current?.injectJavaScript(`
editor.update(() => {
${batched}
});
true;
`);
};
// Example: Insert image and focus editor in one call
batchCommands([
`const imageNode = $createImageNode({ src: "${url}" })`,
`$insertNodes([imageNode])`,
`editor.focus()`,
]);Lessons Learned
What Worked Well
- Three-package split: Clean separation of concerns made development easier
- Platform-agnostic core: Zero platform directives in
lexical-commonprevented coupling - WebView approach: Avoided re-implementing rich text editing from scratch
- Monorepo: Workspace dependencies (
workspace:*) ensured packages stayed in sync
What We'd Do Differently
- Earlier testing: We should have set up cross-platform integration tests from day one
- More granular packages: Plugins could be separate npm packages for better tree-shaking
- Better TypeScript exports: Improve type inference for plugin APIs
Future Improvements
- Collaborative editing: Add Y.js integration for real-time collaboration
- More custom nodes: Tables, embeds (YouTube, Twitter), diagrams
- Offline-first: Local-first editing with sync queue for better mobile UX
- Performance monitoring: Track editor performance metrics (time to interactive, input latency)
Conclusion and Key Takeaways
Building Storyie's cross-platform Lexical editor taught us valuable lessons about architecting for multiple platforms:
- Lexical's framework-agnostic core enabled true cross-platform architecture without compromises
- Three-package split (common, web, mobile) enforced clean separation and prevented platform coupling
- Platform-agnostic package must have zero platform directives - this discipline is non-negotiable
- WebView enables mobile apps to use the exact same editor as web - avoiding months of reimplementation
- JSON serialization provides database-agnostic storage - works with PostgreSQL, MongoDB, or any database supporting JSON
If you're building a cross-platform app with rich text editing, consider this architecture. The upfront investment in package structure pays dividends in maintenance costs and feature velocity.
Related Posts
- Building a Monorepo with pnpm and TypeScript - Learn how we organize packages in our monorepo
- Building a Cross-Platform Mobile App with Expo - Deep dive into our WebView integration patterns
- Database Schema Design with Drizzle ORM - How we store diary content as JSONB
Try Storyie
Experience our cross-platform editor firsthand. Create a diary on the web at storyie.com, then open it on our mobile app - you'll see identical rendering and editing behavior.