Skip to main content

Build a Lexical Markdown editor in React

Lexical is Meta's text editor framework. It gives you a document model, selection, undo history, keyboard handling and a React binding, but it isn't a Markdown editor. Everything between "the user pressed Ctrl and B" and "the file on disk says **bold**" is left for you to write.

This guide starts with the shortest working version, then goes through the hard parts and how @react-markdown-kit/editor handles them.

The short version​

npm install @react-markdown-kit/editor @react-markdown-kit/renderer
'use client'

import { useState } from 'react'
import { MarkdownEditor } from '@react-markdown-kit/editor'
import { defineMarkdownPreset, gfm } from '@react-markdown-kit/renderer'
import '@react-markdown-kit/editor/styles.css'

const appMarkdown = defineMarkdownPreset({ extensions: [gfm()] })

export function NotesEditor({ initial }: { initial: string }) {
const [value, setValue] = useState(initial)

return (
<MarkdownEditor
preset={appMarkdown}
value={value}
onChange={setValue}
aria-label="Notes"
/>
)
}

That's a Lexical editor. You don't write a LexicalComposer, an initialConfig, a node list or a transformer array, because the package handles all of that. value is a Markdown string and onChange gives you back a Markdown string.

Loading editor

The editor needs a DOM, so the component carries "use client". The renderer doesn't, which is why they're separate packages. Server rendering

Why Lexical​

We picked it for four reasons, and they're useful to know whether you use this package or build your own.

  • The model is separate from the DOM. Lexical keeps an immutable editor state and reconciles the DOM from it. A Markdown editor has to map its document onto that state, and that's a lot easier when the state isn't the DOM.
  • Custom nodes are well supported. Every Markdown construct that isn't plain text is a node class (heading, list, table, code block, image and the opaque block that holds syntax the editor doesn't model). Extensions can add their own.
  • It runs without a browser. @lexical/headless builds an editor with no DOM reconciliation. That's what makes the round trip testable, and it lets a migration script run in Node.
  • It's small enough to hide. The engine stays behind an adapter, so the public API can stick to Markdown strings and commands without exposing Lexical types.

What Lexical doesn't give you is Markdown. There's @lexical/markdown, which matches Markdown line by line with regular expressions. An audit of an editor built that way found 22 documents it changed on save, including reference links it never parsed at all and backslashes it doubled on every save (docs/AUDIT.md). This package doesn't use that approach at all.

What the kit puts around it​

The whole engine sits behind one module, packages/editor/src/bridge/session.ts. Everything above it works with Markdown strings and compiled documents, and everything below it works with Lexical nodes.

Parsing. compileMarkdown from the renderer parses with micromark into mdast. The editor imports that tree into Lexical nodes. One parser handles the whole document, so a table cell is just a subtree (there's no second parse with a different dialect).

The dialect is a preset. The same preset you render with configures the editor, so the editor writes what your pages read. Extensions are resolved on every render, so if you change the dialect at runtime the node set gets rebuilt (it isn't fixed at mount).

Serializing block by block. On save the writer lines up the edited blocks against an index of the original source built at import time, which records the byte span of every top-level block. An unchanged block is copied from those bytes, and only blocks you actually edited get serialized. That's why a setext heading stays setext and a ~~~ fence stays tildes.

Opaque nodes. Raw HTML, link definitions, footnote definitions and anything else the editing model can't hold keep their exact source slice, render as non-editable blocks, and are written back from the bytes they came in with. None of it gets turned into paragraph text.

Three modes over one document. rich, source and preview are views of the same document, so switching can't lose something one of them wasn't able to show. Preview uses @react-markdown-kit/renderer.

Put together, that gives you the main guarantee of the package. If you open a document, look around and close it, the file is unchanged, for 22 of 22 audited documents (packages/editor/tests/roundtrip.test.ts). The round-trip suite

Lexical stays out of your types​

Nothing on the root entry mentions Lexical. A test checks that src/index.ts and src/types.ts import nothing from lexical or @lexical/* (packages/editor/tests/styling.test.tsx). The exception is the opt-in @react-markdown-kit/editor/lexical entry, which is for extension authors.

If you really do need the engine:

const native = editor.getNativeEditor() // unknown

It returns unknown, so you cast it yourself and nothing leaks into your types by accident. It has weaker stability guarantees than the rest of the instance, since the editing engine may be replaced in a future major version without that counting as a breaking change elsewhere. If you find yourself using it a lot, please report it as a gap in commands.

Extension authors get a typed entry instead of a cast, @react-markdown-kit/editor/lexical, which types the editor capability (node classes, block and inline adapters, plugins and toolbar commands).

import { lexicalAdapter } from '@react-markdown-kit/editor/lexical'

const editorCapability = lexicalAdapter({
nodes: [DiagramNode],
blocks: [/* the plugin's diagram block adapter, built for its kind registry */],
commands: [/* one insert command per diagram kind */],
})

