SVeditor Docs
Data Persistence & Sync

Auto Save

Auto-save guide — Next.js integration, API shape, metadata, and options.

Auto Save

Auto-save loads the editor body from your application storage and writes it back after the user edits. The usual integration path is:

  1. Provide a document API, for example GET /api/docs/:docId and POST /api/docs/:docId.
  2. Pass extensionsOptions.autoSave to SVeditor.create() in a client-side editor component.
  3. If you need a title, emoji, cover, or other document metadata, configure extensionsOptions.meta separately.
  4. Use onStatusChange and onFetchStatusChange to show loading and save state in your app.

The examples below assume a Next.js App Router project with @wztlink1013/sveditor installed. They do not cover scaffolding a new Next.js app from scratch.

Integrate In A Next.js App

1. Define the document shape

Auto-save treats the body and metadata as separate concerns:

interface DocRecord {
  content: string;
  meta: {
    name?: string;
    description?: string;
    emojiInfo?: string;
    coverImage?: string;
    coverBlock?: string;
    coverPos?: string;
  };
}
  • content is the editor body. Store the Tiptap JSON string received by onSaveContent.
  • meta is document-level data such as title, emoji, and cover. It is loaded and saved through extensionsOptions.meta.
  • For a new document, return "" for body content and {} or a default title for metadata.

2. Implement the document API

You can start with in-memory storage to prove the integration, then replace the Map with your database, object storage, or backend service. This is the full shape for app/api/docs/[docId]/route.ts:

import { NextResponse } from "next/server";

const metaFields = [
  "name",
  "description",
  "contentStr",
  "emojiInfo",
  "coverImage",
  "coverBlock",
  "coverPos",
] as const;

type MetaField = (typeof metaFields)[number];
type DocMeta = Partial<Record<MetaField, string>>;

interface DocRecord {
  content: string;
  meta: DocMeta;
}

const docs = new Map<string, DocRecord>();

function getDoc(docId: string): DocRecord {
  const existing = docs.get(docId);

  if (existing) {
    return existing;
  }

  const created = {
    content: "",
    meta: { name: "Untitled document" },
  } satisfies DocRecord;

  docs.set(docId, created);
  return created;
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

function pickMetaPatch(value: unknown): DocMeta {
  if (!isRecord(value)) {
    return {};
  }

  const patch: DocMeta = {};

  for (const field of metaFields) {
    if (typeof value[field] === "string") {
      patch[field] = value[field];
    }
  }

  return patch;
}

export async function GET(
  _request: Request,
  context: { params: Promise<{ docId: string }> },
) {
  const { docId } = await context.params;
  return NextResponse.json(getDoc(docId));
}

export async function POST(
  request: Request,
  context: { params: Promise<{ docId: string }> },
) {
  const { docId } = await context.params;
  const body = (await request.json().catch(() => ({}))) as {
    content?: unknown;
    meta?: unknown;
  };

  const current = getDoc(docId);
  const metaPatch = pickMetaPatch(body.meta);
  const next: DocRecord = {
    content: typeof body.content === "string" ? body.content : current.content,
    meta:
      Object.keys(metaPatch).length > 0
        ? { ...current.meta, ...metaPatch }
        : current.meta,
  };

  docs.set(docId, next);
  return NextResponse.json({ success: true });
}

Keep the same API semantics in production:

MethodPurposeBody or response
GET /api/docs/:docIdLoad initial body and metadata{ content, meta }
POST /api/docs/:docIdSave a body or metadata patch{ content }, { meta }, or both

3. Create the client editor component

SVeditor runs in the browser, so the Next.js component must use "use client". The important React detail is keeping options stable with useMemo; otherwise every render creates a new object and may destroy and recreate the editor.

"use client";

import { useEffect, useMemo, useRef, useState } from "react";
import { SVeditor } from "@wztlink1013/sveditor";
import type {
  EditorOptions,
  FetchMetaObject,
  FetchStatus,
  SaveStatus,
} from "@wztlink1013/sveditor";
import "@wztlink1013/sveditor/style.css";

type EditorInitOptions = Omit<EditorOptions, "el">;

interface DocEditorProps {
  docId: string;
}

async function readJson<T>(response: Response): Promise<T> {
  if (!response.ok) {
    throw new Error(`Request failed: ${response.status}`);
  }

  return (await response.json()) as T;
}

export function DocEditor({ docId }: DocEditorProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const [fetchStatus, setFetchStatus] = useState<FetchStatus>("idle");
  const [saveStatus, setSaveStatus] = useState<SaveStatus>("idle");

  const options = useMemo<EditorInitOptions>(
    () => ({
      extensionsOptions: {
        autoSave: {
          onFetch: async () => {
            const data = await readJson<{ content?: string }>(
              await fetch(`/api/docs/${docId}`, { cache: "no-store" }),
            );

            return { content: data.content ?? "" };
          },
          onSaveContent: async (json: string) => {
            await readJson(
              await fetch(`/api/docs/${docId}`, {
                method: "POST",
                headers: { "Content-Type": "application/json" },
                body: JSON.stringify({ content: json }),
              }),
            );
          },
          debounceMs: 1000,
          fetchTimeoutMs: 10000,
          onFetchStatusChange: setFetchStatus,
          onStatusChange: setSaveStatus,
        },
        meta: {
          onFetchMeta: async () => {
            const data = await readJson<{ meta?: FetchMetaObject | null }>(
              await fetch(`/api/docs/${docId}`, { cache: "no-store" }),
            );

            return data.meta ?? {};
          },
          onSaveMeta: async (meta: FetchMetaObject) => {
            await readJson(
              await fetch(`/api/docs/${docId}`, {
                method: "POST",
                headers: { "Content-Type": "application/json" },
                body: JSON.stringify({ meta }),
              }),
            );
          },
        },
      },
    }),
    [docId],
  );

  useEffect(() => {
    if (!containerRef.current) {
      return;
    }

    const editor = SVeditor.create({
      el: containerRef.current,
      ...options,
    });

    return () => editor.destroy();
  }, [options]);

  return (
    <section className="doc-editor">
      <div className="doc-editor__status">
        {fetchStatus === "fetching" && "Loading document"}
        {fetchStatus === "error" && "Document failed to load"}
        {fetchStatus !== "fetching" && saveStatus === "saving" && "Saving"}
        {fetchStatus !== "fetching" && saveStatus === "saved" && "Saved"}
        {fetchStatus !== "fetching" && saveStatus === "error" && "Save failed"}
      </div>
      <div ref={containerRef} style={{ minHeight: 640 }} />
    </section>
  );
}

4. Use it from a page

DocEditor is already a client component. The page only needs to pass the route parameter:

import { DocEditor } from "@/components/doc-editor";

export default async function DocPage({
  params,
}: {
  params: Promise<{ docId: string }>;
}) {
  const { docId } = await params;

  return <DocEditor docId={docId} />;
}

At this point, opening /docs/my-doc calls onFetch to load the body. After the user edits, the SDK waits for debounceMs, then calls onSaveContent with the latest JSON and posts it to /api/docs/my-doc.

Common Integration Modes

Body only, no title UI

If you only need body persistence and do not want the built-in title, emoji, or cover UI, configure autoSave only:

SVeditor.create({
  el: "#editor",
  extensionsOptions: {
    autoSave: {
      onFetch: async () => {
        const data = await fetch(`/api/docs/${docId}`).then((res) =>
          res.json(),
        );

        return { content: data.content ?? "" };
      },
      onSaveContent: async (json) => {
        await fetch(`/api/docs/${docId}`, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ content: json }),
        });
      },
    },
  },
});

