On this sectionPersistence

Persistence

Implement EditorPersistenceAdapter to save documents to your own API, or use the built-in localStorage adapter with its autosave debounce.

How saving works

Passing adapter switches the editor from memory mode to persisted mode. The editor loads the document when it opens, saves it after the autosave debounce, and writes a recovery snapshot when something fails.

load
Called once when a document opens. Return the stored value, or null for a new document. The editor parses the value and opens it, so return whatever your API stored.
save
Called after each debounce window. It receives the document id, the schema version, the revision the editor edited from, and the content. Return saved, conflict or error.
saveRecovery
Called with a snapshot when a document could not be saved or opened. Keep it; it is the user's only copy at that point.
subscribe
Optional. The editor passes a callback that receives revisions written elsewhere, such as in another tab.

The interface

This is the file the registry installs. The shapes are copied from the source, so a change in the editor changes this page.

ts
import type { EditorValue } from "./editor-value";

export type SaveRequest = {
  documentId: string;
  schemaVersion: number;
  baseRevision: number;
  content: EditorValue;
};

export type SaveResult =
  | { status: "saved"; revision: number }
  | { status: "conflict"; currentRevision: number }
  | {
      status: "error";
      reason: "quota" | "unavailable" | "unknown";
      message: string;
    };

export type RecoverySnapshot = {
  documentId: string;
  revision: number;
  savedAt: string;
  content: EditorValue;
  reason: "conflict" | "save-failed" | "replaced-invalid";
  raw?: unknown;
};

export type EditorPersistenceAdapter = {
  load: (documentId: string) => Promise<unknown | null>;
  save: (request: SaveRequest) => Promise<SaveResult>;
  saveRecovery: (snapshot: RecoverySnapshot) => Promise<void>;
  subscribe?: (documentId: string, onExternalRevision: (revision: number) => void) => () => void;
};
The barrel exports EditorPersistenceAdapter only. SaveRequest, SaveResult and RecoverySnapshot live in that installed file, and you can import them from it if you need to name them.

An adapter for your own API

Annotate the object with EditorPersistenceAdapter and TypeScript gives every method the right parameter types. The revision is the whole concurrency story: send the revision you edited from and let the server reject a stale write.

ts
import type { EditorPersistenceAdapter } from "@/components/blocks/notion-like-editor";

// The parameter types are inferred from EditorPersistenceAdapter:
// load(documentId: string), save(request: SaveRequest), saveRecovery(snapshot: RecoverySnapshot).
export const apiAdapter: EditorPersistenceAdapter = {
  async load(documentId) {
    const response = await fetch(`/api/documents/${documentId}`, { cache: "no-store" });
    if (response.status === 404) {
      return null; // A new document.
    }
    if (!response.ok) {
      throw new Error("The document could not be loaded.");
    }

    return (await response.json()) as unknown;
  },

  async save(request) {
    const response = await fetch(`/api/documents/${request.documentId}`, {
      method: "PUT",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({
        schemaVersion: request.schemaVersion,
        baseRevision: request.baseRevision,
        content: request.content,
      }),
    });

    if (response.status === 409) {
      const body = (await response.json()) as { revision: number };
      return { status: "conflict", currentRevision: body.revision };
    }

    if (!response.ok) {
      return { status: "error", reason: "unknown", message: "The document could not be saved." };
    }

    const body = (await response.json()) as { revision: number };
    return { status: "saved", revision: body.revision };
  },

  async saveRecovery(snapshot) {
    // Keep the copy the editor could not save, so the user can recover it later.
    await fetch(`/api/documents/${snapshot.documentId}/recovery`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(snapshot),
    });
  },

  subscribe(documentId, onExternalRevision) {
    // Optional. Report a revision written by another tab or by another user.
    const source = new EventSource(`/api/documents/${documentId}/revisions`);
    source.addEventListener("message", (event) => {
      onExternalRevision(Number(event.data));
    });

    return () => {
      source.close();
    };
  },
};

A conflict result tells the editor that someone else saved first. It stops autosaving, writes a recovery snapshot, and shows the user a conflict notice with a reload button. An error result shows the message you return and keeps the content for a retry.

Local storage

createLocalStorageAdapter stores documents in the browser, with the same adapter shape. Create it in the browser only: it reads window when it is created, so a server render cannot build one. The example reads it as a value the server does not have, and renders nothing until it exists.

tsx
"use client";

import { useSyncExternalStore } from "react";

import {
  NotionLikeEditorBlock,
  createLocalStorageAdapter,
  type EditorPersistenceAdapter,
} from "@/components/blocks/notion-like-editor";

let cached: EditorPersistenceAdapter | undefined;

function subscribe(): () => void {
  return () => undefined;
}

function getAdapter(): EditorPersistenceAdapter {
  if (cached === undefined) {
    cached = createLocalStorageAdapter();
  }

  return cached;
}

// The server snapshot is undefined, so no server render touches window.
function getServerAdapter(): undefined {
  return undefined;
}

export function SavedNotes() {
  const adapter = useSyncExternalStore(subscribe, getAdapter, getServerAdapter);

  if (adapter === undefined) {
    return null;
  }

  return (
    <NotionLikeEditorBlock
      documentId="notes"
      adapter={adapter}
      options={{ autosave: { debounceMs: 1500 } }}
    />
  );
}
NameTypeDefaultDescription
storageStoragewindow.localStorageThe Storage to read and write. Pass a fake in a test.
keyPrefixstring"notion-like-editor"Prefix for every key. Documents use <keyPrefix>:<documentId>, and recovery snapshots add :recovery:<reason>.
targetPick<Window, "addEventListener" | "removeEventListener">windowWhere the storage event listener is attached for cross-tab revisions.
The empty state is honest here: local storage is one browser on one device. It is the right default for a demo or a scratchpad, and the wrong one for anything a user expects to find later.

Autosave debounce

The default is 800 ms after the last change. A save also starts after a maximum wait, so a long burst of typing still reaches storage. The save controller is created when a document opens or starts over, so a changed debounceMs applies from the next controller, and the controller that is already running keeps its delay.

The option lives on the editor, not on the adapter: options={{ autosave: { debounceMs: 1500 } }}.

Related