React Markdown Kit editor vs Milkdown
Milkdown is a plugin-driven WYSIWYG Markdown editor framework
built on ProseMirror and remark, MIT licensed, headless by design, with 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.
The short version: Milkdown is a framework you assemble, with collaborative editing available; this kit is a component you drop in, with the round trip proved 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 present until you add it, which is the
point: the same core drives very different editors, and @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. <MarkdownEditor value onChange /> is the whole
integration, and what varies is the dialect, expressed 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} />
The trade is assembly against agreement. Milkdown gives you more room to build an editor
that looks like nothing else. This kit gives you one parser and one preset for the editor
and the published page, so the preview cannot disagree with production: preview delegates
to @react-markdown-kit/renderer rather than to a second renderer.
Output format
Both store Markdown strings, and neither asks your application to persist editor JSON. That is the main thing the two have in common and the reason a migration is feasible at all.
Under the surface the representations differ. 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. That extra retention is what the next section is about.
Constructs neither editing model can represent are handled differently too. Here they become opaque nodes: 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. Nothing degrades into paragraph text. Round-trip reference
Round trip
Open a document, switch modes, close it without typing. The file must be identical.
Because unchanged blocks are copied from the original source rather than re-serialized,
a setext heading stays setext, a ~~~ fence stays tildes, _emphasis_ does not 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 and usually a different one. That is fine when the editor owns the content. It is a problem when the content is files in a repository that humans also edit by hand.
We found no published byte-identity corpus for Milkdown, so the table says "not found",
not "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 wins this row by 5.4 KB gzipped, and the comparison is not 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: 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: 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.
What differs is the blast radius. 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 is the pipeline the React editor runs, and it is how
the round trip is tested. Use it in a migration script or a content check in CI.
Lexical versus ProseMirror
ProseMirror is the older and deeper of the two. Its schema, transforms and plugin model are the reason Milkdown can be a framework rather than a component, and the reason collaborative editing over Yjs is available there and not here.
Lexical is what this kit is built on, and the kit treats it as an implementation detail:
- Markdown is not parsed by the engine. Parsing is micromark into mdast, the same
pipeline the renderer uses.
@lexical/markdownand its line-oriented transformer protocol are not used anywhere in the package. That protocol is the root cause listed in the audit, and avoiding it is what makes byte identity reachable. - Lexical is not in 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 the engine choice is reversible here and load-bearing there. Neither is better in the abstract; it 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 is 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
classNames, rather than a theme package. Styling - Listener to
onChange. The value is a Markdown string on every edit. Debounce persistence outside the editor. Editor basics
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 are building an editor product, not 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 must not 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 to be a test you can run, not a promise.
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 is built on Lexical rather than ProseMirror, it ships a default toolbar, and it asserts 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 has no collaboration support. If several people must type in the same document at the same time, that is a reason to choose 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 asserted byte-identical in packages/editor/tests/roundtrip.test.ts. Milkdown serializes the ProseMirror document through remark, so the output is the serializer 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