Fumadocs Editor

Sync

Save to the file system with autosave and live merge

Sync connects the editor to a directory of .md and .mdx files:

  • edits autosave to disk
  • changes on disk (a git pull, a save from your IDE) merge into the open document without moving your caret
  • a block changed on both sides becomes a conflict you resolve in one click

Setup

Install

npm install @fumadocs-editor/core

Add the Vite plugin

It runs the sync server inside vite dev. root is the directory to edit.

vite.config.ts
import { defineConfig } from "vite";
import { editorSync } from "@fumadocs-editor/core/vite";

export default defineConfig({
  plugins: [editorSync({ root: "content/docs" })],
});

Keep Vite away from the edited files

If root sits inside your Vite project, exclude it from the watcher, or every save reloads the page:

vite.config.ts
export default defineConfig({
  server: { watch: { ignored: ["**/content/docs/**"] } },
});

Never import those files as modules either (?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 }} />;
}

The editor now loads the file, autosaves and shows a status dot in its header.

Saving

  • Autosave runs 800 ms after you stop typing, and at most 5 s after the first unsaved change. ⌘S and closing the page save immediately.
  • A save never overwrites a file it has not seen. If the file changed on disk in the meantime, that change is merged in first.
  • When the same block changed on both sides, your version is kept and saving pauses until you pick keep mine or take disk.

The status dot shows where the document stands. Mirror it elsewhere with sync.onStatus.

StatusMeaning
synceddisk matches what you see
dirtyedits waiting for autosave
savinga save is in flight
conflictthe same block changed locally and on disk
offlinedisconnected; edits are kept and saved later
deniedthe server rejected your credentials

Sync client

By default, every editor on the page shares one connection to the dev server. Create your own client to connect elsewhere, send credentials or turn on collaboration, then pass it to every editor:

editor.tsx
import { createSyncClient, wsTransport } from "@fumadocs-editor/core/sync";
import { MdxEditor } from "@fumadocs-editor/ui";

const client = createSyncClient({
  transport: wsTransport("ws://localhost:3100/sync"),
});

export function Editor({ path }: { path: string }) {
  return <MdxEditor sync={{ client, path }} />;
}

Prop

Type

Standalone server

Without Vite, createSyncServer gives you handlers for any Node HTTP server:

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);

The Vite plugin accepts the same options:

Prop

Type

Other backends

A database, a CMS or a worker can replace the Node server by speaking the sync protocol.

Without MdxEditor

sync is built on client.open. Use it directly for a custom status UI or a non-React shell:

custom-document.tsx
import { useEffect, useRef, useState } from "react";
import { MdxEditor, type MdxEditorRef } from "@fumadocs-editor/ui";
import type { DocumentStatus, SyncClient, SyncDocument } from "@fumadocs-editor/core/sync";

export function CustomDocument({ client, path }: { client: SyncClient; path: string }) {
  const editorRef = useRef<MdxEditorRef>(null);
  const documentRef = useRef<SyncDocument>(null);
  const [status, setStatus] = useState<DocumentStatus>("synced");

  useEffect(() => {
    const editor = editorRef.current!;
    const doc = client.open(path, { editor });
    documentRef.current = doc;
    doc.opened.then(({ text }) => editor.setMarkdown(text), console.error);
    const stop = doc.subscribe(() => setStatus(doc.status()));
    return () => {
      stop();
      doc.close();
    };
  }, [client, path]);

  return (
    <>
      <p>{status}</p>
      <MdxEditor ref={editorRef} onChange={() => documentRef.current?.changed()} />
    </>
  );
}
  • editor is anything with getMarkdown, applyExternalMarkdown, setMarkdown and markSaved. The editor ref qualifies.
  • Load opened's text into the editor, call changed() on every edit.
  • A client holds one document per path.

Prop

Type

Last updated on

On this page