Fumadocs Editor

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:

vite.config.ts
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:

vite.config.ts
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

editor.tsx
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.

StatusMeaning
synceddisk matches what you see
dirtylocal edits waiting for autosave
savinga write is in flight
conflictthe same block changed locally and on disk
offlineconnection lost; edits are kept, saves resume on reconnect
deniedthe 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.

EndpointConstantPurpose
/__fde_syncSYNC_ENDPOINTthe sync websocket
/__fde_uploadUPLOAD_ENDPOINTmedia uploads (POST), stored under <root>/assets
/__fde_asset/*ASSET_ENDPOINTserves 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:

server.ts
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.
  • version is an opaque compare-and-swap token. write must fail with the current file state when baseVersion no longer matches. That is all the session needs to merge and retry.
  • onStatus is 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:

custom-session.tsx
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

On this page