Skip to main content

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.

MilkdownReact Markdown Kit editor
Editing engineProseMirrorLexical
Markdown parserremark, with mdast as the document sourcemicromark into mdast, shared with the renderer
Value in and outMarkdown stringMarkdown string
Out of the boxHeadless framework; @milkdown/crepe is the prebuilt editorA component with a toolbar, plus a headless API
ModesRich editing; a source view is built from pluginsrich, source and preview in the component
Round tripNo published byte-identity corpus found22 of 22 byte-identical (test)
CollaborationYes, a Yjs pluginNone
Gzipped JS for the entry104.9 KB (7.22.1)110.3 KB (0.1.0)
Server renderingClient componentClient component; the renderer is a separate package that server-renders
LicenceMITMIT

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.

Loading editor

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:

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.

EntryVersionMinifiedGzipped
@milkdown/react + @milkdown/preset-commonmark7.22.1346.2 KB104.9 KB
@react-markdown-kit/editor0.1.0349.7 KB110.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/markdown and 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.ts and src/types.ts import nothing from lexical or @lexical/* (test). The opt-in @react-markdown-kit/editor/lexical entry is the exception, for extension authors. The one escape hatch, editor.getNativeEditor(), returns unknown.

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:

  1. Collaboration. If you use the Yjs plugin, stop here. There is no equivalent.
  2. 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
  3. 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
  4. 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