DocMosaicdocs
Recipes

Persisting templates

Save and load documents via localStorage, a backend, or a URL - without bloating the payload with embedded image data.

Document is plain JSON, so persistence is "just JSON.stringify." The trick is keeping the payload small - image sections embed data URLs by default, which can balloon a single document into MBs.

Minimal save / load

function saveDocument(doc: Document) {
    localStorage.setItem('my-template', JSON.stringify(doc));
}

function loadDocument(): Document | null {
    const raw = localStorage.getItem('my-template');
    return raw ? (JSON.parse(raw) as Document) : null;
}

Wire this into Editor.Root controlled mode:

const [doc, setDoc] = useState<Document>(() => loadDocument() ?? createDocument());

useEffect(() => {
    saveDocument(doc);
}, [doc]);

return (
    <Editor.Root document={doc} onDocumentChange={setDoc}>
        {/* ... */}
    </Editor.Root>
);

Stable serialization with exportTemplate / importTemplate

@docmosaic/core ships a dedicated serializer pair. exportTemplate(doc) emits JSON with stable key ordering - two equivalent documents produce identical bytes, which is handy for snapshot tests and version-control diffs - and ISO-encoded timestamps. importTemplate(json) parses it back, validates every required top-level field, and rehydrates the createdAt / updatedAt Dates (raw JSON.parse would leave them as strings).

import { exportTemplate, importTemplate, type DocumentTemplate } from '@docmosaic/core';

const json = exportTemplate(doc); // stable, diff-friendly JSON string
const restored: DocumentTemplate = importTemplate(json); // throws on a missing/invalid field

Prefer these over hand-rolled JSON.stringify / JSON.parse when you persist documents you'll diff, snapshot, or load from an untrusted source - importTemplate fails loudly with the name of the first missing field instead of handing you a half-formed document. DocumentTemplate is an alias for Document.

Keep image blobs separate

Replace embedded data URLs with object-store references before saving. On load, swap them back.

async function strip(doc: Document): Promise<Document> {
    const next = structuredClone(doc);
    for (const s of next.sections) {
        if (s.type === 'image' && s.imageUrl?.startsWith('data:')) {
            const blob = dataURLToBlob(s.imageUrl);
            const key = await uploadBlob(blob); // s3, R2, etc.
            s.imageUrl = `blob://${key}`;
        }
    }
    return next;
}

async function hydrate(doc: Document): Promise<Document> {
    const next = structuredClone(doc);
    for (const s of next.sections) {
        if (s.type === 'image' && s.imageUrl?.startsWith('blob://')) {
            const key = s.imageUrl.slice('blob://'.length);
            s.imageUrl = await fetchBlobAsDataUrl(key);
        }
    }
    return next;
}

The reducer doesn't care what scheme imageUrl uses - it's just a string. As long as your custom image renderer (or the bundled <img> element) can resolve it, the editor works.

Templates (read-only seeds)

For ship-with-the-app templates, save the JSON to a file and import it directly:

import template from './templates/invoice.json';

<Editor.Root defaultDocument={template as Document}>
    {/* ... */}
</Editor.Root>;

See also