Original research

MDX vs rich-text JSON, measured

One fixed article, authored identically as MDX, plain Markdown, Portable Text, Lexical, and Slate, then rendered to static HTML on the same machine. Structural measures, not opinions — payload size, render time, renderer code required, dependency surface, and how often each format has broken compatibility in the last two years. Last verified 2026-08-22.

MetricMDX (compile per request)MDX (precompiled)Markdown (parse per request)Markdown (cached output)Portable TextLexicalSlate
Package@mdx-js/mdx@3.1.1@mdx-js/mdx@3.1.1react-markdown@10.1.0react-markdown@10.1.0@portabletext/react@8.0.1lexical@0.49.0slate@0.126.2
Stored payload664 B664 B664 B664 B2,254 B3,765 B1,282 B
Rendered HTML840 B840 B840 B840 B797 B1,642 B771 B
Avg render time0.73 ms0.20 ms0.50 ms0.00 ms0.19 ms0.35 ms0.02 ms
p95 render time0.86 ms0.24 ms0.74 ms0.00 ms0.35 ms0.52 ms0.04 ms
Renderer LOC3211261
Node types mapped00001178
Deps to render1110171
Deps to author0000051
Major lines touched (24mo)112261 (pre-1.0)1 (pre-1.0)
Releases (24mo)22662053269

Machine-readable version: /research/mdx-vs-rich-text-json/data

Methodology

The same ~180-word article — one heading, bold and italic text, an unordered and an ordered list, a blockquote, a code block, and two links — was authored once per format: as raw MDX source, as plain Markdown (the same source — it had no JSX in it), as a Portable Text block array, as a Lexical headless editor state, and as a Slate value. Each was rendered to static HTML with React's renderToStaticMarkup, on Node 24 and React 19, 200 timed runs after 20 warmup runs.

Both MDX and Markdown are measured twice, same split for each: “per request” re-parses (Markdown) or recompiles (MDX) the source on every call — the naive path if you store source text and render it straight from the database. “Precompiled”/“cached output” does that work once at build or publish time, so a request only runs the cheap final step — the path Draftbase's own MDX components ship on. Portable Text, Lexical, and Slate don't get a second measurement because they're stored pre-parsed already; there's no naive path to compare against. MDX's cached path still runs its compiled function on every request (~0.2ms) because the output can embed live React components and can't always collapse to a static string. Markdown, rendered through the single-package react-markdown, has no such escape hatch — it fully collapses to static HTML, so caching the rendered string means a request touches zero format-specific packages and costs a rounding error (~0.0001ms, a Map lookup). That is a genuinely different caching shape from the old unified/remark/rehype pipeline, which exposed a parsed tree you could cache and still walk per request — a single all-in-one package trades that partial-caching option away for having one dependency instead of four.

