Skip to main content

Your own upload handler

By default Lettrove stores the images your users upload. To keep them yourself — in your own S3 bucket, Cloudinary, a DAM, your database, anywhere — tell the editor so, and give it the function that stores a file. Lettrove then stores none of your users' uploads, and never sees where they go.

createEditor({
// …container, publishableKey, getToken
storage: { images: 'host' },
providers: {
// Store one file, wherever you like; answer with where it is served from.
'image:upload': async (file, { signal }) => {
const response = await fetch('/my-uploads', {
method: 'POST',
headers: { 'content-type': file.type },
body: file,
signal,
});
if (!response.ok) return { error: { message: 'The upload failed. Try a smaller image.' } };
return await response.json(); // { url, width?, height?, id? }
},
// Optional: your library, for the editor's Images panel.
'image:list': async ({ query, cursor, limit }) => {
const params = new URLSearchParams({ q: query ?? '', cursor: cursor ?? '', limit: String(limit ?? 30) });
const response = await fetch(`/my-uploads?${params}`);
return await response.json(); // { items: [{ url, width, height, alt, id }], nextCursor }
},
},
});

That is the whole contract: store it wherever you wish, and return what we expect.

What the editor hands you, and what it expects back​

image:upload(file, { signal })​

You get
fileThe File your user chose: file.name, file.type, file.size, and its bytes
signalAn AbortSignal, aborted if the editor goes away — pass it to your fetch
You answer
urlRequired. Where the image is served from: an absolute https:// address your users' emails can load
width, heightOptional. Its size in pixels: the editor uses them to lay it out without measuring
altOptional. A description
idOptional. Your id for it: kept on the image in the design, so you can tell where your assets are used
mime, bytesOptional

Or, to refuse: { error: { message: 'Shown to your user as you wrote it.' } }. A provider that throws is treated the same way, with the error's message.

You have a minute to answer an upload.

image:list({ query, cursor, limit }, { signal })​

Answer one page of your library, newest first:

{
"items": [{ "url": "https://cdn.acme.com/u/123/logo.png", "width": 600, "height": 200, "alt": "Acme", "id": "img_88" }],
"nextCursor": "page-2",
"source": { "name": "Acme Media Library" }
}

nextCursor: null on the last page. Without image:list, uploads still work; the Images panel lists nothing of yours.

A complete example: your server stores the files​

The page above posts each file to your server. Here is that server — Express, storing on disk. In production, put the same few lines in front of your S3 bucket, Cloudinary or any storage you use.

upload-server.mjs
import express from 'express';
import { mkdirSync, readdirSync, statSync, writeFileSync } from 'node:fs';
import { randomUUID } from 'node:crypto';

const app = express();
mkdirSync('uploads', { recursive: true });
app.use('/uploads', express.static('uploads'));

// Images only, and the file name is yours: never take a path or extension from the upload.
const IMAGE_TYPES = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp' };

// Store one file. In your product: behind your login, and per user.
app.post('/my-uploads', express.raw({ type: '*/*', limit: '10mb' }), (req, res) => {
const ext = IMAGE_TYPES[req.get('content-type')];
if (!ext) return res.status(415).json({ error: { message: 'Only PNG, JPEG, GIF or WebP images.' } });
const id = randomUUID();
writeFileSync(`uploads/${id}.${ext}`, req.body);
res.json({ id, url: `${req.protocol}://${req.get('host')}/uploads/${id}.${ext}` });
});

// List them, newest first.
app.get('/my-uploads', (req, res) => {
const files = readdirSync('uploads')
.map((name) => ({ name, at: statSync(`uploads/${name}`).mtimeMs }))
.sort((a, b) => b.at - a.at);
res.json({
items: files.map(({ name }) => ({ id: name, url: `${req.protocol}://${req.get('host')}/uploads/${name}` })),
nextCursor: null,
});
});

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

And the page's provider that sends to it:

providers.js
export const providers = {
'image:upload': async (file, { signal }) => {
const response = await fetch('/my-uploads', {
method: 'POST',
headers: { 'content-type': file.type },
body: file,
signal,
});
if (!response.ok) return { error: { message: 'The upload failed.' } };
return await response.json(); // { id, url }
},
'image:list': async () => (await fetch('/my-uploads')).json(),
};

While you build, a test key also accepts http://localhost addresses like the ones this server gives, so you can store uploads on your own machine. A live key accepts only https://.

The rules​

  • storage and providers must agree. images: 'host' without an image:upload provider is refused when the editor opens (provider_missing), and so is a provider for images Lettrove stores (provider_not_host). Your users' files are never split between two stores.
  • Only https:// (or http://localhost with a test key). An answer with any other address — a data: image, plain http — is refused with image_not_hosted: inboxes would not load it.
  • Lettrove keeps nothing: no copy of the file, no thumbnail. Exports point at your addresses.
  • AI image generation is off for a project whose images you keep, until generated pictures can be handed to your provider too.
  • Your id stays with the image. The id you answer is kept on the image in the design (getDesign, designs.get), so your system can tell where each of your assets is used.