To save a Fabric.js canvas to a database, send its editable JSON through an API and retrieve that JSON when the user opens the document. With fabricjs-document-engine on npm, a storage adapter connects engine.save() and engine.load(documentId) to your own HTTP endpoints.
The package manages the document workflow around your existing Fabric.js canvas. Your app owns the API, authentication, database, and file storage. You can keep the toolbar and React components you already have.
This guide builds a React editor, a typed HTTP adapter, and an Express/PostgreSQL endpoint. The examples follow the public API in package 1.0.2, checked on October 1, 2026. The package supports Fabric.js 6 and 7. Start with the official quick-start guide if you have not created an engine before.
What should you save to reopen an editable Fabric.js canvas?
Save the document JSON. A PNG is useful for a thumbnail or download, but a PNG does not preserve independently editable text, shapes, groups, and object properties.
Fabric.js supplies serialization through canvas.toJSON() and canvas.toObject(). The document engine adds the information needed to identify, reopen, and save a document consistently:
| Saved field | Purpose |
|---|---|
id |
Finds the document through your API |
schemaVersion |
Identifies the engine's document format |
revision |
Detects a save based on an older stored revision |
canvas |
Records width, height, and background |
objects |
Records serialized objects in stacking order, with stable IDs |
assets |
Lists external images and font variants used by objects |
metadata |
Holds app data such as the document title |
Store the complete document body. Keeping only objects would remove the document ID, revision, and canvas dimensions that the rest of this example uses.
The document format reference describes the exact fields. The save and load guide explains how the engine serializes an existing canvas.
What is the storage adapter contract?
A basic DocumentStorage adapter has two required methods: one reads a document and one saves it. The engine does not prescribe a database, URL structure, or server framework.
import type { FabricDocument } from "fabricjs-document-engine";
// The public contract, shown here for reference.
interface DocumentStorage {
loadDocument(id: string): Promise<unknown>;
saveDocument(
document: FabricDocument,
context: {
expectedRevision: number | null;
signal: AbortSignal;
}
): Promise<void | { revision?: number }>;
}
loadDocument returns unknown because storage is an external boundary. The engine checks the returned document before loading its content.
expectedRevision is the revision this editor last loaded or saved. A new document starts at revision 0. The outgoing document normally carries the next revision. If your server assigns the revision, return the accepted value as { revision }.
A value of null means the caller requested an overwrite. Pass the provided signal to the save request so the engine can abort the client request when the session changes. Request cancellation does not undo a write that the server has already committed.
These behaviors come from the storage API reference and the custom backend guide. Optional version-storage methods are outside this article's two-method adapter.
How do you connect the document engine to an HTTP API?
Install Fabric.js and the document engine in your React app:
npm install fabric fabricjs-document-engine
Create api-storage.ts. This adapter uses same-origin endpoints and your app's existing session cookie.
import {
DocumentEngineError,
type DocumentStorage,
} from "fabricjs-document-engine";
export const apiStorage: DocumentStorage = {
async loadDocument(id) {
const response = await fetch(
`/api/documents/${encodeURIComponent(id)}`,
{ cache: "no-store" }
);
if (response.status === 404) {
throw new DocumentEngineError(
"DOCUMENT_NOT_FOUND",
"This document was not found."
);
}
if (!response.ok) {
throw new Error(`Opening the document failed (${response.status}).`);
}
return response.json();
},
async saveDocument(document, { expectedRevision, signal }) {
const response = await fetch(
`/api/documents/${encodeURIComponent(document.id)}`,
{
method: "PUT",
signal,
headers: {
"Content-Type": "application/json",
"X-Expected-Revision":
expectedRevision === null ? "*" : String(expectedRevision),
},
body: JSON.stringify(document),
}
);
if (response.status === 409 || response.status === 412) {
throw new DocumentEngineError(
"SAVE_CONFLICT",
"Another tab or device saved this document first."
);
}
if ([400, 401, 403, 404, 413, 422].includes(response.status)) {
throw Object.assign(
new Error(`The server refused this save (${response.status}).`),
{ retryable: false }
);
}
if (!response.ok) {
throw new Error(`Saving the document failed (${response.status}).`);
}
const result = (await response.json()) as { revision?: unknown };
if (!Number.isSafeInteger(result.revision) || Number(result.revision) < 1) {
throw Object.assign(new Error("The API returned an invalid revision."), {
retryable: false,
});
}
return { revision: Number(result.revision) };
},
};
X-Expected-Revision is an app-defined header. In this example, 0 means create a document and * means explicitly overwrite an existing one. These are conventions shared by this adapter and the endpoint below; the package does not require this header.
The official REST storage example uses If-Match. This article uses a custom header to keep its numeric revision protocol separate from standard HTTP entity-tag semantics.
Throwing SAVE_CONFLICT tells the engine to stop retrying that save. Setting retryable: false also stops retries for errors that require a change to the request or permissions. Other save failures can follow the engine's retry policy.
How do you save and open a document in React?
First, create the Fabric.js canvas after React mounts the canvas element. Put this helper in use-fabric-canvas.ts:
import { Canvas } from "fabric";
import { useEffect, useRef, useState } from "react";
export function useFabricCanvas() {
const elementRef = useRef<HTMLCanvasElement>(null);
const [canvas, setCanvas] = useState<Canvas | null>(null);
useEffect(() => {
if (!elementRef.current) return;
const created = new Canvas(elementRef.current, {
width: 800,
height: 500,
});
setCanvas(created);
return () => {
void created.dispose().catch(() => undefined);
};
}, []);
return { elementRef, canvas };
}
Then create document-editor.tsx:
"use client";
import { Rect } from "fabric";
import { isDocumentEngineError } from "fabricjs-document-engine";
import {
useDocumentEngine,
useDocumentState,
} from "fabricjs-document-engine/react";
import { useState } from "react";
import { apiStorage } from "./api-storage";
import { useFabricCanvas } from "./use-fabric-canvas";
export function DocumentEditor() {
const { elementRef, canvas } = useFabricCanvas();
const engine = useDocumentEngine(canvas, {
storage: apiStorage,
document: { id: "design-42", metadata: { title: "First design" } },
});
const state = useDocumentState(engine);
const [documentId, setDocumentId] = useState("design-42");
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState("");
async function run(action: () => Promise<unknown>, success: string) {
setBusy(true);
setMessage("");
try {
await action();
setMessage(success);
} catch (error) {
if (isDocumentEngineError(error) && error.code === "LOAD_ABORTED") return;
if (isDocumentEngineError(error) && error.code === "UNSAVED_CHANGES") {
setMessage("Save your current changes before opening another document.");
} else if (isDocumentEngineError(error) && error.code === "SAVE_CONFLICT") {
setMessage("A newer revision exists. Review the conflict before continuing.");
} else {
setMessage(error instanceof Error ? error.message : "The request failed.");
}
} finally {
setBusy(false);
}
}
const disabled = !engine || busy || Boolean(state?.isSaving || state?.isLoading);
return (
<section>
<label>
Document ID
<input
value={documentId}
onChange={(event) => setDocumentId(event.target.value)}
/>
</label>
<button
disabled={disabled || !documentId.trim()}
onClick={() => {
if (engine) void run(
() => engine.load(documentId.trim()),
"Document opened."
);
}}
>Open</button>
<button
disabled={disabled}
onClick={() => canvas?.add(new Rect({
left: 80, top: 60, width: 160, height: 100, fill: "#6366f1",
}))}
>Add rectangle</button>
<button
disabled={disabled}
onClick={() => {
if (engine) void run(() => engine.save(), "Save confirmed by the API.");
}}
>Save</button>
<p aria-live="polite">
Current document: {state?.documentId ?? "Preparing editor"}
{state ? ` · Revision ${state.revision} · ${state.saveStatus}` : ""}
</p>
<p role="status">{message}</p>
<canvas ref={elementRef} aria-label="Editable design canvas" />
</section>
);
}
Add a rectangle and click Save. The adapter sends the full document to PUT /api/documents/design-42. Refresh the page and click Open to retrieve it through GET /api/documents/design-42.
The text field chooses the document to open. Changing the text field does not rename the current document. A save always uses the engine's current document ID, shown below the buttons.
The example uses manual saves to make the request flow visible. To add autosave, pass autosave: true when creating the engine and continue displaying state.saveStatus and state.saveError. Options are read when the hook creates the engine; changing an options object later does not reconfigure that engine.
See the React guide, React hooks API, and autosave guide. In Next.js, mount Fabric.js through the client-only setup in the Next.js guide.
What does the app-owned HTTP endpoint look like?
The following backend code belongs to your application. It is an illustrative Express 5 and PostgreSQL implementation, not an endpoint supplied by fabricjs-document-engine.
Create a table through your app's database migration:
CREATE TABLE documents (
owner_id text NOT NULL,
id text NOT NULL,
body jsonb NOT NULL,
revision integer NOT NULL CHECK (revision >= 1),
PRIMARY KEY (owner_id, id)
);
Use your existing authentication middleware as requireUser. That middleware must verify the session, reject unauthenticated requests, and set req.auth.userId. Neither the middleware nor DATABASE_URL comes from the package.
// server.ts — app-owned backend, using Express 5.
import express from "express";
import { Pool } from "pg";
import { validateDocument, type FabricDocument } from "fabricjs-document-engine";
import { requireUser } from "./app-auth"; // Your verified-session middleware.
declare global {
namespace Express {
interface Request { auth: { userId: string } }
}
}
const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
app.use(requireUser);
app.use(express.json({ limit: "2mb" })); // An app-chosen request limit.
app.get("/api/documents/:id", async (req, res) => {
const result = await pool.query(
"SELECT body FROM documents WHERE owner_id = $1 AND id = $2",
[req.auth.userId, req.params.id]
);
if (result.rowCount === 0) {
res.status(404).json({ error: "DOCUMENT_NOT_FOUND" });
return;
}
res.set("Cache-Control", "no-store").json(result.rows[0].body);
});
app.put("/api/documents/:id", async (req, res) => {
const header = req.get("X-Expected-Revision");
if (!header || (header !== "*" && !/^(0|[1-9]\d*)$/.test(header))) {
res.status(400).json({ error: "INVALID_EXPECTED_REVISION" });
return;
}
const expected = header === "*" ? null : Number(header);
if (expected !== null && (!Number.isSafeInteger(expected) || expected > 2147483646)) {
res.status(400).json({ error: "INVALID_EXPECTED_REVISION" });
return;
}
const value: unknown = req.body;
const issues = validateDocument(value);
if (issues.length > 0) {
res.status(422).json({ error: "INVALID_DOCUMENT", issues });
return;
}
const document = value as FabricDocument;
if (document.id !== req.params.id) {
res.status(422).json({ error: "DOCUMENT_ID_MISMATCH" });
return;
}
const params = [req.auth.userId, document.id, JSON.stringify(document)];
const result = expected === 0
? await pool.query(
`INSERT INTO documents (owner_id, id, body, revision)
VALUES ($1, $2, jsonb_set($3::jsonb, '{revision}', '1'::jsonb), 1)
ON CONFLICT (owner_id, id) DO NOTHING
RETURNING revision`,
params
)
: await pool.query(
`UPDATE documents
SET body = jsonb_set($3::jsonb, '{revision}', to_jsonb(revision + 1)),
revision = revision + 1
WHERE owner_id = $1 AND id = $2
AND ($4::integer IS NULL OR revision = $4::integer)
RETURNING revision`,
[...params, expected]
);
if (result.rowCount === 0) {
res.status(409).json({ error: "SAVE_CONFLICT" });
return;
}
res.status(expected === 0 ? 201 : 200).json({
revision: result.rows[0].revision,
});
});
app.listen(3001);
Run the API behind the same origin as the React app, or configure a development proxy for /api. Apply your existing session and CSRF policy to these routes. A document ID locates a row; ownership comes from the verified session, never from metadata.owner supplied by the browser.
The endpoint assigns revisions and writes that value into both the JSON body and the revision column. This keeps the next GET consistent with the previous save response. Its overwrite branch updates an existing row; it does not recreate a deleted document. A missing row or revision mismatch returns 409.
The 2mb limit is an example application limit, not a package limit. Adjust it for your documents. validateDocument checks the document shape; enforce any additional object-count, URL, asset, or business rules in your backend too.
The SQL follows the package's atomic revision-check guidance. See PostgreSQL's UPDATE and RETURNING documentation for the database behavior. Express 5 forwards rejected async route handlers to error handling; integrate your app's normal handler using the Express error-handling guide.
How does a revision check prevent lost updates?
Suppose two tabs open revision 4 of the same document. Both tabs edit it. Without a server-side check, the second save can overwrite the first save without warning.
| Request | Expected revision | Stored revision before request | Result |
|---|---|---|---|
| Tab A saves | 4 |
4 |
Accepted; server stores revision 5
|
| Tab B saves | 4 |
5 |
Rejected with HTTP 409
|
| Tab B adapter handles response | — | 5 |
Throws SAVE_CONFLICT
|
These numbers illustrate the protocol; they are not a performance measurement.
Check the revision and write the document in the same database operation. A separate SELECT followed by an unconditional UPDATE leaves room for another request to save between them.
The engine also serializes saves within one editor session. That does not replace a database revision check across tabs, devices, or API clients.
For conflict UI, offer to review the stored document or deliberately overwrite it. engine.save({ overwrite: true }) passes expectedRevision: null; use that only after the user chooses to overwrite. Loading a newer document can also replace unsaved work, so make that choice explicit before passing discardUnsavedChanges: true.
Read the save conflicts guide for the full decision flow.
Are image files stored inside the canvas JSON?
A normal image object stores an address for its image. Saving that address does not upload the file. In particular, a blob: URL refers to data in the current browser environment and is unsuitable for reopening on another device.
Configure assets.upload to turn tab-only images into durable URLs while the engine prepares a save:
// Add this assets option when creating the engine.
const assets = {
async upload({ blob }: { blob: Blob }) {
const body = new FormData();
body.append("file", blob);
const response = await fetch("/api/uploads", { method: "POST", body });
if (!response.ok) throw new Error("The image could not be uploaded.");
const result = (await response.json()) as { url: string };
return result.url;
},
};
/api/uploads is another app-owned endpoint. It must save the file and return an address that your readers can access. The package does not create an S3 bucket, configure a CDN, or supply the upload route.
Avoid treating an expiring signed URL as a permanent asset identity. The images and fonts guide explains assets.upload and assets.resolveUrl, including how to resolve stored asset addresses when opening a document.
If a stored image cannot be opened, see the companion article, Why Fabric.js loadFromJSON Can Leave Your Editor Half-Loaded.
What should you check before shipping this save flow?
Verify the complete round trip, including failures:
- Reopen the document. Save, refresh, and open the same ID.
- Compare the content. Check object IDs, canvas dimensions, and stacking order.
- Create a conflict. Open two tabs; confirm the older revision cannot overwrite the newer save.
- Reject invalid input. Send a mismatched document ID and malformed document body.
- Check ownership. Confirm another account cannot read or write the document.
- Interrupt a request. Confirm the UI does not claim a failed save succeeded.
- Reopen uploaded images. Test from a fresh browser session.
The adapter verification guide helps check storage behavior. The production checklist covers the wider editor lifecycle.
For this article, the complete TypeScript examples passed type checking against package 1.0.2 and Fabric.js 7.4.0. A Chromium check with the built-in memory adapter confirmed that saved object IDs and canvas dimensions survived reopening, the first save produced revision 1, and an older editor's save produced SAVE_CONFLICT. The HTTP/PostgreSQL endpoint is an illustrative implementation; connect and test it with your app's actual authentication and database.
Retries need one extra decision in your API: a write can succeed even if its response is lost. Retrying with the old expected revision can then produce a conflict. This sample preserves the stored document rather than guessing. If your app needs transparent retry acknowledgments, implement request idempotency on the server.
Can you use MongoDB, MySQL, or a different backend?
Yes. The adapter sends plain JSON and returns a revision. Replace the PostgreSQL endpoint with your own backend while keeping the two storage methods and an atomic revision condition.
For MongoDB, the equivalent idea is a conditional update filtered by document ID, owner, and expected revision. The exact database code belongs to your app. The package does not ship a MongoDB model or a MySQL table.
Can you open JSON saved by an older Fabric.js editor?
The engine can recognize plain Fabric.js JSON through load, loadDocument, and importFabricJson. A document loaded by engine.load(id) retains that requested ID, and its next save uses the engine's current document format.
Check the migration guide before moving existing records. Back up the stored JSON and test your custom classes and assets. The API example above validates incoming saves in the engine's document format.
Does calling toDocument() mean the canvas has been saved?
No. engine.toDocument() produces a document object. engine.save() uses the configured storage adapter and updates the engine's save state after storage accepts the write.
Use toDocument() when you need a snapshot. Use save() for the managed persistence flow shown here.
Start with a manual save and an open-by-ID action. Once the round trip and conflicts work with your real storage, add autosave and image uploads. The npm package, quick start, and GitHub repository contain the implementation and related examples.

Top comments (0)