A block adapter's $export returns the original raw bytes while the block is untouched and null once it's been edited. That's how a plugin keeps the same round-trip guarantee as the core. Extensions

When to go headless​

There are three levels, and each one drops something the level above has.

1. The component​

<MarkdownEditor preset={appMarkdown} value={value} onChange={setValue} />

Use it when the default toolbar is fine for you. toolbar={false} removes it, and classNames replaces the rmk- class on any part of the chrome.

2. Headless React​

Use this when the editor has to live inside your own layout and design system, like a CMS, a chat composer or an admin panel.

'use client'

import {
useMarkdownEditor,
MarkdownEditorProvider,
MarkdownEditorContent,
useMarkdownEditorContext,
} from '@react-markdown-kit/editor'

function Toolbar() {
const editor = useMarkdownEditorContext()
const { commands } = editor
return (
<div>
<button type="button" onClick={() => commands.toggleMark('strong')}>Bold</button>
<button type="button" onClick={() => commands.setBlockType('heading2')}>Heading</button>
<button type="button" onClick={() => commands.insertMarkdown('\n| a | b |\n| - | - |\n')}>
Table
</button>
<button type="button" onClick={() => editor.setMode('source')}>Markdown</button>
</div>
)
}

export function Composer({ value, onChange }) {
const editor = useMarkdownEditor({ value, onChange, preset: appMarkdown })

return (
<MarkdownEditorProvider editor={editor}>
<Toolbar />
<MarkdownEditorContent aria-label="Message" />
</MarkdownEditorProvider>
)
}

<MarkdownEditor> is a thin component built from these same pieces, so the default chrome doesn't use anything you can't. If you want button highlighting without writing a selection listener, use the toolbar render prop on the component instead. Each item comes with active, disabled, label, icon, group and run(). The headless API

3. No React at all​

Use this when there's no browser, for example in a migration script, a content check in CI or a server job that normalizes documents.

import { createMarkdownBridge } from '@react-markdown-kit/editor'

const bridge = createMarkdownBridge({ preset: appMarkdown, headless: true })
bridge.load(source)
const saved = bridge.getMarkdown()

headless: true builds the editor with createHeadlessEditor, so there's no DOM reconciliation and no document. It's the same pipeline the React editor runs, which is why a round trip that passes here also holds in the browser.

Which level to pick​

You needUse
Editing, with a toolbar, today<MarkdownEditor>
Your own chrome, your own layoutuseMarkdownEditor and MarkdownEditorContent
Your own buttons but stock highlightingthe toolbar render prop
Markdown in, Markdown out, no browsercreateMarkdownBridge({ headless: true })
A new block type in the rich surfacean extension with a lexicalAdapter capability
Raw Lexicaleditor.getNativeEditor(), cast at your own risk

FAQ​

How do I build a Markdown editor with Lexical?

Lexical gives you a rich text surface, and the Markdown part is up to you. You need a parser that turns Markdown into editor nodes, a serializer that turns editor nodes back into Markdown, node classes for every construct you support, and a toolbar. @react-markdown-kit/editor has those four parts already written, behind a component whose value is a Markdown string.

Should I use @lexical/markdown?

Probably only for simple content. @lexical/markdown matches Markdown line by line with regular expressions, so anything it has no transformer for (reference links, for example) is never parsed, and it escapes text too eagerly, which can damage fences and Windows paths. This package parses with micromark into mdast instead.

Does using this editor put Lexical in my types?

No. Nothing the root entry exports needs a Lexical type, and a test checks that src/index.ts and src/types.ts import nothing from lexical or @lexical/* (packages/editor/tests/styling.test.tsx). The exception is the opt-in @react-markdown-kit/editor/lexical entry, which is meant for extension authors. The escape hatch, editor.getNativeEditor(), returns unknown, so you cast it yourself.

When should I go headless?

Go headless when you need your own chrome. useMarkdownEditor, MarkdownEditorProvider and MarkdownEditorContent keep the engine and drop the toolbar. If there is no browser at all, for example in a migration script or a test, go one level lower to createMarkdownBridge with headless true.

Try the editor demo · @react-markdown-kit/editor on npm · Source on GitHub

React Markdown editor · Lossless Markdown editing · Editor basics · Headless editing · Compared with MDXEditor · Compared with Milkdown