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:
- Provide a document API, for example
GET /api/docs/:docIdandPOST /api/docs/:docId. - Pass
extensionsOptions.autoSavetoSVeditor.create()in a client-side editor component. - If you need a title, emoji, cover, or other document metadata, configure
extensionsOptions.metaseparately. - Use
onStatusChangeandonFetchStatusChangeto 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;
};
}contentis the editor body. Store the Tiptap JSON string received byonSaveContent.metais document-level data such as title, emoji, and cover. It is loaded and saved throughextensionsOptions.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:
| Method | Purpose | Body or response |
|---|---|---|
GET /api/docs/:docId | Load initial body and metadata | { content, meta } |
POST /api/docs/:docId | Save 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;
}| Option | Use it when | Description |
|---|---|---|
onFetch | The editor should load initial body content from your server | Runs once on mount. Return { content }; auto-save starts only after it resolves. |
onSaveContent | You need to persist edited body content | Called after debounce with a Tiptap JSON string. |
debounceMs | You need to control save frequency | Default is 1000. Lower values feel more immediate; higher values reduce requests. |
fetchTimeoutMs | Initial loading may hang | Treats the initial fetch as failed after the timeout. |
onFetchError | You want a retry button or custom load error UI | Receives retry, which reruns onFetch. |
onStatusChange | Your page should show “saving / saved / failed” | Receives SaveStatus. |
onFetchStatusChange | Your 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;
}| Option | Use it when | Description |
|---|---|---|
onFetchMeta | The editor needs initial title, emoji, or cover data | Runs in parallel with autoSave.onFetch. |
onSaveMeta | Built-in metadata edits must persist | Called immediately, without autoSave.debounceMs. |
onMetaChange | The host app needs to observe any metadata change | Fires 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";| Status | Source | Meaning |
|---|---|---|
idle | Save or fetch | Initial idle state. |
fetching | FetchStatus | onFetch is running. |
success | FetchStatus | Initial body content loaded. |
saving | SaveStatus | onSaveContent is running. |
saved | SaveStatus | The latest body save completed. |
error | Save or fetch | A callback threw or timed out. |
Backend API Requirements
Auto-save does not require a specific backend, but these API rules keep the editor predictable:
| Requirement | Description |
|---|---|
| Idempotent | Repeating the same POST should not create extra side effects. |
| Incremental updates | { content } updates only the body; { meta } updates only metadata. |
| Fast enough | Target body saves below 500ms; slow responses keep the UI in saving. |
| Throw on failure | onSaveContent and onSaveMeta should throw on non-2xx responses so the SDK can enter error. |
| Keep body and metadata separate | onFetch 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 succeedsSave 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 attemptContent 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
onFetchruns once when the editor mounts. WhendocIdchanges, destroy and recreate the editor instance.onSaveMetais not debounced. If your title input saves frequently, debounce in your app or backend.- Save failure does not start infinite automatic retries. After
error, the next content change triggers another save attempt. - Common debounce values: normal documents
1000ms, high-frequency input1500msto2000ms, low-latency feedback500ms. - In React or Next.js, do not create
optionsinline. UseuseMemoso the options object remains stable.