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 | |
|---|---|
file | The File your user chose: file.name, file.type, file.size, and its bytes |
signal | An AbortSignal, aborted if the editor goes away — pass it to your fetch |
| You answer | |
|---|---|
url | Required. Where the image is served from: an absolute https:// address your users' emails can load |
width, height | Optional. Its size in pixels: the editor uses them to lay it out without measuring |
alt | Optional. A description |
id | Optional. Your id for it: kept on the image in the design, so you can tell where your assets are used |
mime, bytes | Optional |
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.
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:
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
storageandprovidersmust agree.images: 'host'without animage:uploadprovider 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://(orhttp://localhostwith a test key). An answer with any other address — adata:image, plainhttp— is refused withimage_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
idyou answer is kept on the image in the design (getDesign,designs.get), so your system can tell where each of your assets is used.