# Save and load

> Save a Fabric.js canvas and load from JSON safely. Replace toJSON and loadFromJSON with validated loading that never leaves a half-empty canvas.

Source: https://fabricjs-document-engine.jscrate.dev/docs/guides/save-and-load
Last updated: 2026-09-28

Saving is `engine.toDocument()`. A Fabric.js load from JSON is `engine.loadDocument(json)`. Both work without any storage, so you can keep the JSON wherever you like.

```tsx
"use client";

import type { FabricDocument } from "fabricjs-document-engine";
import { useDocumentEngine } from "fabricjs-document-engine/react";
import { CircleIcon, FolderOpenIcon, SaveIcon, SquareIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";

import {
  DemoCanvas,
  DemoFrame,
  newShape,
  seedShapes,
  Status,
  Toolbar,
  ToolButton,
} from "./demo-ui";
import { useFabricCanvas } from "./use-fabric-canvas";

export default function SaveLoadDemo() {
  const t = useTranslations("demos");
  const { elementRef, canvas } = useFabricCanvas();
  const engine = useDocumentEngine(canvas);
  const [saved, setSaved] = useState<FabricDocument>();

  useEffect(() => {
    if (!engine) return;
    engine.canvas.add(...seedShapes());
    engine.clearHistory();
  }, [engine]);

  function save() {
    // The whole canvas as plain JSON. Store it anywhere.
    setSaved(engine?.toDocument());
  }

  async function load() {
    if (saved) await engine?.loadDocument(saved);
  }

  return (
    <DemoFrame>
      <DemoCanvas elementRef={elementRef} label={t("canvas")} />

      <Toolbar>
        <ToolButton
          icon={SquareIcon}
          label={t("square")}
          onClick={() => canvas?.add(newShape("square"))}
        />
        <ToolButton
          icon={CircleIcon}
          label={t("circle")}
          onClick={() => canvas?.add(newShape("circle"))}
        />
        <span className="ml-auto flex gap-1.5">
          <ToolButton
            icon={SaveIcon}
            label={t("saveLoad.save")}
            primary
            disabled={!engine}
            onClick={save}
          />
          <ToolButton
            icon={FolderOpenIcon}
            label={t("saveLoad.load")}
            disabled={!saved}
            onClick={() => void load()}
          />
        </span>
      </Toolbar>

      <Status tone={saved ? "ok" : "idle"}>
        {saved
          ? t("saveLoad.saved", { count: saved.objects.length })
          : t("saveLoad.hint")}
      </Status>

      <pre className="h-32 overflow-auto rounded-lg border bg-muted p-3 font-mono text-xs">
        {saved ? JSON.stringify(saved, null, 2) : "{}"}
      </pre>
    </DemoFrame>
  );
}
```

```tsx
import { Canvas } from "fabric";
import { useLayoutEffect, useRef, useState } from "react";

/**
 * The page every demo draws on. The document keeps this size; CSS scales the
 * canvas down to fit its card, so saved documents and exports never depend on
 * the screen.
 */
export const PAGE = { width: 480, height: 300 };

// Selection handles in the site's style. Fabric does not save these.
const SELECTION = {
  borderColor: "#6f00ff",
  borderScaleFactor: 1.5,
  borderOpacityWhenMoving: 0.5,
  cornerStyle: "circle",
  cornerSize: 11,
  cornerColor: "#ffffff",
  cornerStrokeColor: "#6f00ff",
  transparentCorners: false,
} as const;

export function styleSelection(canvas: Canvas) {
  canvas.set({
    selectionColor: "rgba(111, 0, 255, 0.06)",
    selectionBorderColor: "#6f00ff",
    selectionLineWidth: 1,
  });
  const style = () => canvas.getActiveObject()?.set(SELECTION);
  canvas.on("selection:created", style);
  canvas.on("selection:updated", style);
}

/**
 * Creates a Fabric.js canvas once the `<canvas>` element exists, and disposes
 * it on unmount. The engine is created from the returned `canvas`.
 */
export function useFabricCanvas({ width, height } = PAGE) {
  const elementRef = useRef<HTMLCanvasElement>(null);
  const [canvas, setCanvas] = useState<Canvas | null>(null);

  // A layout effect creates the canvas before the first paint.
  useLayoutEffect(() => {
    const created = new Canvas(elementRef.current!, {
      width,
      height,
      backgroundColor: "#ffffff",
    });
    styleSelection(created);
    setCanvas(created);

    return () => {
      // Dispose after the engine's own cleanup, so its last recovery copy
      // still sees the objects.
      setTimeout(() => created.dispose().catch(() => undefined));
    };
  }, [width, height]);

  return { elementRef, canvas };
}
```

