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 fieldPrefer 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
- Document model - the JSON shape
- Controlled vs uncontrolled - when the document is yours
- Custom image renderer - resolving
blob://URLs