Sync
Save to the file system with autosave and live merge
On its own the editor is a text box: you pass MDX in, it hands MDX back.
The sync layer in @fumadocs-editor/core connects it to a directory of
.md and .mdx files:
- edits autosave to disk, with compare-and-swap writes so nothing is overwritten blindly
- changes on disk (a
git pull, a save from your IDE) merge into the open document without moving your caret - the same block changed on both sides becomes a conflict you resolve with one click
The files remain the source of truth. The editor is just another writer.
Setup
Mount the dev server
The Vite plugin runs the sync server inside vite dev:
import { defineConfig } from "vite";
import { editorSync } from "@fumadocs-editor/core/vite";
export default defineConfig({
plugins: [editorSync({ root: "content/docs" })],
});
root is the mirrored directory, resolved against the Vite project root.
Keep Vite away from the mirrored files
If the directory sits inside your Vite root, exclude it from the watcher. Tailwind's source scan would otherwise rebuild the CSS and reload the page on every save:
export default defineConfig({
server: { watch: { ignored: ["**/content/docs/**"] } },
});For the same reason, never import a mirrored file as a module (?raw
included).
Point the editor at a file
import { MdxEditor } from "@fumadocs-editor/ui";
export function SyncedEditor({ path }: { path: string }) {
return <MdxEditor sync={{ path }} />;
}
That is the whole integration. The editor reads the file, autosaves, merges
disk changes and shows a status dot beside the mode tabs. With no
transport, every editor on the page shares one websocket to the dev server
on the current host.
How saving works
Autosave runs 800 ms after the last keystroke, and at most 5 s after the first unsaved one. Cmd-S, leaving the window and closing the page flush immediately.
Every write is compare-and-swap: it carries the version of the file the editor last saw. If the disk moved on since then, the write is refused and the new disk content is merged in first. Nothing is overwritten unseen.
Status
The dot beside the mode tabs shows the session status. Mirror it elsewhere
with sync.onStatus.
| Status | Meaning |
|---|---|
synced | disk matches what you see |
dirty | local edits waiting for autosave |
saving | a write is in flight |
conflict | the same block changed locally and on disk |
offline | connection lost; edits are kept, saves resume on reconnect |
denied | the server rejected your credentials, see Auth |
Merging disk changes
Disk changes merge into the open document block by block:
- Blocks changed only on disk are applied in place. Your caret stays where it was.
- A block changed on both sides is a conflict. Your version is kept, writes pause, and a chip offers keep mine (overwrite disk) or take disk (drop the local edit).
The dev server
The plugin mounts three endpoints. Their paths are exported as constants
from @fumadocs-editor/core/sync.
| Endpoint | Constant | Purpose |
|---|---|---|
/__fde_sync | SYNC_ENDPOINT | the sync websocket |
/__fde_upload | UPLOAD_ENDPOINT | media uploads (POST), stored under <root>/assets |
/__fde_asset/* | ASSET_ENDPOINT | serves stored assets |
The plugin accepts every server option below, including
authenticate.
Standalone server
The same server runs on any Node HTTP server. createSyncServer returns
plain handlers:
import { createServer } from "node:http";
import { createSyncServer } from "@fumadocs-editor/core/node";
const sync = createSyncServer({ root: "content/docs" });
const server = createServer((request, response) => {
const url = request.url ?? "/";
if (url.startsWith("/upload")) return sync.handleUpload(request, response);
if (url.startsWith("/assets/")) {
// handleAsset reads the path after the mount prefix, like a middleware
request.url = url.slice("/assets".length);
return sync.handleAsset(request, response);
}
response.statusCode = 404;
response.end();
});
server.on("upgrade", (request, socket, head) => {
if (request.url === "/sync") sync.handleUpgrade(request, socket, head);
});
server.listen(3100);
Options
Prop
Type
Custom backends
The dev server is one implementation of SyncTransport. A database, a CMS
or a browser file-system handle needs the same four calls:
Prop
Type
- Documents are addressed by root-relative posix paths.
versionis an opaque compare-and-swap token.writemust fail with the current file state whenbaseVersionno longer matches. That is all the session needs to merge and retry.onStatusis optional. Omit it for a backend that cannot go offline.
Pass the transport as sync.transport and everything above applies
unchanged.
Your own session
sync is built on createFileSession, which is public. Reach for it when
you want a custom status UI, a non-React shell, or one session shared by
several views:
import { useEffect, useRef } from "react";
import { MdxEditor, type MdxEditorRef } from "@fumadocs-editor/ui";
import {
createFileSession,
type FileSession,
type SyncTransport,
} from "@fumadocs-editor/core/sync";
// the same wiring `sync` does for you, on a transport of your own
export function CustomSession({ transport, path }: { transport: SyncTransport; path: string }) {
const editorRef = useRef<MdxEditorRef>(null);
const sessionRef = useRef<FileSession>(null);
useEffect(() => {
const session = createFileSession({
transport,
path,
document: editorRef.current!,
onStatus: (status) => console.log(status),
});
sessionRef.current = session;
void session.open();
return () => session.close();
}, [transport, path]);
return <MdxEditor ref={editorRef} onChange={() => sessionRef.current?.changed()} />;
}
document is anything with getMarkdown, applyExternalMarkdown,
setMarkdown and markSaved; the editor ref qualifies. The session has no
React dependency. Call changed() whenever the document changes and
flush() when a save should happen now.
Last updated on