```tsx
import { Circle, type FabricObject, Rect, Triangle } from "fabric";
import type { LucideIcon } from "lucide-react";

import { cn } from "@/lib/utils";

import { PAGE } from "./use-fabric-canvas";

export const COLORS = ["#f2836f", "#5fe6c4", "#7ad9ec", "#6f00ff", "#ffd666"];

// Demo shapes are placed by their top-left corner, like a design tool.
const CORNER = { originX: "left", originY: "top" } as const;

/** A new shape at a random spot on the page. */
export function newShape(kind: "square" | "circle", fill?: string) {
  const left = 40 + Math.random() * (PAGE.width - 160);
  const top = 30 + Math.random() * (PAGE.height - 120);
  const color = fill ?? COLORS[Math.floor(Math.random() * COLORS.length)];
  return kind === "square"
    ? new Rect({
        ...CORNER,
        left,
        top,
        width: 88,
        height: 64,
        rx: 10,
        ry: 10,
        fill: color,
      })
    : new Circle({ ...CORNER, left, top, radius: 34, fill: color });
}

/** A small composed drawing, so no demo starts on an empty page. */
export function seedShapes(): FabricObject[] {
  return [
    new Rect({
      ...CORNER,
      left: 48,
      top: 56,
      width: 176,
      height: 124,
      rx: 16,
      ry: 16,
      fill: COLORS[4],
    }),
    new Circle({ ...CORNER, left: 268, top: 44, radius: 58, fill: COLORS[0] }),
    new Rect({
      ...CORNER,
      left: 256,
      top: 196,
      width: 176,
      height: 52,
      rx: 26,
      ry: 26,
      fill: COLORS[1],
    }),
    new Triangle({
      ...CORNER,
      left: 96,
      top: 196,
      width: 72,
      height: 60,
      fill: COLORS[3],
    }),
  ];
}

export function DemoFrame({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      className={cn(
        "flex w-full max-w-md flex-col gap-3 text-left outline-none",
        className
      )}
      {...props}
    />
  );
}

/**
 * Scales the fixed-size Fabric canvas to the frame's width. The rules are
 * `!important` because Fabric writes its sizes as inline styles.
 */
export function DemoCanvas({
  elementRef,
  label,
  page = PAGE,
  children,
}: {
  elementRef: React.Ref<HTMLCanvasElement>;
  label: string;
  page?: { width: number; height: number };
  children?: React.ReactNode;
}) {
  return (
    <div
      className="relative overflow-hidden rounded-lg border bg-white shadow-xs [&_.canvas-container]:aspect-(--page)! [&_.canvas-container]:h-auto! [&_.canvas-container]:w-full! [&_canvas]:h-full! [&_canvas]:w-full!"
      style={
        { "--page": `${page.width} / ${page.height}` } as React.CSSProperties
      }
    >
      <div>
        <canvas ref={elementRef} aria-label={label} />
      </div>
      {children}
    </div>
  );
}

export function Toolbar({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      className={cn("flex flex-wrap items-center gap-1.5", className)}
      {...props}
    />
  );
}

export function ToolButton({
  icon: Icon,
  label,
  iconOnly = false,
  primary = false,
  className,
  ...props
}: React.ComponentProps<"button"> & {
  icon: LucideIcon;
  label: string;
  iconOnly?: boolean;
  primary?: boolean;
}) {
  return (
    <button
      type="button"
      title={label}
      aria-label={iconOnly ? label : undefined}
      className={cn(
        "inline-flex h-8 min-w-0 items-center justify-center gap-1.5 rounded-md border px-2.5 text-[13px] font-medium whitespace-nowrap transition-colors disabled:pointer-events-none disabled:opacity-40 [&_svg]:size-3.5 [&_svg]:shrink-0",
        primary
          ? "border-transparent bg-foreground text-background hover:bg-foreground/85"
          : "bg-background hover:bg-muted",
        iconOnly && "w-8 px-0",
        className
      )}
      {...props}
    >
      <Icon aria-hidden />
      {!iconOnly && <span className="truncate">{label}</span>}
    </button>
  );
}

const TONES = {
  idle: "bg-zinc-400",
  busy: "bg-amber-400",
  ok: "bg-emerald-500",
  error: "bg-red-500",
};

/** One line of status text. Its height never changes, so the card never jumps. */
export function Status({
  tone = "idle",
  children,
}: {
  tone?: keyof typeof TONES;
  children: React.ReactNode;
}) {
  return (
    <p
      role="status"
      className="flex h-5 min-w-0 items-center gap-2 text-[13px] text-muted-foreground"
    >
      <span
        aria-hidden
        className={cn("size-1.5 shrink-0 rounded-full", TONES[tone])}
      />
      <span className="truncate">{children}</span>
    </p>
  );
}
```

