Building a Cross-Platform Rich Text Editor with Lexical

Storyie Engineering Team
8 min read

Learn how we built a platform-agnostic Lexical editor that works on both Next.js web and React Native mobile apps through a three-package monorepo architecture.

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-common contains zero platform-specific code
  • lexical-editor wraps the common package for Next.js
  • lexical-editor-expo wraps 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 both lexical-editor (Web) and lexical-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() and importJSON() enable database storage
  • decorate() 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:

  1. Both platforms use the same ImageNode class (from lexical-common)
  2. Both platforms register the same custom nodes
  3. Both platforms use Lexical's built-in serialization

When a user creates a diary on web and opens it on mobile, this flow happens:

  1. Web: User edits → Lexical EditorState → editorState.toJSON() → Save to database (JSONB column)
  2. 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

  1. Three-package split: Clean separation of concerns made development easier
  2. Platform-agnostic core: Zero platform directives in lexical-common prevented coupling
  3. WebView approach: Avoided re-implementing rich text editing from scratch
  4. Monorepo: Workspace dependencies (workspace:*) ensured packages stayed in sync

What We'd Do Differently

  1. Earlier testing: We should have set up cross-platform integration tests from day one
  2. More granular packages: Plugins could be separate npm packages for better tree-shaking
  3. Better TypeScript exports: Improve type inference for plugin APIs

Future Improvements

  1. Collaborative editing: Add Y.js integration for real-time collaboration
  2. More custom nodes: Tables, embeds (YouTube, Twitter), diagrams
  3. Offline-first: Local-first editing with sync queue for better mobile UX
  4. 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:

  1. Lexical's framework-agnostic core enabled true cross-platform architecture without compromises
  2. Three-package split (common, web, mobile) enforced clean separation and prevented platform coupling
  3. Platform-agnostic package must have zero platform directives - this discipline is non-negotiable
  4. WebView enables mobile apps to use the exact same editor as web - avoiding months of reimplementation
  5. 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

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.