Back to Blog

Stop shipping ProseMirror to people who are only reading

Mounting a read-only rich text editor to display an article sends an editing engine to every reader. Render the stored JSON on the server instead — in Blockleaf that is 1.87 kB against 145 kB.

Most rich text content is written once and read thousands of times. Most rich text integrations ship the same JavaScript to both audiences.

The pattern is so common it looks like good practice:

// the article page
<Editor
	value={article.body}
	editable={false}
/>

One component for writing and reading, no duplicated rendering logic, no second implementation to keep in sync. It is a reasonable instinct and it is expensive.

What editable={false} actually ships

It is not a display mode. It is the whole editor, mounted, with input turned off. Every reader of that page downloads, parses and executes:

  • ProseMirror's model, state, view and transform packages
  • The schema, every extension, every input rule and keyboard shortcut
  • Your slash menu, toolbar and drag handles, bundled whether or not they can ever appear
  • The React bindings that glue it together

Then the browser builds a contenteditable, installs document-level event listeners, and runs a reconciliation loop for a document nobody will edit.

For Blockleaf that is about 145 kB brotlied, and it is not meaningfully different for any ProseMirror, Lexical or Slate editor — the engine is the engine.

What reading actually needs

A walk over a tree, emitting elements:

// a server component
import { RenderDocument } from '@blockleaf/renderer/react';

<div className="blockleaf-editor">
	<div className="blockleaf-editor__content">
		<RenderDocument doc={article.body} />
	</div>
</div>;

That is 1.87 kB, it runs on the server, and it ships no client JavaScript at all. The HTML-string export is 1.97 kB and does not even import React:

import { renderToHTML } from '@blockleaf/renderer/html';

const html = renderToHTML(article.body);

Both numbers are measured in CI against a hard budget. The build fails when they grow.

Why it has to be a separate package

A subpath export would not have worked. Tree-shaking across a package that also contains ProseMirror depends on every link in the chain declaring sideEffects: false, and ProseMirror does not. One import of the wrong module drags the editing engine back in, silently, in a bundle you will not look at again.

A separate package cannot make that mistake. @blockleaf/renderer does not depend on @blockleaf/editor; it does not depend on ProseMirror or Tiptap at all. It takes JSON and returns elements.

Keeping the two in step

The usual objection to a second renderer is that it drifts: the editor adds a node, the renderer shows nothing, and someone finds out in production.

The answer is a parity test rather than discipline. The same document goes through both packages and the HTML is compared, attribute for attribute, including data-id on every block and data-type on a callout. It is not a convention that the editor and renderer agree. It is a test that fails.

That is also why one stylesheet covers both. The markup is the same markup.

When you do want the editor mounted

Read-only is still the right answer sometimes, and the line is about whether the page can become an editing page:

SituationUse
A page that only displays content@blockleaf/renderer
A preview that toggles back into editingeditable={false}
An email, an RSS item, a cached fragmentrenderToHTML
A comment thread rendered under a formThe renderer for the thread, the editor for the form

The last row is the one people get wrong most often: a page with one editor on it does not need twenty.

Unknown nodes, three years later

The renderer renders an unknown node's children and drops an unknown mark's decoration rather than throwing. Documents outlive the code that wrote them, and a reader built today will meet a document written by a version of your application that no longer exists. Degrading beats a stack trace on an article page.

If you add a node of your own, hand the renderer a component for it — the override map is a patch, not a full mapping:

<RenderDocument
	doc={doc}
	components={{ embed: ({ node }) => <Embed url={node.attrs?.url} /> }}
/>

Further reading