## Save the canvas

```ts
const document = engine.toDocument();
await fetch(`/api/documents/${document.id}`, {
  method: "PUT",
  body: JSON.stringify(document),
});
```

The result is a plain `FabricDocument` object. It holds the canvas size and background, every object in stacking order, the images and fonts the document needs, and your own `metadata`. The [document format](https://fabricjs-document-engine.jscrate.dev/docs/guides/document-format) page describes each field.

Compared with Fabric.js `toJSON`, this serialization adds:

- a stable `id` on every object, including children of groups,
- the properties of your [custom objects](https://fabricjs-document-engine.jscrate.dev/docs/guides/custom-objects), without listing them on each call,
- a `schemaVersion`, so future versions can upgrade old files.

## Fabric.js load from JSON

```ts
const response = await fetch("/api/documents/plan-42");
await engine.loadDocument(await response.json());
```

Before the canvas is touched, the engine:

1. validates the document and reports the exact path of any problem,
2. upgrades older documents and plain Fabric.js JSON,
3. refuses unknown object types, instead of turning them into something else,
4. loads every image and font, and fails if an image is missing.

Only then does it turn the JSON to canvas objects. A failed load throws a `DocumentEngineError` and leaves the current canvas exactly as it was. With plain `loadFromJSON`, the canvas may already be cleared when an image fails.

## When loads overlap

If the user opens document A and then document B before A finishes, B wins. The load of A is cancelled with `LOAD_ABORTED`, so it can never replace B:

```ts
engine.on("load:error", ({ error }) => {
  if (error.code === "LOAD_ABORTED") return;
  showMessage(`The document could not be opened: ${error.message}`);
});
```

## Save and load with storage

With a storage adapter, you work by id instead of by JSON:

```ts
await engine.load("plan-42");
await engine.save();
```

This also turns on [autosave](https://fabricjs-document-engine.jscrate.dev/docs/guides/autosave) and [save conflict checks](https://fabricjs-document-engine.jscrate.dev/docs/guides/save-conflicts). See [storage adapters](https://fabricjs-document-engine.jscrate.dev/docs/storage/storage-adapters) for the built-in options.

> **Unsaved work is never replaced**
>
> With a storage adapter, `load`, `loadDocument`, `importFabricJson` and `newDocument` refuse with `UNSAVED_CHANGES` while there are unsaved changes. Save first, or pass `{ discardUnsavedChanges: true }` when the user chose to throw the changes away.

## Open old Fabric.js JSON

A Fabric.js load from JSON also works for plain `canvas.toJSON()` output from Fabric.js 5, 6 or 7. It is upgraded when it is opened. See [migration](https://fabricjs-document-engine.jscrate.dev/docs/guides/migration).

## Next steps

- [Custom objects](https://fabricjs-document-engine.jscrate.dev/docs/guides/custom-objects): keep your own properties.
- [Images and fonts](https://fabricjs-document-engine.jscrate.dev/docs/guides/assets-and-fonts): what happens when an image is missing.
