In-Memory Versioning
This example shows how to use InMemoryVersioningExtension without a collaboration layer. It seeds history with ProseMirror document JSON, which the extension converts to immutable documents using the editor's schema. initialVersions also accepts BlockNote JSON as arrays of partial blocks.
The sidebar opens on a document with a few versions already in its history, including an automatic unnamed version, so you can preview them, compare them, rename them, restore them, and try the named-only filter right away. The editor is read-only while the sidebar is open: close it to edit the document, then reopen it with the "History" button.
import { RenderInPortalElement, useCreateBlockNote } from "@blocknote/react";import "@blocknote/core/fonts/inter.css";import { InMemoryVersioningExtension, type LocalVersioningOptions, type VersioningController,} from "@blocknote/core/extensions";import { DiffVersioningExtension } from "@blocknote/core/y";import { DefaultVersionMenuItems, useVersionSnapshot, VersionMenu, VersionMenuItem, VersioningSidebar,} from "@blocknote/react/versioning";import { RiFileCopyLine } from "react-icons/ri";import { BlockNoteView } from "@blocknote/mantine";import "@blocknote/mantine/style.css";import { useState } from "react";import { DAY_MS, LIVE_DOCUMENT, SAMPLE_HISTORY } from "./sampleVersions";import "./style.css";const historyOptions: LocalVersioningOptions = { initialVersions: SAMPLE_HISTORY.map((version) => ({ name: version.name, createdAt: Date.now() - version.daysAgo * DAY_MS, // These samples have flat blocks with plain text. ProseMirror JSON wraps // each block in a blockContainer, inside the document's blockGroup. content: { type: "doc", content: [ { type: "blockGroup", content: version.blocks.map((block) => ({ type: "blockContainer", attrs: { id: block.id }, content: [ { type: block.type, attrs: block.props, content: block.content ? [{ type: "text", text: block.content }] : [], }, ], })), }, ], }, })),};export default function App() { // Each editor owns its history, seeded here with a few saved documents. const editor = useCreateBlockNote({ initialContent: LIVE_DOCUMENT, extensions: [ InMemoryVersioningExtension(historyOptions), // Opt into rendering version diffs: when comparing two versions the // sidebar shows insertions/deletions as attributed marks. Without this // extension the in-memory versioning falls back to a plain document swap. DiffVersioningExtension(), ], }); const [showSidebar, setShowSidebar] = useState(true); const [sidebarPanel, setSidebarPanel] = useState<HTMLDivElement | null>(null); return ( <div className="wrapper layout"> {/* No `editable` prop: the sidebar makes the editor read-only for as long as it's open, and restores it on close. */} <BlockNoteView editor={editor}> {!showSidebar && ( <button className="show-history-button" onClick={() => { editor.getExtension<VersioningController>("versioning")!.open(); setShowSidebar(true); }} > History </button> )} {showSidebar && sidebarPanel && ( <RenderInPortalElement target={sidebarPanel}> <VersioningSidebar onClose={() => setShowSidebar(false)} // Extend the row menu by composing it: the default items plus // an app-specific one. Order is yours to choose. snapshotMenu={ <VersionMenu> <DefaultVersionMenuItems /> <MakeCopyItem /> </VersionMenu> } /> </RenderInPortalElement> )} </BlockNoteView> {showSidebar && <div className="sidebar-section" ref={setSidebarPanel} />} </div> );}/** * An application-specific row action. `useVersionSnapshot()` hands it the row * it was rendered in, so it needs no props — the sidebar knows nothing about it. */function MakeCopyItem() { const { snapshot, isCurrent } = useVersionSnapshot(); return ( <VersionMenuItem icon={<RiFileCopyLine />} onClick={() => { window.alert( `Would copy ${isCurrent ? "the current version" : (snapshot.name ?? new Date(snapshot.createdAt).toLocaleString())} into a new document.`, ); }} > Make a copy </VersionMenuItem> );}import type { PartialBlock } from "@blocknote/core";export const DAY_MS = 24 * 60 * 60 * 1000;export type SampleVersion = { name?: string; /** How long ago the version was saved. */ daysAgo: number; blocks: SampleBlock[];};// Stable ids let previews show edits to the same blocks across versions.type SampleBlock = PartialBlock & { id: string; type: "heading" | "paragraph" | "bulletListItem" | "numberedListItem"; content: string;};function updateContent(blocks: SampleBlock[], updates: Record<string, string>) { return blocks.map((block) => ({ ...block, content: updates[block.id] ?? block.content, }));}const firstDraft: SampleBlock[] = [ { id: "title", type: "heading", props: { level: 2 }, content: "Launch plan: Notes 2.0", }, { id: "goal", type: "paragraph", content: "Goal: ship the new editor to every workspace before the end of the quarter.", }, { id: "milestones", type: "heading", props: { level: 3 }, content: "Milestones", }, { id: "m1", type: "bulletListItem", content: "Beta with five design partners", }, { id: "m3", type: "bulletListItem", content: "Public release" },];const addedDates = updateContent( [ ...firstDraft.slice(0, -1), { id: "m2", type: "bulletListItem", content: "Fix the ten most-reported beta issues", }, ...firstDraft.slice(-1), ], { goal: "Goal: ship the new editor to every workspace before the end of September.", m1: "Beta with five design partners (June)", m3: "Public release (September)", },);const marketingReview: SampleBlock[] = [ ...updateContent(addedDates, { m3: "Public release (September 15)" }), { id: "announcement", type: "heading", props: { level: 3 }, content: "Announcement", }, { id: "announcement-text", type: "paragraph", content: "The blog post and changelog entry go out on release day. The newsletter follows a week later.", },];/** Saved versions, oldest first. */export const SAMPLE_HISTORY: SampleVersion[] = [ { name: "First draft", daysAgo: 9, blocks: firstDraft }, { daysAgo: 6, blocks: addedDates }, { name: "Marketing review", daysAgo: 2, blocks: marketingReview },];/** The newest version with unsaved edits to compare against. */export const LIVE_DOCUMENT: PartialBlock[] = [ ...updateContent(marketingReview, { goal: "Goal: ship the new editor to every workspace before the end of September, keeping the old editor available as a fallback for one release.", }), { id: "questions", type: "heading", props: { level: 3 }, content: "Open questions", }, { id: "q1", type: "numberedListItem", content: "Do we keep the old editor available as a fallback?", }, { id: "q2", type: "numberedListItem", content: "Who owns the migration guide?", },];/* App layout only. The versioning sidebar's own styling (header, snapshot rows, selected/comparing states, the "..." menu) ships with the UI library (@blocknote/mantine etc.), so it isn't repeated here. */.wrapper { height: calc(100vh - 20px);}.layout { display: flex; gap: 0; height: calc(100vh - 20px);}.layout > .bn-container { flex: 1; height: calc(100vh - 20px); margin: 0; max-width: none; min-width: 0; overflow: auto; padding: 0; position: relative;}.layout > .bn-container .bn-editor { height: calc(100vh - 20px); overflow: auto;}/* The history panel sits flush against the editor with a subtle divider. */.sidebar-section { display: flex; flex-direction: column; height: calc(100vh - 20px); overflow: auto; width: 350px;}/* `RenderInPortalElement` creates a themed root *inside* the application-owned slot. Theme variables live on that root, not on its parent. */.sidebar-section > .bn-root { background-color: var(--bn-colors-editor-background); border-left: 1px solid var(--bn-colors-border); box-shadow: -6px 0 16px rgba(0, 0, 0, 0.05); display: flex; flex: 1; min-height: 0;}.sidebar-section > .bn-root.dark { box-shadow: -6px 0 16px rgba(0, 0, 0, 0.3);}.show-history-button { background-color: var(--bn-colors-menu-background); border: var(--bn-border); border-radius: var(--bn-border-radius-medium); box-shadow: var(--bn-shadow-medium); color: var(--bn-colors-menu-text); cursor: pointer; font-size: 13px; font-weight: 600; padding: 6px 12px; position: absolute; right: 16px; top: 16px;}