Skip to main content
Version: Next

Your own design storage

By default Lettrove saves your users' designs. To keep them only in your own database, tell the editor so and give it two functions: one that loads a design, one that saves it. Lettrove then holds no design at all — it renders and exports what is on screen, and keeps only a record of each export.

import { designProviders } from './design-providers.js';

createEditor({
// …container, publishableKey, getToken
designId: savedId ?? null, // your id for it; leave it out to start a new design
storage: { designs: 'host' },
providers: designProviders,
on: { 'design:saved': ({ designId }) => rememberDesignId(designId) },
});

The two providers call your server:

design-providers.js
export const designProviders = {
'design:load': async ({ id }, { signal }) => {
const response = await fetch(`/my-designs/${encodeURIComponent(id)}`, { signal });
if (!response.ok) return { error: { message: 'That design is not there.', code: 'design_not_found' } };
return await response.json(); // { design, revision }
},
'design:save': async ({ id, design, revision, idempotencyKey }, { signal }) => {
const response = await fetch(`/my-designs/${encodeURIComponent(id)}`, {
method: 'PUT',
headers: { 'content-type': 'application/json', 'idempotency-key': idempotencyKey },
body: JSON.stringify({ design, revision }),
signal,
});
return await response.json(); // { id, revision, savedAt } — or { conflict: { theirs, revision } }
},
};

The contract​

design:load({ id }, { signal })​

Answer { design, revision }: the design document exactly as the editor gave it to you, and the revision you stored it at. To refuse: { error: { message, code: 'design_not_found' } }.

design:save({ id, design, revision, idempotencyKey }, { signal })​

You get
idThe design's id. For a new design the editor makes one (dsg_…); store the design under it.
designThe design document. Store it exactly as it is (JSON).
revisionThe revision this save is based on: the one you last answered (0 for a new design).
idempotencyKeyThe same on a retry of the same save. Answer a repeat with the same answer.
You answerWhen
{ id, revision, savedAt }Saved. revision is the new one (the old one plus one), savedAt an ISO time.
{ conflict: { theirs, revision } }Your stored revision is not the one this save is based on: someone else saved since. theirs is the design you hold now, revision its revision. Nothing is overwritten: the editor tells your user, who chooses Use the saved version (the editor shows theirs) or Keep mine (its version is saved again, based on revision).
{ error: { message } }Refused, in your own words.

The editor saves as your user works, so this is called often.

A complete example: your server keeps the designs​

design-server.mjs
import express from 'express';

const app = express();
const designs = new Map(); // id → { design, revision }. In your product: your database.
const answered = new Map(); // idempotency key → the answer given

app.get('/my-designs/:id', (req, res) => {
const found = designs.get(req.params.id);
if (!found) return res.status(404).json({ error: { message: 'No such design.', code: 'design_not_found' } });
res.json(found);
});

app.put('/my-designs/:id', express.json({ limit: '2mb' }), (req, res) => {
const key = req.get('idempotency-key');
if (key && answered.has(key)) return res.json(answered.get(key)); // a retry: the same answer
const { design, revision } = req.body;
const current = designs.get(req.params.id);
let answer;
if (!current && revision !== 0) {
answer = { error: { message: 'No such design.', code: 'design_not_found' } };
} else if (current && current.revision !== revision) {
answer = { conflict: { theirs: current.design, revision: current.revision } };
} else {
const next = { design, revision: revision + 1 };
designs.set(req.params.id, next);
answer = { id: req.params.id, revision: next.revision, savedAt: new Date().toISOString() };
}
if (key) answered.set(key, answer);
res.json(answer);
});

app.use(express.static('public'));
app.listen(3300, () => console.log('Listening on http://localhost:3300'));

:::warning Guard these two routes The example answers anyone, to stay short. These routes are yours, so they sit behind your own sign-in: before loading or saving, check the signed-in user may open that design, exactly as you would for any other record of theirs. Lettrove never calls them; only your page does. :::

What changes when you keep designs​

  • Your users' designs exist only with you. lettrove.designs.list and designs.get have nothing to list, and there is no design.saved webhook — you saw each save as it happened.
  • Exports still work from the page: exportHtml, exportPdf and the rest render the design on screen and record the export (with your id and revision). To export from your server, use lettrove.render({ doc, designId }) with the design from your database.
  • History (restore points) is yours to keep: the editor's History panel is hidden.
  • Keep the id the editor gives a new design (design:saved's designId, or the id in the first save) to open it again with designId.

You can keep designs, images, or both: storage: { designs: 'host', images: 'host' } with all four providers leaves nothing your users made with Lettrove.