Building a Lexical editor that works identically on web and mobile is one of the most challenging aspects of cross-platform development. At Storyie, diary entries created on our Next.js web app must render perfectly in our React Native mobile app, and vice versa. Through a two-package architecture plus Expo DOM Components on top of Meta's Lexical framework, we achieved true cross-platform editor parity — same nodes, same serialization, same round-trip. This guide walks through the architecture, the editorState.toJSON() / parseEditorState() round-trip, an OnChangePlugin for capturing edits, and the Expo DOM Components setup that lets a Lexical editor run inside React Native.
TL;DR: Lexical EditorState Serialization API
If you landed here looking for the canonical way to serialize and deserialize a Lexical EditorState, here is the full round-trip in four APIs.
Operation | API | Use case |
|---|---|---|
Serialize |
| Convert |
Stringify |
| DB-storable string (calls |
Deserialize |
| Parse JSON back into a Lexical |
Apply |
| Mount the parsed state into the live editor |
Full round-trip example — save to DB, then load and restore:
// 1. Serialize (save to DB)
const editorState = editor.getEditorState();
const json = JSON.stringify(editorState); // calls editorState.toJSON() under the hood
await db.diaries.update({ id, content: json });
// 2. Deserialize (load from DB)
const stored = await db.diaries.get(id);
const state = editor.parseEditorState(stored.content);
editor.setEditorState(state);Two important notes:
JSON.stringify(editorState)works because Lexical'sEditorStateexposes atoJSONmethod, whichJSON.stringifyinvokes automatically. You almost never need to calleditorState.toJSON()yourself unless you want the JSON object (not a string).editor.parseEditorState()accepts either a JSON string or a parsed object — both forms are valid.
To share this same serialization layer between Next.js web and React Native, Storyie keeps the Lexical core in a platform-agnostic @storyie/lexical-common package and consumes it from both @storyie/lexical-editor (web) and Expo DOM Components on the mobile side. The rest of this post is the architectural deep-dive behind that.
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: instead of hand-rolling react-native-webview we use Expo DOM Components, and we keep only two shared packages (lexical-common, lexical-editor) so mobile-only UI can live next to the Expo screens that need it.
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.
Two-Package Architecture + Expo DOM Components
Our editor lives in two npm packages plus a set of Expo DOM Components inside the mobile app (see our monorepo architecture post):
packages/
├── lexical-common/ # Platform-agnostic, headless-safe core
└── lexical-editor/ # Web (Next.js) wrapper, re-exports lexical-common
apps/expo/components/lexical/
├── editor/ # React Native chrome (toolbar, sheets, modals)
└── dom/ # "use dom" files run inside Expo's managed WebView
├── editor/SimpleLexicalEditorCore.tsx
├── viewer/SimpleLexicalViewer.tsx
├── plugins/ # Mobile-only Lexical plugins
└── common/ # Mobile DOM theme/config (consumes lexical-common)This architecture enforces separation of concerns:
lexical-commoncontains zero platform-specific code — no"use client","use server","use dom", nonext/*, noexpo-*, noreact-native. It is also headless-safe so the same nodes and serialization run under Node (e.g. server-side AI prompt building).lexical-editorwraps the common package for Next.js. It carries"use client"and any DOM-only nodes (e.g. anImageNodethat decorates to JSX).- The Expo app consumes
lexical-commondirectly fromapps/expo/components/lexical/dom/**. Those files are tagged"use dom", so Expo runs them inside a managed WebView — but you write them as ordinary React, not as rawreact-native-webviewHTML strings.
We do not ship a third lexical-editor-expo npm package. Mobile-specific Lexical UI lives in the app itself because it does not need to be reused outside apps/expo.
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: headless-safe nodes (headings, lists, code, links, hashtags, tables, marks), theme tokens, editor config, commands, markdown converters, serialization helpers.
Used by@storyie/lexical-editor(web) and by Expo DOM Components underapps/expo/components/lexical/dom/.
Platform-bound nodes (anything that calls decorate(): JSX.Element or touches the DOM directly — for us, ImageNode) live in lexical-editor, not lexical-common. The Expo side ships its own DOM-Component image rendering inside apps/expo/components/lexical/dom/. Both sides agree on the serialized JSON shape, which is the only contract that has to stay in lockstep.
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.
Mobile via Expo DOM Components ("use dom")
Instead of shipping a third package and stitching together react-native-webview by hand, we use Expo DOM Components. A file marked with the "use dom" directive is automatically run inside a managed WebView; from React Native it just looks like a normal component.
// apps/expo/components/lexical/dom/editor/SimpleLexicalEditorCore.tsx
"use dom";
import "../styles/base.css";
import { LexicalComposer } from "@lexical/react/LexicalComposer";
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import { lexicalCommonNodes, type SerializedEditorState } from "@storyie/lexical-common";
export function SimpleLexicalEditorCore({
initialState,
onChange,
}: {
initialState?: SerializedEditorState;
onChange: (state: SerializedEditorState) => void;
}) {
const editorConfig = {
namespace: "storyie-mobile",
theme: mobileTheme, // mobile-flavored CSS classes
nodes: [...lexicalCommonNodes /* + mobile-only DOM nodes */],
editorState: initialState ? JSON.stringify(initialState) : undefined,
onError: (error: Error) => console.error(error),
};
return (
<LexicalComposer initialConfig={editorConfig}>
<RichTextPlugin
contentEditable={<ContentEditable className="editor-content" />}
placeholder={null}
/>
<SerializedEditorStatePlugin onChange={onChange} />
</LexicalComposer>
);
}// apps/expo/components/lexical/editor/SimpleLexicalEditorWithKeyboardToolbar.tsx
import { SimpleLexicalEditorCore } from "../dom/editor/SimpleLexicalEditorCore";
export function SimpleLexicalEditor(props: Props) {
// From RN's perspective this is just a React component.
// Expo runs the "use dom" tree inside a managed WebView for us.
return (
<KeyboardAvoidingView>
<SimpleLexicalEditorCore
initialState={props.initialState}
onChange={props.onChange}
/>
<KeyboardToolbar /* native RN UI lives outside the DOM tree */ />
</KeyboardAvoidingView>
);
}Because the DOM file consumes @storyie/lexical-common directly, the same nodes, theme tokens, commands, and serialization that power the web editor power the mobile one. We do not ship a separate lexical-editor-expo npm package — these files live next to the Expo screens that use them.
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. Headless-safe nodes (headings, lists, code, links, hashtags, tables, marks) live in lexical-common and are exported as lexicalCommonNodes. Anything that has to render JSX or touch the DOM — for us, ImageNode — lives in the consuming package, e.g. packages/lexical-editor/src/nodes/ImageNode.ts for web. Both sides agree on the same SerializedImageNode shape, which is the only contract that has to stay in lockstep.
Here's the web ImageNode:
// packages/lexical-editor/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 (web only — Expo DOM Components ship their own image renderer)
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:
decorate()is web-only — that is exactly why this node lives inlexical-editor, notlexical-commonexportJSON()andimportJSON()define the wire format both platforms agree on- Mobile registers a parallel
ImageNodeinsideapps/expo/components/lexical/dom/that emits the sameSerializedImageNodeJSON - 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.
Mobile Implementation (Expo DOM Components)
The mobile side uses Expo DOM Components instead of a separate lexical-editor-expo package.
Why DOM Components Instead of Raw react-native-webview?
React Native doesn't provide contentEditable or any rich text editing primitives. Building a custom editor from scratch would mean re-implementing text selection, cursor positioning, undo/redo, focus and keyboard handling, and formatted text rendering — months of work and prone to bugs.
The traditional alternative is react-native-webview: build an HTML string, hand it to a <WebView>, and bridge state with postMessage / injectJavaScript. We started there. It works, but it has real downsides:
- You write your editor as a giant HTML string template — no type checking, no JSX, no IDE help
- Calling into the editor means stringifying JS and
injectJavaScript-ing it, which is fragile and hard to debug - Hot reload and source maps don't really apply
Expo DOM Components ("use dom") replace all of that. A file marked "use dom" is bundled and run inside Expo's managed WebView, but from the React Native side it just looks like a normal React component. Props become messages over the bridge automatically. We get:
- ✅ Identical editing behavior to web (it is a WebView)
- ✅ The same custom nodes and theme as the web editor, by importing
@storyie/lexical-commondirectly - ✅ Real React/JSX/TypeScript instead of HTML string templates
- ✅ Standard React props for native ↔ DOM communication, no manual
postMessage/injectJavaScript - ✅ No third package to publish — files live next to the Expo screens
Editor core (the "use dom" file)
// apps/expo/components/lexical/dom/editor/SimpleLexicalEditorCore.tsx
"use dom";
import "../styles/base.css";
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 {
lexicalCommonNodes,
type SerializedEditorState,
} from "@storyie/lexical-common";
import { mobileTheme } from "../common/theme";
import { SerializedEditorStatePlugin } from "../plugins/SerializedEditorStatePlugin";
export type SimpleLexicalEditorCoreProps = {
initialState?: SerializedEditorState;
onChange: (state: SerializedEditorState) => void;
onInsertImagePress?: () => void; // calls back into RN
};
export function SimpleLexicalEditorCore({
initialState,
onChange,
}: SimpleLexicalEditorCoreProps) {
const editorConfig = {
namespace: "storyie-mobile",
theme: mobileTheme,
nodes: [...lexicalCommonNodes /* + mobile-only DOM nodes */],
editorState: initialState ? JSON.stringify(initialState) : undefined,
onError: (error: Error) => console.error(error),
};
return (
<LexicalComposer initialConfig={editorConfig}>
<RichTextPlugin
contentEditable={<ContentEditable className="editor-content" />}
placeholder={null}
/>
<HistoryPlugin />
<SerializedEditorStatePlugin onChange={onChange} />
</LexicalComposer>
);
}This is real React. The serialization round-trip is the same editorState.toJSON() / parseEditorState / setEditorState we use on web — there is no string-templated editor.update(...) anywhere.
React Native side: just a React component
// apps/expo/components/lexical/editor/SimpleLexicalEditorWithKeyboardToolbar.tsx
import { KeyboardAvoidingView } from "react-native-keyboard-controller";
import { SimpleLexicalEditorCore } from "../dom/editor/SimpleLexicalEditorCore";
import { KeyboardToolbar } from "../../keyboard-toolbar";
export function SimpleLexicalEditor(props: Props) {
return (
<KeyboardAvoidingView style={{ flex: 1 }}>
{/* Lives inside Expo's managed WebView, but you don't see that here. */}
<SimpleLexicalEditorCore
initialState={props.initialState}
onChange={props.onChange}
onInsertImagePress={props.onInsertImagePress}
/>
{/* Native RN UI — outside the DOM tree. */}
<KeyboardToolbar onInsertImage={props.onInsertImagePress} />
</KeyboardAvoidingView>
);
}The toolbar is regular React Native (it has to be — keyboard handling, native modals, image picker). Anything that touches the editor itself crosses into the DOM Component as a normal prop or callback.
Mobile-Specific Features (e.g. image picker)
Native UI like an image picker stays on the React Native side, then we hand the resulting upload URL back to the editor. There is no injectJavaScript — we just call back through props.
import * as ImagePicker from "expo-image-picker";
import { uploadToR2 } from "../../services/imageUpload";
async function pickAndInsertImage(insertImage: (payload: ImageInsertPayload) => void) {
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!permission.granted) return;
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
quality: 0.8,
});
if (result.canceled) return;
const imageUrl = await uploadToR2(result.assets[0].uri);
// Push back into the DOM editor via a normal React prop / Lexical command
insertImage({ src: imageUrl, alt: "Uploaded image" });
}Inside the DOM file, insertImage is wired to a Lexical command (e.g. INSERT_IMAGE_COMMAND from @storyie/lexical-common) that creates the mobile ImageNode. The same command exists on web, so toolbars on both platforms speak the same dialect.
Serialization with editorState.toJSON() and JSON.stringify
Cross-platform compatibility relies on consistent JSON serialization. Lexical exposes two equivalent serialization entry points:
editorState.toJSON()— returns a plain JSON object (SerializedEditorState).JSON.stringify(editorState)— returns a string.JSON.stringifywalks the value and callstoJSON()onEditorStateautomatically, so the two are interchangeable when you need a string.
We persist the string form to PostgreSQL (JSONB column) so we never have to re-serialize before writing:
const json = JSON.stringify(editor.getEditorState());
await db.diaries.update({ id, content: json });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.
Deserialization with parseEditorState and setEditorState
Loading saved content back into a live editor is a two-step API call: editor.parseEditorState() to turn JSON into an EditorState, then editor.setEditorState() to mount it.
const stored = await db.diaries.get(id);
// parseEditorState accepts a JSON string or a SerializedEditorState object.
const state = editor.parseEditorState(stored.content);
// setEditorState replaces the current editor state in one synchronous call.
editor.setEditorState(state);A few details that trip people up:
- Call
setEditorState()outside aeditor.update()block. It is a top-level state replacement, not a transformation. - All custom nodes (e.g.
ImageNode) must be registered ineditorConfig.nodesbeforeparseEditorStateruns, otherwise unknown nodes are dropped. - For SSR, you can pass the JSON string as
editorConfig.editorStateso Lexical hydrates with the saved state on first mount and you skip the manualparseEditorState+setEditorStateround trip.
Cross-Platform Round Trip
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()(viaJSON.stringify) → Save to database (JSONB column) - Mobile: Load from database →
editor.parseEditorState(json)→editor.setEditorState(state)→ Render inside an Expo DOM Component
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.
Configuring LexicalComposer initialConfig with an EditorState JSON string
LexicalComposer accepts an editorState field on initialConfig. There are three valid shapes — pick the one that matches what your data layer hands you:
import { LexicalComposer } from "@lexical/react/LexicalComposer";
import { lexicalCommonNodes } from "@storyie/lexical-common";
// 1. JSON string straight out of the database (most common)
const config = {
namespace: "storyie-web",
theme: DefaultTheme,
nodes: [...lexicalCommonNodes, ImageNode],
editorState: storedJsonString, // e.g. await db.diaries.get(id).content
onError: console.error,
};
// 2. Parsed JSON object — also OK
const config2 = {
namespace: "storyie-web",
theme: DefaultTheme,
nodes: [...lexicalCommonNodes, ImageNode],
editorState: JSON.stringify(storedObject), // stringify it back
onError: console.error,
};
// 3. Initializer function — full control, useful for SSR + custom hydration
const config3 = {
namespace: "storyie-web",
theme: DefaultTheme,
nodes: [...lexicalCommonNodes, ImageNode],
editorState: (editor) => editor.parseEditorState(storedJsonString),
onError: console.error,
};
return (
<LexicalComposer initialConfig={config}>
{/* RichTextPlugin, plugins, etc. */}
</LexicalComposer>
);A few rules of thumb:
- Always pass the same
nodesarray your saved state was produced from. Any unknown node type is silently dropped on parse — surprise data loss. - For SSR with Next.js Server Components, ship the JSON string from a Server Component to a
"use client"wrapper that callsLexicalComposerwitheditorState: jsonString. This skips an unnecessaryparseEditorState+setEditorStateround trip after hydration. editorStateoninitialConfigonly runs on first mount. If the server-fetched JSON changes (e.g. a route param flips), triggereditor.update(() => editor.setEditorState(newState))instead of remountingLexicalComposer.
OnChangePlugin example: capturing editorState.toJSON() with debouncing
The useLexicalComposerContext hook gives you the editor instance, and editor.registerUpdateListener fires on every change. Save the result of editorState.toJSON() (or JSON.stringify(editorState)) to your backend — and debounce it, because the listener runs on every keystroke.
Web implementation ("use client"):
"use client";
import { useEffect } from "react";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { debounce } from "@storyie/lexical-common";
type Props = {
onChange: (json: string) => void;
};
export function OnChangePlugin({ onChange }: Props) {
const [editor] = useLexicalComposerContext();
useEffect(() => {
const flush = debounce((state) => {
// editorState.toJSON() returns the SerializedEditorState object;
// JSON.stringify converts it to the string we want to persist.
onChange(JSON.stringify(state));
}, 1000);
return editor.registerUpdateListener(({ editorState }) => {
// .read() ensures we run inside an EditorState read context, which is
// required when accessing nodes during serialization.
editorState.read(() => flush(editorState.toJSON()));
});
}, [editor, onChange]);
return null;
}A few things that bite people:
- Don't call
editorState.toJSON()outsideeditorState.read(...). It works for the root, but custom nodeexportJSON()methods that touch other nodes will throw "outside of read context" errors. - The listener fires once per
editor.update(...)call, not once per keystroke — so if you batch updates insideeditor.update(), you only see one event. That is the right behavior for debouncing logic. - Don't forget to return the un-subscriber.
registerUpdateListenerreturns one; ifonChangechanges identity often the listener accumulates and you get duplicate writes.
Lexical with React Native: cross-platform editorState round-trip
Lexical is framework-agnostic in the sense that its core is plain JavaScript, but the React-Lexical bindings target contentEditable — which React Native does not provide. So "Lexical on React Native" really means "run Lexical inside a WebView and bridge state to the native side."
There are two ways to do that:
- Hand-rolled
react-native-webview— build the editor as an HTML string, inject JS, sendpostMessageevents back. Powerful but you lose JSX, type checking, hot reload, source maps. - Expo DOM Components (
"use dom") — mark a file with the"use dom"directive and Expo bundles + runs it inside a managed WebView automatically. Props look like normal React props. This is what Storyie ships.
The serialization round-trip is identical to web because both sides import the same custom nodes from @storyie/lexical-common:
[ web ] [ mobile (Expo "use dom") ]
editorState.toJSON() / JSON.stringify -> editor.parseEditorState(json)
│ editor.setEditorState(state)
v
PostgreSQL JSONB column
^
│
editorState.toJSON() / JSON.stringify <- editorState.toJSON() / JSON.stringify
[ mobile ] [ web ]Concrete consequences:
- A diary saved on web renders pixel-equivalent on mobile (and vice-versa) because the JSON is the only contract — no platform-specific transforms.
- Custom nodes (e.g.
ImageNode) live in the shared@storyie/lexical-commonpackage so both sides agree onexportJSON()/importJSON()shapes. Anything that has to render JSX (web<img>, mobile DOM<img>with extra mobile styling) lives in the consuming package, but the serialized format does not differ. - For mobile, debounce serialization callbacks before crossing the WebView ↔ React Native bridge. Each
onChangeevent is a structured-clone copy; sending one per keystroke is wasteful.
If you're stuck on a literal "react-native-webview" setup, the same techniques apply — just replace "use dom" with manually-crafted HTML and postMessage.
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 (Expo DOM Components)
Expo DOM Components still run inside a WebView, so the bridge between the native side and the DOM tree is the main thing to keep cheap. We optimize by:
- Debouncing serialization: only push
editorState.toJSON()back over the bridge on idle (≥ 1s after the last keystroke), not on every change - Minimizing prop churn: pass primitives across the
"use dom"boundary (string IDs, JSON snapshots) instead of large objects with stable identities - Co-locating heavy work in the DOM file: image processing, markdown parsing, and command dispatch happen inside the DOM Component, so a single
onChangecallback delivers the result to React Native rather than chatting back and forth - Keeping commands on
lexical-common: both web and mobile dispatch the sameINSERT_IMAGE_COMMAND/UPDATE_IMAGE_COMMAND/DELETE_IMAGE_COMMAND, which keeps mobile-only branching out of the hot path
// Inside the "use dom" file: one debounced edge to the RN side.
import { useEffect } from "react";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { debounce } from "@storyie/lexical-common";
export function SerializedEditorStatePlugin({
onChange,
}: {
onChange: (state: SerializedEditorState) => void;
}) {
const [editor] = useLexicalComposerContext();
useEffect(() => {
const flush = debounce((state: SerializedEditorState) => onChange(state), 1000);
return editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => flush(editorState.toJSON()));
});
}, [editor, onChange]);
return null;
}Lessons Learned
What Worked Well
- Two packages, not three: Keeping mobile-only Lexical UI inside
apps/expo(instead of a publishedlexical-editor-expopackage) removed a layer of indirection without losing reuse — the core that had to be reused was alwayslexical-common - Platform-agnostic core: Zero platform directives and zero DOM-only nodes in
lexical-commonprevented coupling and let us run the same serialization headlessly on the server - Expo DOM Components over raw
react-native-webview: We got a real WebView-backed editor with normal React/TypeScript ergonomics — no HTML string templates, noinjectJavaScript - Monorepo: Workspace dependencies (
workspace:*) ensuredlexical-commonandlexical-editorstayed in sync acrossapps/webandapps/expo
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
- Two shared packages plus Expo DOM Components (
lexical-common,lexical-editor, andapps/expo/components/lexical/dom/**) was the right factoring — not a thirdlexical-editor-expopackage - Platform-agnostic package must have zero platform directives and zero DOM-only nodes - this discipline is non-negotiable
- Expo DOM Components let mobile reuse the web editor with normal React - avoiding months of reimplementation and avoiding hand-rolled
react-native-webviewbridges - 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 Expo DOM Components integration patterns
- Database Schema Design with Drizzle ORM - How we store diary content as JSONB
For Developers: Where the Code Lives
If you want to skim the actual implementation behind this post, the relevant files in the Storyie monorepo are:
packages/lexical-common/— platform-agnostic core: custom nodes (withexportJSON/importJSON), commands, theme tokens, markdown converters. No"use client","use server","use dom", nonext/*, noreact-nativeimports. Headless-safe so the same serialization works under Node (e.g. AI prompt building) too.packages/lexical-editor/— Next.js web wrapper ("use client"). Re-exportslexical-commonand adds web-only plugins/components.apps/expo/components/lexical/dom/— React Native side. Files here are tagged"use dom"so Expo runs them inside a managed WebView; they import directly from@storyie/lexical-commonand call the sameparseEditorState/setEditorStateround-trip described above.
The serialization round-trip (editorState.toJSON() → JSONB column → parseEditorState → setEditorState) is identical across both apps. That symmetry is exactly what falls out of keeping lexical-common free of any platform directive or import.
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.
FAQ
How do I serialize a Lexical EditorState to JSON?
Call editorState.toJSON() to get a SerializedEditorState object, or JSON.stringify(editorState) to get a string. JSON.stringify works because EditorState exposes a toJSON method, so the two are equivalent — pick whichever shape your storage layer needs.
What does JSON.stringify(editorState) actually return?
The same shape as editorState.toJSON(), but as a string: a top-level { "root": { "children": [...], "type": "root", "version": 1, ... } } document. Each node carries a type, version, and node-specific fields like format/style for text or src/alt for images.
How do I load saved JSON back into a Lexical editor with parseEditorState and setEditorState?
Pass the JSON string (or parsed object) to editor.parseEditorState(json) to get an EditorState, then editor.setEditorState(state) to replace the live state. Call setEditorState outside an editor.update() block, and make sure every custom node is registered in editorConfig.nodes before parsing — unknown node types are dropped silently.
How do I configure LexicalComposer initialConfig with an EditorState JSON string?
Set editorConfig.editorState to a JSON string (or to (editor) => editor.parseEditorState(json) for objects). Lexical hydrates the editor with that state on first mount, which is the right path for SSR — you skip the manual parseEditorState + setEditorState round trip.
How do I write an OnChangePlugin that captures editorState.toJSON()?
Inside a "use client" component, call useLexicalComposerContext to grab the editor, then editor.registerUpdateListener(({ editorState }) => onChange(editorState.toJSON())). Debounce the callback (we use ~1s) before writing to the database — every keystroke fires an update.
Does Lexical work with React Native?
Lexical is framework-agnostic but relies on contentEditable, which React Native does not provide. Run the Lexical core inside a WebView. The cleanest setup on Expo is to mark a file with the "use dom" directive — Expo bundles and runs it inside a managed WebView while you keep writing real React/JSX, and props serialize across the bridge automatically.