React Markdown Kit editor vs Milkdown
Milkdown is a plugin-driven WYSIWYG Markdown editor framework
built on ProseMirror and remark. It's MIT licensed and headless by design, and has a React
binding in @milkdown/react. This page compares it with @react-markdown-kit/editor, which is
built on Lexical and ships a default interface.
Roughly speaking, Milkdown is a framework you put together yourself and it supports collaborative editing. This kit is a component you add to a page, and its round trip is covered by a test.
Facts about Milkdown come from its documentation and its repository. Facts about this editor link to the code or the test that backs them.
| Milkdown | React Markdown Kit editor | |
|---|---|---|
| Editing engine | ProseMirror | Lexical |
| Markdown parser | remark, with mdast as the document source | micromark into mdast, shared with the renderer |
| Value in and out | Markdown string | Markdown string |
| Out of the box | Headless framework; @milkdown/crepe is the prebuilt editor | A component with a toolbar, plus a headless API |
| Modes | Rich editing; a source view is built from plugins | rich, source and preview in the component |
| Round trip | No published byte-identity corpus found | 22 of 22 byte-identical (test) |
| Collaboration | Yes, a Yjs plugin | None |
| Gzipped JS for the entry | 104.9 KB (7.22.1) | 110.3 KB (0.1.0) |
| Server rendering | Client component | Client component; the renderer is a separate package that server-renders |
| Licence | MIT | MIT |
Editing model
Milkdown is a framework. You create an editor, configure a root element, add a preset
such as @milkdown/preset-commonmark, add plugins for anything else, and mount it with
MilkdownProvider and useEditor. Nothing is there until you add it, which is
intentional, since the same core drives very different editors (@milkdown/crepe is the
ready-made one built on top).
import { Milkdown, MilkdownProvider, useEditor } from '@milkdown/react'
import { commonmark } from '@milkdown/preset-commonmark'
React Markdown Kit is a component. You add <MarkdownEditor value onChange /> and that's
most of the integration. What you configure is the dialect, as a preset shared with the
renderer.
import { MarkdownEditor } from '@react-markdown-kit/editor'
import { defineMarkdownPreset, gfm } from '@react-markdown-kit/renderer'
const appMarkdown = defineMarkdownPreset({ extensions: [gfm()] })
<MarkdownEditor preset={appMarkdown} value={value} onChange={setValue} />
So it's a tradeoff. Milkdown gives you more room to build a very custom editor. This kit
uses one parser and one preset for both the editor and the published page, so the preview
can't disagree with production (preview uses @react-markdown-kit/renderer instead of a
second renderer).
Output format
Both store Markdown strings, and neither asks your application to persist editor JSON. That's the main thing they have in common, and it's why migrating between them is feasible at all.
Internally the representations are different. Milkdown holds a ProseMirror document and serializes it through remark on the way out. This kit holds mdast, the same tree the renderer compiles, plus the original source text and the byte span of every top-level block. The next section covers why it keeps all that.
They also handle constructs the editing model can't represent differently. Here they become opaque nodes, so a raw HTML block, a link definition, a footnote definition or a third-party extension's node keeps its exact source slice, renders as a non-editable block, and is written back from the bytes it arrived with. None of it gets turned into paragraph text. Round-trip reference
Round trip
If you open a document, switch modes and close it without typing, the file should be identical.
Because unchanged blocks are copied from the original source instead of re-serialized,
a setext heading stays setext, a ~~~ fence stays tildes, _emphasis_ doesn't become
*emphasis*, and four blank lines stay four blank lines.
The corpus is 22 documents that a line-oriented editor damaged on save, from
docs/AUDIT.md:
- Editor layer, bytes identical: 22 of 22, with GFM on and with GFM off
(
packages/editor/tests/roundtrip.test.ts). - Serializer layer, meaning unchanged and idempotent: 22 of 22; bytes identical
14 of 22, because that path canonicalizes on purpose
(
tests/roundtrip.test.ts).
Any editor that serializes the whole document on save produces its serializer's canonical form, which is a correct document but usually a different one. That's fine when the editor owns the content. It's a problem when the content is files in a repository that people also edit by hand.
We couldn't find a published byte-identity corpus for Milkdown, so the table says "not
found" (we're not saying it fails). The fixture is plain JSON with a cases array of { name, source, why },
so you can run it against any editor that imports and exports a Markdown string.
How to run the suite on your own documents
Bundle
Measured by scripts/compare-bundles.mjs
and stored in docs/data/bundle-sizes.json,
on 2026-09-20. Each row is the whole import closure of the recorded entry,
bundled with esbuild 0.27.7, minified, tree-shaken, with React and React DOM
external. 1 KB is 1024 bytes.
| Entry | Version | Minified | Gzipped |
|---|---|---|---|
| @milkdown/react + @milkdown/preset-commonmark | 7.22.1 | 346.2 KB | 104.9 KB |
| @react-markdown-kit/editor | 0.1.0 | 349.7 KB | 110.3 KB |
Milkdown is 5.4 KB smaller gzipped, though the comparison isn't quite like for like in
either direction. The Milkdown entry is the React binding with the CommonMark
preset and no toolbar, no theme and no further plugins; adding them adds bytes. The kit's
entry includes @react-markdown-kit/renderer, the toolbar and all three modes, because
the editor imports them. Neither row includes CSS, since the measured entries import no
stylesheet, and the generator records CSS separately when a bundle emits any.
For a page that only displays Markdown, neither number applies. That page imports the renderer, which is 36.8 KB gzipped for CommonMark, 48.5 KB with the GFM preset. The renderer
Server rendering
Neither rich surface server-renders. ProseMirror and Lexical both need a DOM for selection, undo history and keyboard handling, so both components are client components.
The difference is how much a server page has to import. Here the renderer and the editor are separate packages
with separate installs, so a server-rendered document page imports
@react-markdown-kit/renderer, needs no "use client" boundary, and pulls no Lexical
into the server bundle. Server rendering
The editor also has a Node path with no DOM.
import { createMarkdownBridge } from '@react-markdown-kit/editor'
const bridge = createMarkdownBridge({ preset: appMarkdown, headless: true })
bridge.load(source)
const saved = bridge.getMarkdown()
It runs on @lexical/headless, it's the same pipeline the React editor runs, and the
round-trip tests use it. You can use it in a migration script or a content check in CI.
Lexical versus ProseMirror
ProseMirror is the older and more mature of the two. Its schema, transforms and plugin model are why Milkdown can be a framework instead of a component, and why collaborative editing over Yjs is available there and not here.
This kit is built on Lexical, and treats it as an implementation detail.
- The engine doesn't parse Markdown. Parsing is micromark into mdast, the same
pipeline the renderer uses.
@lexical/markdownand its line-oriented transformer protocol aren't used anywhere in the package. That protocol is the root cause listed in the audit, and avoiding it is what makes byte identity possible. - Lexical stays out of your types. Nothing the root entry exports requires a Lexical
type, and a test asserts that
src/index.tsandsrc/types.tsimport nothing fromlexicalor@lexical/*(test). The opt-in@react-markdown-kit/editor/lexicalentry is the exception, for extension authors. The one escape hatch,editor.getNativeEditor(), returnsunknown.
So here the engine could be swapped out later, while in Milkdown it's built into the design. Neither is better in general. It mostly decides what you can extend and what you can replace. How the kit wraps Lexical
Migration
// before
import { Milkdown, MilkdownProvider, useEditor } from '@milkdown/react'
import { commonmark } from '@milkdown/preset-commonmark'
import { Editor, rootCtx, defaultValueCtx } from '@milkdown/core'
function Notes({ value }) {
useEditor((root) =>
Editor.make()
.config((ctx) => {
ctx.set(rootCtx, root)
ctx.set(defaultValueCtx, value)
})
.use(commonmark),
)
return <Milkdown />
}
// after
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()] })
function Notes({ value, onChange }) {
return <MarkdownEditor preset={appMarkdown} value={value} onChange={onChange} />
}
Four things to check before you commit:
- Collaboration. If you use the Yjs plugin, stop here. There's no equivalent.
- Plugins to preset. Milkdown plugins have no one-to-one mapping. Decide the
dialect instead: CommonMark, or CommonMark plus
gfm(), plus any extension you also render with. Presets - Theme to class names. The stylesheet here is optional and the chrome takes your
own class names through
classNamesinstead of a theme package. Styling - Listener to
onChange. The value is a Markdown string on every edit. Debounce persistence outside the editor. Editor basics
I'd run the round-trip script over your content directory first, with the preset you intend to ship. The script
Who should stay with Milkdown
- You need collaborative editing.
- You're building an editor product (as opposed to adding editing to an application) and you want ProseMirror's schema and plugin model underneath it.
- You have already built Milkdown plugins or a Milkdown theme.
- The smaller entry bundle matters more than the round-trip guarantee.
Who should try this one
- Your Markdown lives in files or in a column that humans also edit by hand, and a save shouldn't rewrite what nobody touched.
- You already render Markdown with React and want the editor to write that exact dialect.
- You want a working editor in one component, with the headless API available when the default chrome stops fitting.
- You want the round trip backed by a test you can run yourself.
FAQ
Is there an alternative to Milkdown for React?
Yes. @react-markdown-kit/editor is a rich, source and preview Markdown editor for React. It's built on Lexical instead of ProseMirror, ships a default toolbar, and has tests that check byte identity for 22 audited documents opened and saved without an edit.
Is Milkdown or React Markdown Kit smaller?
Milkdown is smaller at the entry measured here: @milkdown/react with the CommonMark preset is 104.9 KB gzipped against 110.3 KB for @react-markdown-kit/editor, a difference of 5.4 KB, with React external. Both rows come from scripts/compare-bundles.mjs.
Does either editor do collaborative editing?
Milkdown does, through its collaborative plugin built on Yjs. React Markdown Kit doesn't support collaboration. If several people need to type in the same document at the same time, go with Milkdown.
Which one keeps my Markdown files unchanged?
React Markdown Kit writes unchanged blocks back from the original source bytes, and 22 of 22 audit documents are checked for byte identity in packages/editor/tests/roundtrip.test.ts. Milkdown serializes the ProseMirror document through remark, so what you get back is the serializer's canonical form.
Next
Try the editor demo · @react-markdown-kit/editor on npm · @milkdown/react on npm · Source on GitHub
React Markdown editor · Lossless Markdown editing · Compared with MDXEditor · Lexical Markdown editor guide