Building a Rich-Text Editor with Lexical in Next.js
Why Lexical
I spent a while choosing an editor for the note-taking app I'm rebuilding. The requirement seemed simple: rich text with headings and formatting, with collaborative editing somewhere down the road. The reality was that every option left me uneasy for a different reason.
TipTap and ProseMirror were the obvious first stop. TipTap wraps ProseMirror in a clean API, but it's opinionated in a way that started to chafe once I wanted behavior it didn't anticipate. Slate gave me pause on maintenance; the repo has had stretches where core issues sat unanswered. Quill is fine until you need to go past its extension model. And raw contentEditable? I've been down that road once. Undo, selection, caret placement across browsers, IME composition, it's a swamp, and I don't plan on going back.
What sold me on Lexical is that it treats the editor as a framework, not a widget. Facebook backs it and uses it at scale. It's React-first by design, which most editors treat as an afterthought. And it has a real plugin model: you hook into the editor's lifecycle the same way you hook into React. That matters when the editor is the most customized piece of your app.
The selection API sealed it. I knew collaboration was on the roadmap, and Lexical's node-based selection model is built for that. On top of it all, the package is tree-shakeable, so I didn't have to ship a kitchen-sink editor to get one feature.
Setting Up
The setup is unremarkable until it isn't. LexicalComposer wraps the editor and takes an initialConfig. Because the editor touches browser APIs, the component that mounts it has to be a client component. "use client" at the top, non-negotiable.
The theme config maps Lexical's editor state to CSS classes. I used Tailwind classes directly: h1, h2, h3, paragraph, and formatting nodes all map to their utility classes. Custom nodes like HeadingNode come from @lexical/rich-text. HistoryPlugin gives undo and redo, RichTextPlugin provides the editing surface, and a ContentEditable plus a placeholder round it out.
The gotcha that cost me an hour: initialConfig.editorState has to be a serialized JSON string, not a parsed object. I passed JSON.parse(savedState) because it felt right, and the editor silently rendered nothing. No error, no warning, just an empty page. The state needs to be the raw string so Lexical can hydrate it internally. That's the kind of bug that makes you wonder whether the framework hates you.
The Auto-Sync Plugin
The auto-save behavior is where I learned the most. It's a custom Lexical plugin built on useLexicalComposerContext, and it registers update listeners on the editor.
The Debounce
The flow goes like this. Every time the editor updates, the plugin compares the root nodes against the previous state using $getRoot(). If nothing meaningfully changed, it bails early. When there's a real change, it clears any pending timeout and starts a new one for one second. Keep typing and the timeout keeps resetting. Only when you go idle for a full second does it call syncNote().
The debounce itself is the classic pattern: clearTimeout on new input, setTimeout on idle. Nothing clever there. The actual debugging came from the initial load.
The Mount Bug
On mount, the editor fires an update event immediately. The diff between the freshly-hydrated state and empty is, predictably, "different." So without a guard, every page load triggered a spurious save. The fix was checking prevEditorState.isEmpty() before comparing. If the previous state is empty, it's initialization noise, not user input, so skip it.
Create vs Update
The sync has two paths. A note without an ID is a create: POST it into the currently-selected folder. A note with an ID is an update: PATCH it with an optimistic SWR mutation, so the UI reflects the change before the server confirms, and rolls back if the request fails.
One more detail: the note's description field auto-extracts the first two non-empty lines of editor content as a preview. That runs alongside the state sync, so the note list always shows something useful even if all you typed was a heading.
The backend stores the whole editor state as a JSON string in MongoDB. It works, but it made me uncomfortable in a way that shaped the next section.
What I'd Do Differently
Markdown over JSON
First, I'd serialize as Markdown instead of Lexical's JSON. That format is coupled to Lexical's internal node structure. A version upgrade could quietly break old documents, and the payload is useless outside the editor. Markdown survives framework changes, and it's diffable, searchable, and human-readable.
Batched Syncs
Second, I'd batch syncs. Right now every idle event fires a request. In a rapid editing session that's a burst of API calls. A queue that coalesces changes and flushes after a pause would cut that down considerably.
Conflict Resolution
Third, and most important, conflict resolution. It's last-write-wins today, which is fine on a single device and wrong the moment you add a second one. A timestamp comparison is the cheap fix. A CRDT is the honest answer, and it's on my roadmap.
The uncomfortable part is that I already know the CRDT answer. I wrote about wanting offline-first collaboration and CRDTs in a previous post, and here I am shipping last-write-wins. It's a good reminder that the right architecture only feels obvious in hindsight, after you've paid for the shortcuts.
Thanks for reading. More soon.