> Lettrove docs 1.x · https://docs.lettrove.com/docs/data/own-upload-handler

# 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.

```ts
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:

```json
{
  "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.

```js title="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:

```js title="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.