Initial content is already loaded

If the page already has the content, skip onFetch and pass content directly:

SVeditor.create({
  el: "#editor",
  content: initialContentFromPage,
  extensionsOptions: {
    autoSave: {
      onSaveContent: async (json) => {
        await saveDoc(docId, { content: json });
      },
      debounceMs: 1000,
    },
  },
});

This works well when a Server Component has already loaded the document, or when a parent application injects the content.

Browser-only prototype

Without a backend, use localStorage to test the flow:

const storageKey = `draft:${docId}`;

SVeditor.create({
  el: "#editor",
  extensionsOptions: {
    autoSave: {
      onFetch: async () => ({
        content: localStorage.getItem(storageKey) ?? "",
      }),
      onSaveContent: async (json) => {
        localStorage.setItem(storageKey, json);
      },
      debounceMs: 800,
    },
    meta: {
      onFetchMeta: async () => ({
        name: localStorage.getItem(`${storageKey}:title`) ?? "Untitled",
      }),
      onSaveMeta: async (meta) => {
        if (meta.name) {
          localStorage.setItem(`${storageKey}:title`, meta.name);
        }
      },
    },
  },
});

Include metadata

When you configure extensionsOptions.meta.onSaveMeta or extensionsOptions.meta.onMetaChange, the editor shows the built-in title and emoji area. The body still saves through autoSave.onSaveContent; metadata saves through meta.onSaveMeta:

SVeditor.create({
  el: "#editor",
  extensionsOptions: {
    autoSave: {
      onFetch: () => fetchDocContent(docId),
      onSaveContent: (json) => saveDoc(docId, { content: json }),
    },
    meta: {
      onFetchMeta: () => fetchDocMeta(docId),
      onSaveMeta: (meta) => saveDoc(docId, { meta }),
      onMetaChange: (meta) => setPreviewTitle(meta.name ?? ""),
    },
  },
});

onMetaChange is for synchronizing host UI, such as an external title preview. It is not a persistence hook and does not replace onSaveMeta.

Add version history

Version history is an independent extension. Use auto-save for the current body and version history for snapshots:

SVeditor.create({
  el: "#editor",
  extensionsOptions: {
    autoSave: {
      onFetch: () => fetchDocContent(docId),
      onSaveContent: (json) => saveDoc(docId, { content: json }),
      debounceMs: 1000,
    },
    versionHistory: {
      enabled: true,
      autoSnapshot: {
        enabled: true,
        debounceMs: 3000,
      },
    },
  },
});

Configuration

extensionsOptions.autoSave

autoSave only handles the document body. It does not handle title, emoji, cover, or other metadata.

