Cross-platform Lexical with use dom: monorepo gains and the bridges you still own
Storyie ships the same rich-text editor on Next.js (web) and Expo (mobile). Both sides use Lexical, and the goal is simple: a diary written on the web opens identically on mobile, and vice versa. We previously wrote about the serialization round trip — editorState.toJSON() → JSONB → parseEditorState. This post is about the layer above that: how the package structure carries Lexical across two runtimes, and where Expo's "use dom" directive (introduced in SDK 52) draws the line between "free reuse" and "still your problem."
TL;DR
- Pull every Lexical primitive that doesn't touch the DOM into a platform-agnostic package (
@storyie/lexical-common): node definitions, theme, command tokens, config factories. Both web and mobile consume it directly. - Expo's
"use dom"lets the mobile app run real@lexical/reactcode inside a managed WebView. The editing surface, plugins, and shared package work without modification. - What
"use dom"does not solve: native ↔ DOM state sync, OS-level APIs (image picker, file system), link handoff to the system browser, keyboard geometry, and CSS theming across the WebView/native divide. These still need a deliberate bridge. - The line we settled on: shared package = wire format and behavior; web package = DOM-bound nodes and SSR; app side = native APIs, native UI, and the bridge plumbing.
| Layer | Lives in | Carries |
| ---------------- | ------------------------------------- | ------------------------------------------------------------------------ |
| Shared core | packages/lexical-common | Node classes (no decorate()), theme, config factories, command tokens |
| Web wrapper | packages/lexical-editor | "use client", ImageNode (DOM decorate), SSR setup, web-only plugins |
| Mobile chrome | apps/expo/components/lexical/dom/** | "use dom" files importing the shared package + mobile-only DOM nodes |
| Mobile native UI | apps/expo/components/lexical/editor/| Toolbars, keyboard, picker integration, bridge state |
Why a monorepo at all
We landed on a pnpm workspace because three concerns kept dragging the editor toward duplication, and duplicating any of them would have shipped bugs.
The wire format has to be a single source of truth
Lexical persists editor state as SerializedEditorState, a JSON shape derived from whatever nodes are registered on the editor. If the web editor knows about HeadingNode and the mobile editor doesn't, a heading saved on the web is silently dropped on mobile when parseEditorState runs — unknown node types are removed. We needed both sides to register the exact same node set, so the node definitions had to live in one place.
Theme tokens drift the moment they're typed twice
The Lexical theme is a flat map from node types to CSS class names: editor-heading-h1, editor-quote, and so on. If you let each app declare its own version, someone will rename editor-heading-h1 to editor-h1 on one side six months later, and the headings will quietly stop being styled on the other side. Centralizing the class-name map made platform-specific work strictly cosmetic — each app writes its own CSS, but never disagrees on which class to write rules for.
Config boilerplate begs for a factory
Every Lexical mount needs a namespace, the full nodes array, a theme, an onError, and an initial editorState. Re-deriving that on each surface is busywork. A createBaseConfig() helper in the shared package collapses it to one line on both web and mobile.
Package layout
packages/
lexical-common/ # platform-agnostic
lexical-editor/ # web-only (DOM-dependent)
apps/
web/ # Next.js
expo/ # Expo (React Native)What goes into lexical-common
- All headless-safe nodes:
HeadingNode,ListNode,LinkNode,CodeNode,HashtagNode, etc. - Type aliases for serialized shapes:
SerializedImageNode,EditorContent. - The theme: a typed map of class names.
- Config factories:
createBaseConfig({ namespace, initialState }). - Command tokens:
INSERT_IMAGE_COMMAND,UPDATE_IMAGE_COMMAND,DELETE_IMAGE_COMMAND.
The non-negotiable rule for this package is no platform directive, no platform import. No "use client", no "use server", no "use dom", no next/*, no expo-*, no react-native. The package has to be runnable from anywhere — including under Node, where we use it for AI prompt construction without spinning up a DOM.
What goes into lexical-editor
This package is "use client" on top, and exists because some Lexical work is fundamentally DOM-bound:
ImageNode: extendsDecoratorNode<JSX.Element>. Itsdecorate()returns a real<img>.ImagePlugin: handles insert/update/delete commands and renders the React tree inside the editor.- SSR helpers: a small DOM polyfill so Lexical can render headlessly during Next.js server rendering.
Why ImageNode doesn't live in the shared package
This is the most-asked question on our team. Both platforms need an image node, and the serialized shape is genuinely identical, so the temptation is to put it in lexical-common and import it from both sides.
The blocker is decorate(). On web, decorating an image node is <img src={...} alt={...} /> — JSX rendered into the editor's React tree. On mobile (inside the "use dom" WebView), decorating still produces JSX, but the JSX has to wire to a custom mobile-flavored renderer that understands progress states, retry UI, and tap-to-preview. Putting both implementations behind a single class meant either smuggling a platform branch into the supposedly platform-free package, or shipping a "renderer-less" base class with two subclasses — at which point the inheritance is doing nothing and the duplication is just hidden.
We accepted the duplication. The web ImageNode lives in lexical-editor. The mobile ImageNode lives in apps/expo/components/lexical/dom/nodes/. Both exportJSON() to the same SerializedImageNode. That JSON contract is the only thing that has to stay locked, and it does — because the type lives in lexical-common and is imported by both sides.
What "use dom" actually buys you
"use dom" is the most important Expo SDK feature for this setup. A file marked with the directive is bundled and run inside a managed WebView, but from React Native's perspective it's a normal React component. Props become messages over the bridge automatically. There's no injectJavaScript, no HTML string template, no postMessage plumbing for the editing path itself.
Our mobile editor lives in:
apps/expo/components/lexical/
dom/
editor/SimpleLexicalEditorCore.tsx ← "use dom"
viewer/SimpleLexicalViewer.tsx ← "use dom"
editor/
SimpleLexicalEditorWithKeyboardToolbar.tsx ← native wrapperSimpleLexicalEditorCore.tsx opens with "use dom" and from there reads as if it were a web file:
"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, createBaseConfig } from "@storyie/lexical-common";
import { mobileTheme } from "../common/theme";
import { SerializedEditorStatePlugin } from "../plugins/SerializedEditorStatePlugin";
export function SimpleLexicalEditorCore({ initialState, onChange }: Props) {
const editorConfig = createBaseConfig({
namespace: "storyie-mobile",
nodes: [...lexicalCommonNodes /* + mobile-only DOM nodes */],
theme: mobileTheme,
initialState,
});
return (
<LexicalComposer initialConfig={editorConfig}>
<RichTextPlugin
contentEditable={<ContentEditable className="editor-content" />}
placeholder={null}
/>
<SerializedEditorStatePlugin onChange={onChange} />
</LexicalComposer>
);
}The pieces that work for free across both apps because of this:
- The Lexical core:
LexicalComposer,RichTextPlugin,HistoryPlugin,AutoFocusPlugin, etc. - The shared package:
@storyie/lexical-commonwith config factories, themes, node definitions. - Editing logic: bold, headings, lists, blockquote, undo/redo — all identical.
- Plugin reuse:
AutoLinkPlugin,HashtagPlugin,ListPlugin, and our own custom ones written for web.
If you only ever needed to edit text — no images, no system browser links, no keyboard geometry — "use dom" would be the entire story. The hard part is that real apps need everything past that line.
What "use dom" doesn't solve
The directive removes the WebView plumbing, not the WebView boundary. Anything that has to cross the boundary still needs a designed bridge. Below is what we ended up building, in roughly the order that bit us.
1. Native toolbar ↔ DOM editor selection sync
The mobile keyboard toolbar lives natively (it has to — it manages the on-screen keyboard, the image picker sheet, etc.). When the user taps "Bold," the toolbar sits in React Native land; the editor selection sits in the DOM. Both sides need to talk.
We solved this with two cooperating plugins inside the DOM tree:
ExternalCommandPlugin— accepts anExternalFormattingCommandobject via props. Whenever native passes a new command ({ type: "TOGGLE_BOLD", at: <timestamp> }), the plugin dispatches the matching Lexical command (FORMAT_TEXT_COMMANDwith"bold"). The timestamp prevents re-dispatching the same command twice when React rerenders.FormattingStateEmitterPlugin— registers an update listener on the editor, reads the active selection's formatting state on every change, and pushes it back via anonFormattingStatecallback prop. The native toolbar updates its button highlight states from that.
Both directions are explicit. "use dom" made the wire props/callbacks instead of postMessage, but the protocol design — what to send, when to send it, how to debounce — is hand-rolled.
2. The image upload flow
This is the most complex bridge in the app. The shape we ended up with:
- Native opens
expo-image-picker, gets a local URI. - Native pushes an
INSERT_IMAGE_COMMANDthrough the props bridge with{ id, uri: localUri, status: "uploading" }. The DOM-side image node renders a placeholder with the local URI and a spinner. - Native uploads to Cloudflare R2, emitting progress events.
- Native pushes
UPDATE_IMAGE_COMMANDwith progress to update the same node's progress bar. - On success, native pushes
UPDATE_IMAGE_COMMANDwith{ id, src: cdnUrl, status: "done" }. The DOM node swaps to the CDN URL. - On failure, native pushes
UPDATE_IMAGE_COMMANDwith{ id, status: "failed" }and shows a retry sheet.
The bridge had to support correlation IDs (so progress events update the right node), idempotent upserts (the user might background the app mid-upload), and at-least-once command delivery (a dropped progress event is fine; a dropped "done" is not). None of this is "use dom"'s job — but "use dom" is also explicitly not getting in the way.
3. Anchor clicks vs the system browser
A naive <a href="…"> inside the DOM editor navigates inside the WebView. On mobile, that loads the destination on top of the editor and traps the user inside it. Worse, internal links to /u/... or /d/... would try to load Storyie's web app inside our own native app.
We catch all anchor clicks at the document level inside the DOM tree:
useEffect(() => {
const onClick = (e: MouseEvent) => {
const a = (e.target as HTMLElement).closest("a");
if (!a?.href) return;
e.preventDefault();
onLinkPress(a.href);
};
document.addEventListener("click", onClick);
return () => document.removeEventListener("click", onClick);
}, [onLinkPress]);onLinkPress is a prop coming from the native side. The native wrapper calls Linking.openURL(href) so the system browser opens. "use dom" doesn't know about deep links or system intents — it's just a WebView.
4. Keyboard geometry
Mobile editors have to reflow when the keyboard opens. Storyie uses react-native-keyboard-controller on the native side to manage KeyboardAvoidingView, but the editor's caret-into-view logic lives in DOM land via an AutoScrollPlugin.
The plugin needs to know how much screen real estate the keyboard is occupying so it can scroll just enough — not too much, not too little. The keyboard height is a native concern; we forward it to the DOM tree as a keyboardHeight prop, the plugin reads it, and the DOM editor scrolls accordingly. Without that prop, the plugin would be guessing.
5. CSS, dark mode, and the two style universes
A "use dom" component can import "./styles.css" — that import is real, it works, and the styles apply inside the WebView. But it lives in a completely separate style world from the React Native StyleSheet that surrounds it.
Dark mode means we read the OS color scheme natively, pass it as a colorScheme prop into the DOM tree, and let the DOM tree set CSS variables (:root[data-theme="dark"] { --bg: …; }). The shared theme (@storyie/lexical-common's class-name map) tells both sides which class to style; each side has to actually write the styles. The class names match, so there's no semantic drift, but the rules are duplicated in spirit.
Looking back: was the monorepo worth it?
Yes, decisively. The wire format alone made the whole thing tractable — without a shared lexical-common, every cross-app change would carry the risk of silent node-drop on parse, and that bug class would burn weeks of debugging time per occurrence.
But "use dom" is best understood as a delete the WebView plumbing button, not a delete the platform divide button. The boundary between native and DOM is still real, and most of the interesting product work — image upload, link handoff, toolbar sync, keyboard geometry — lives across that boundary. The right reaction is to spend the saved plumbing time on a better bridge protocol, not to assume the bridge is gone.
The layering rule we settled on, in plain English:
- Shared package: types, wire format, themes (class-name maps), config factories, command tokens.
- Web package: DOM-bound nodes (
decorate()-using), SSR setup, HTML rendering helpers. - App side: platform APIs (camera, filesystem, keyboard, browser), native UI (toolbar, sheets), and the bridge protocol that connects all of it to the DOM tree.
Takeaways
- A pnpm monorepo with a strict platform-agnostic shared package is the cheapest way to keep two Lexical surfaces from drifting on wire format or theme.
"use dom"is genuinely transformative for the editing surface. Real@lexical/reactcode, real plugins, real CSS — all running on mobile with effectively no port."use dom"does not eliminate the bridge. Any feature that has to read native state or call a platform API still needs a deliberate, designed protocol — typically a pair of plugins inside the DOM tree (one for incoming commands, one for outgoing state) plus matching code on the native side.- Drawing the line consciously is the architecture work. Once you accept that some things will live on each side of the boundary, the question is just what to put where, and that question has a small set of clear answers.
Related Posts
- Lexical editorState.toJSON & parseEditorState: a copy-pasteable guide — the serialization round trip that makes any of this work
- Building a Monorepo with pnpm and TypeScript — workspace conventions and dependency rules
- Building a Cross-Platform Mobile App with Expo — the broader Expo DOM Components context
Try Storyie
If you want to see what falls out of all this on the user side, write a diary on the web at storyie.com and open it on the iOS app — same content, same formatting, same custom node types. The boundary is invisible from the outside, which is exactly the goal.