Renderer LOC counts the actual render function written for each format, imports and blank lines excluded. No format in this table renders through a hand-rolled node-type switch — every row uses the real, most-widely-used package for that format, at the fewest packages that fully solve rendering: MDX (@mdx-js/mdx), Markdown (react-markdown), Portable Text (@portabletext/react), and Slate (@slate-serializers/html, serializing through Payload CMS's own shipped production config, unmodified) each do it in one package. Lexical is the outlier at 7: its HTML export, @lexical/html, calls document.createElement directly, so outside a browser it needs a real DOM (jsdom) — and each node family it walks (heading/quote, list, code, link) ships as its own separate package by design, since Lexical is built to be tree-shaken down to only the node types a given app registers.

Deps to author counts what it actually took to construct valid content for this bench, checked against the fixture files rather than assumed. MDX, Markdown, and Portable Text were all hand-authored as plain text or JS literals with zero format-specific imports — verified for Portable Text by running the real render path, which preserved hand-authored links with no extra config. Slate looks the same on paper — a plain tagged-object array — but isn't: running the same hand-authored value through Slate's own Editor.normalize() silently deleted a custom link element, merging it into plain text, unless the editor's isInline is explicitly configured to recognize it. Confirmed by running both the broken and fixed versions against the real slate package — so Slate's deps to author is 1, not 0. Lexical is the clearer outlier: its state can't be hand-built and stay valid at all — even the headless path is just lexical's own createEditor plus one package per node type used (heading/quote, list, code, link), 5 in total for this fixture. Neither column is about a WYSIWYG editing UI for end users — that's a different question, covered below.

Upgrade-break surface comes from npm's registry time field for each package: the count of distinct semver-major lines touched by any release in the trailing 24 months, queried 2026-08-22. Lexical and Slate are both still pre-1.0, so semver major never moves — a 0.x minor bump can break compatibility by spec and this metric can't see it. Raw release counts are included alongside so that caveat is visible, not hidden. Full renderer source for every format is in the raw dataset below.

Does the gap hold at scale?

Same structural unit — heading, two paragraphs, bullet list, numbered list, blockquote, code block, a paragraph with two links — repeated 1×, 4×, and 10× per format, so length scales while the node-type mix per unit stays fixed.

FormatShort (1×)Medium (4×)Long (10×)
MDX (compile per request)0.73 ms1.84 ms4.39 ms
MDX (precompiled)0.20 ms+0.81 ms compile0.39 ms+1.76 ms compile0.77 ms+4.25 ms compile
Markdown0.50 ms1.28 ms3.02 ms
Portable Text0.19 ms0.24 ms0.57 ms
Slate0.02 ms0.06 ms0.11 ms
Lexical0.35 ms1.06 ms2.52 ms

Both per-request paths scale badly — MDX worst, Markdown close behind — because parse/compile cost grows with content length and dominates the total either way. MDX (precompiled) and the three JSON formats stay sub-millisecond even at 10× length, since none of them re-parse anything per request. Markdown's cached-output number isn't in this table at all — once you cache the fully rendered HTML string, it's a constant-time lookup that doesn't scale with node count, see the main table above. The one path here that scales linearly and can't be cached away is the one nobody should be running per request in the first place.

Embedding a custom React component

The same identical component — a small pricing callout — dropped mid-article in each format. Only the shared component itself is excluded; everything below is the code needed to get it onto the page.

FormatWiring LOCNeeds a node classNotes
MarkdownNot supportedNo mechanism exists in the spec to embed a React component. Getting one in means inventing custom syntax and writing a remark/rehype plugin to parse it — at that point you have reinvented part of MDX.
MDX1One entry in the `components` map passed to `run()`. Content authors the JSX tag directly — no data-shape decision to make.
Portable Text2One `types` map entry plus the tagged block in the stored array — same pattern as any other custom type.
Slate2One `elementTransforms` entry in `@slate-serializers/html`'s config, returning a `domhandler` `Element` — that's a second direct import, not a transitive one, since the transform has no other way to hand back a DOM node — plus a plain void node in the stored value. No class or registration to read it back.
Lexical43A registered Node subclass is mandatory — plain objects are rejected by the editor. ~40 lines for the minimum class, plus registration, creation, and a render case. DecoratorNode defaults to inline; block placement needs an explicit isInline() override.

Plain Markdown can't do this at all — there's no mechanism in the spec for embedding a component, custom syntax and a parser plugin would be needed, which is most of the way to reinventing MDX. MDX handles it natively — the content author writes the JSX tag directly, and the render call maps it to the component through the same components prop used to override any built-in element. Portable Text and Slate both cost one extra config map entry, no class or registration required — Slate's does need a direct domhandler import to construct the returned element, since that's the only shape its serializer accepts back. Lexical is the outlier: a custom block must be an instance of a registered Node subclass, not a plain tagged object, so the minimum viable class alone runs ~40 lines before any render code — and building it surfaced a real gotcha along the way: DecoratorNode defaults to inline, so a block-level custom element silently gets wrapped in an implicit <p> unless you override isInline().

What real editors actually run on

The storage format and the editing engine are not the same thing. Each row names the most-used editor for that format and what it's actually built on, sourced from that editor's own docs.

Markdownreact-md-editor@uiw/react-md-editor

Plain Markdown's most common editor isn't a structured rich-text editor at all — it's a text area with a live-rendered preview pane. No AST-aware editing UI is needed because the storage format is the editing format.

npmjs.com — @uiw/react-md-editor
MDXMDXEditor@mdxeditor/editor

MDXEditor's editing engine is Lexical, not MDX — it converts bidirectionally between a Markdown AST and the Lexical AST, and only serializes to MDX text on save.

mdxeditor.dev — Overview
Portable TextPortable Text Editor@portabletext/editor

Sanity extracted its own Studio editor into a standalone package purpose-built for Portable Text — the only format here with an editor designed around its storage format rather than adapted to it.

sanity.io — Portable Text Editor configuration
LexicalPayload CMS@payloadcms/richtext-lexical

Payload switched its default rich-text editor to Lexical and has deprecated its previous Slate-based editor, removing it entirely in v4.0 — a production CMS abandoning Slate for Lexical, not a lab comparison.

payloadcms.com — Rich Text Editor overview
SlatePlate@udecode/plate

Plate — the most active Slate-based editor framework — is built directly on top of Slate's primitives, layering plugins and state management on top rather than replacing them.

plate.udecode.io — Introduction