interface AutoSaveOptions {
  onFetch?: () => Promise<{ content: string }>;
  onSaveContent?: (json: string) => Promise<void>;
  debounceMs?: number;
  fetchTimeoutMs?: number;
  onFetchError?: (error: Error, retry: () => void) => void;
  onStatusChange?: (status: SaveStatus) => void;
  onFetchStatusChange?: (status: FetchStatus) => void;
}
OptionUse it whenDescription
onFetchThe editor should load initial body content from your serverRuns once on mount. Return { content }; auto-save starts only after it resolves.
onSaveContentYou need to persist edited body contentCalled after debounce with a Tiptap JSON string.
debounceMsYou need to control save frequencyDefault is 1000. Lower values feel more immediate; higher values reduce requests.
fetchTimeoutMsInitial loading may hangTreats the initial fetch as failed after the timeout.
onFetchErrorYou want a retry button or custom load error UIReceives retry, which reruns onFetch.
onStatusChangeYour page should show “saving / saved / failed”Receives SaveStatus.
onFetchStatusChangeYour page should show “loading / failed”Receives FetchStatus.

extensionsOptions.meta

Metadata is configured separately from body persistence:

interface MetaOptions {
  onFetchMeta?: () => Promise<FetchMetaObject>;
  onSaveMeta?: (meta: FetchMetaObject) => Promise<void>;
  onMetaChange?: (meta: FetchMetaObject) => void;
}
OptionUse it whenDescription
onFetchMetaThe editor needs initial title, emoji, or cover dataRuns in parallel with autoSave.onFetch.
onSaveMetaBuilt-in metadata edits must persistCalled immediately, without autoSave.debounceMs.
onMetaChangeThe host app needs to observe any metadata changeFires for UI edits and editor.setMeta(); synchronizes state only.

FetchMetaObject

interface FetchMetaObject {
  name?: string;
  description?: string;
  contentStr?: string;
  emojiInfo?: string;
  coverImage?: string;
  coverBlock?: string;
  coverPos?: string;
}

At runtime, call editor.getMeta() to read metadata or editor.setMeta({ ... }) to merge partial updates. See EditorInstance.

Status types

type SaveStatus = "idle" | "saving" | "saved" | "error";
type FetchStatus = "idle" | "fetching" | "success" | "error";
StatusSourceMeaning
idleSave or fetchInitial idle state.
fetchingFetchStatusonFetch is running.
successFetchStatusInitial body content loaded.
savingSaveStatusonSaveContent is running.
savedSaveStatusThe latest body save completed.
errorSave or fetchA callback threw or timed out.

Backend API Requirements

Auto-save does not require a specific backend, but these API rules keep the editor predictable:

RequirementDescription
IdempotentRepeating the same POST should not create extra side effects.
Incremental updates{ content } updates only the body; { meta } updates only metadata.
Fast enoughTarget body saves below 500ms; slow responses keep the UI in saving.
Throw on failureonSaveContent and onSaveMeta should throw on non-2xx responses so the SDK can enter error.
Keep body and metadata separateonFetch returns { content }; title, emoji, and cover data come from onFetchMeta.

How It Works

Initialization flow

When onFetch is configured, the editor starts by loading the document. Auto-save only begins after the body content loads successfully, which prevents an empty editor from overwriting remote content.

Component mounts
  |
  |-- No onFetch
  |     Initialize from the provided content
  |     Auto-save can react to later edits immediately
  |
  |-- onFetch configured
        onFetchStatusChange("fetching")
        Run onFetch() and onFetchMeta() in parallel
        |
        |-- Success
        |     Set body and metadata
        |     onFetchStatusChange("success")
        |     Later edits can trigger auto-save
        |
        |-- Failure or timeout
              onFetchStatusChange("error")
              Show notification or call onFetchError
              Empty content is not saved before loading succeeds

Save queue

Body saves use a latest-write-wins queue. If a save is already running and the user keeps typing, the SDK does not call onSaveContent concurrently. It keeps only the latest JSON and saves that once the current request finishes.

User edits
  |
  |-- More edits within debounceMs
  |     Reset debounce timer
  |
  |-- debounceMs elapsed
        onStatusChange("saving")
        await onSaveContent(json)
        |
        |-- Success
        |     onStatusChange("saved")
        |     If new content arrived meanwhile, save the latest content
        |
        |-- Failure
              onStatusChange("error")
              The next content change starts another save attempt

Content format

onSaveContent receives the result of JSON.stringify(editor.getJSON()), not HTML. This preserves the full Tiptap document structure. If your application also needs HTML, call editor.getHTML() in your own layer or generate display HTML on the server.

Important Notes

  1. onFetch runs once when the editor mounts. When docId changes, destroy and recreate the editor instance.
  2. onSaveMeta is not debounced. If your title input saves frequently, debounce in your app or backend.
  3. Save failure does not start infinite automatic retries. After error, the next content change triggers another save attempt.
  4. Common debounce values: normal documents 1000ms, high-frequency input 1500ms to 2000ms, low-latency feedback 500ms.
  5. In React or Next.js, do not create options inline. Use useMemo so the options object remains stable.