Getting started
The editor's model, how to save and load a document properly, and the props you will actually reach for.
Quick start gets an editor on the screen. This page is the next hour: how the component thinks, how to save without writing on every keystroke, how to load a document back, and which props matter.
Two packages, two jobs
| Package | Use it for | Ships |
|---|---|---|
@blockleaf/editor | Writing | The editor, the chrome, the schema |
@blockleaf/renderer | Reading | JSON to React or an HTML string, no ProseMirror |
A page that only displays a document should never import the editor. That separation is the whole reason the renderer exists ā see server rendering.
The editor is uncontrolled
defaultValue is read once, when the editor mounts. Changing it later does
nothing.
// This does NOT swap the document.
<BlockleafEditor defaultValue={selectedDoc} />That is deliberate, not an omission. A controlled ProseMirror fights its own
transactions: every keystroke produces a transaction, re-rendering with a new
value replaces the document underneath the caret, and the undo stack dies
with it. Every editor on ProseMirror that offers a value prop is either
doing something clever and fragile, or quietly doing this.
To swap documents, remount:
<BlockleafEditor
key={docId}
defaultValue={doc}
/>Or drive the instance directly with useBlockleafEditor, below.
Saving
There are two change callbacks, and picking the wrong one is the most common mistake:
<BlockleafEditor
defaultValue={doc}
onChange={(next) => save(next)}
onChangeImmediate={() => setDirty(true)}
/>onChangeis debounced ā 300 ms by default,debounceMsto change it. This is what you persist.onChangeImmediatefires on every transaction. This is for a dirty flag, an autosave indicator, a live preview or a word count.
Saving from onChangeImmediate means a network write per character. Driving
a live preview from onChange means a preview that lags a third of a second
behind the caret. Each callback is cheap for its own job and expensive for
the other one.
What arrives
Both callbacks receive a BlockleafDocument ā plain JSON, with
attrs.schemaVersion already stamped on it. There is nothing to serialise
and nothing to clean up before it goes into a column:
await db.article.update({
where: { id },
data: { body: doc } // a jsonb column
});Loading
Read the JSON back and hand it to defaultValue. The only thing worth
checking first is that it is a Blockleaf document at all:
import { createEmptyDocument, isBlockleafDocument } from '@blockleaf/editor';
const doc = isBlockleafDocument(article.body)
? article.body
: createEmptyDocument();isBlockleafDocument is a structural guard ā it answers "did this come from
Blockleaf?", not "is every node valid?". It is cheap enough to call on every
load, and it imports without pulling the editor in, so a server route can use
it too.
Read-only
editable={false} keeps the editor mounted and turns off input. It is the
right choice for a preview that toggles back into editing, and the wrong
choice for a page that only ever displays content ā that page should use
the renderer and ship no editor at all.
<BlockleafEditor
defaultValue={doc}
editable={isEditing}
/>Presets
preset chooses the extension set:
standard(the default) ā everything in the schema: headings, lists, task lists, tables, callouts, images, code blocks, blockquotes, links, highlight.minimalā paragraphs and the inline marks only: bold, italic, underline, strike, inline code and links. No headings, lists, quotes, code blocks, rules, tables or images. For comment boxes and short-form fields where a table would be absurd.
<BlockleafEditor preset="minimal" />Passing your own extensions array replaces the preset entirely. See
extensions for adding to one instead.
The props you will reach for
| Prop | Type | Default | What it does |
|---|---|---|---|
defaultValue | BlockleafDocument | empty document | Initial content; read once |
onChange | (doc) => void | ā | Debounced. Persist from here |
onChangeImmediate | (doc) => void | ā | Every transaction. Dirty flags and previews |
debounceMs | number | 300 | How long onChange waits |
preset | 'standard' | 'minimal' | 'standard' | Which extension set |
extensions | Extensions | ā | Replaces the preset entirely |
editable | boolean | true | Turns input off without unmounting |
autofocus | boolean | false | Place the caret on mount |
label | string | 'Document' | Accessible name for the editing surface |
placeholder | string | object | false | built-in | Empty-block hint |
className | string | ā | Added alongside blockleaf-editor |
fallback | ReactNode | null | Rendered before the editor is ready |
Upload props are documented in uploads; every chrome prop ā
slashItems, bubbleActions, blockActions, tableActions and the rest ā
in the chrome.
Give it a label
A contenteditable is an ARIA textbox, and an unnamed one is announced as
nothing at all. label defaults to Document; set it to whatever the
user thinks they are editing.
Next.js
The editor is a client component. Three things are already handled for you, and one is not:
'use client'goes at the top of the file that rendersBlockleafEditor, as with any interactive component.- Hydration ā the editor does not render until it is on the client, which is the single most common Tiptap and SSR mismatch. Nothing to configure.
- The stylesheet can be imported from a server component, a client component or your root layout. It is plain CSS.
fallbackis yours to set. Until the editor is ready the component rendersnull, which means the page is a little shorter for a frame. Pass a skeleton of roughly the right height if that shift bothers you.
<BlockleafEditor
fallback={<div style={{ minHeight: '24rem' }} />}
defaultValue={doc}
onChange={save}
/>Dropping to the hook
BlockleafEditor is a thin component over useBlockleafEditor. When you want
the Tiptap instance ā to run commands, to add a toolbar of your own, to call
setContent ā use the hook and render the chrome yourself:
'use client';
import { useBlockleafEditor } from '@blockleaf/editor';
import { EditorContent } from '@tiptap/react';
export const Editor = ({ doc }: { doc: BlockleafDocument }) => {
const editor = useBlockleafEditor({ defaultValue: doc });
if (!editor) return null;
return (
<div className="blockleaf-editor">
<button onClick={() => editor.commands.toggleBold()}>Bold</button>
<EditorContent editor={editor} />
</div>
);
};The hook returns a Tiptap Editor or null. It takes the same options as
the component minus the chrome flags, so the default menus are not included
ā add them from the chrome, or build your own.