---
title: "Blok Storage Presets — Supabase, S3, Cloudinary"
description: "Ready-made uploaders for Blok: Supabase, S3-compatible storage, Cloudinary, and IndexedDB — no backend to write for file uploads."
source: https://blokeditor.com/presets/
lastmod: 2026-08-28
---

# Storage presets

Ready-made uploaders from @bloklabs/presets, so Blok has somewhere to put uploaded files without you writing an upload handler. Each one plugs into the uploader config option directly.

| Preset | Re-hosts a remote URL? | Production-ready? |
| --- | --- | --- |
| [Fetch endpoint](#fetch-endpoint) | Yes | Yes |
| [Supabase](#supabase) | No | Yes |
| [Presigned URLs](#presigned) | No | Yes |
| [Cloudinary](#cloudinary) | Yes | Yes |
| [IndexedDB](#indexeddb) | No | No |

## Fetch endpoint

Talks to a backend you already have. Every upload goes through your server, which decides where the bytes actually land.

Re-hosting a remote URL

Yes — your endpoint does the fetch server-side, so the browser never has to reach a third-party URL itself.

### Configuration

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `baseUrl` | `string` | `(required)` | Origin of your upload service, e.g. "https://api.myapp.com". |
| `field` | `string` | `"file"` | Multipart field name the endpoint reads for POST /upload. |
| `headers` | `Record<string, string> | (() => Promise<Record<string, string>>)` | `undefined` | Extra headers on every request. Pass a function to mint a fresh access token per upload. |

### Storage-side setup

- A server that answers POST {baseUrl}/upload (multipart) and POST {baseUrl}/upload-by-url ({ url }) with { url, fileName? } JSON.
- Where the endpoint stores the file — S3, a disk volume, Supabase — is entirely the endpoint's decision; this preset never sees it.
- The URL your /upload-by-url endpoint receives is user-supplied — validate it server-side and block internal/private addresses before fetching it, or you have built an SSRF vector.

### Usage Example

TypeScript

```
import { fetchStorage } from '@bloklabs/presets';

new Blok({
  holder: 'editor',
  uploader: fetchStorage({ baseUrl: 'https://api.myapp.com' }),
});
```

## Supabase

Uploads straight to a Supabase Storage bucket using the client you already initialize.

Re-hosting a remote URL

No — re-hosting a remote URL needs a server-side fetch, and the browser can't make one on Supabase's behalf. uploadByUrl is left undefined so Blok stores the URL verbatim instead of pretending it was re-hosted.

### Configuration

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `bucket` | `string | ((kind: AssetKind) => string)` | `"blok"` | Bucket name, or a function of the asset kind for per-kind buckets. |
| `path` | `(file: File, ctx: UploadContext) => string` | `random name, original extension kept` | Object path within the bucket. |

### Storage-side setup

- Create the bucket in the Supabase dashboard (default name "blok", or whatever `bucket` resolves to).
- Make the bucket public, or add a Storage policy granting anon SELECT — this preset returns getPublicUrl() directly, so an unreadable object is a broken image.
- Add a Storage INSERT policy for whichever role the client authenticates as.

### Usage Example

TypeScript

```
import { createClient } from '@supabase/supabase-js';
import { supabaseStorage } from '@bloklabs/presets';

const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
new Blok({ holder: 'editor', uploader: supabaseStorage(supabase, { bucket: 'blok' }) });
```

## Presigned URLs

For S3 and S3-compatible storage (R2, MinIO, GCS). Your backend mints a short-lived signed URL; the browser PUTs the file straight to it.

Re-hosting a remote URL

No — re-hosting a remote URL needs a server-side fetch, which is outside what a presigned PUT URL can do. uploadByUrl is left undefined so Blok stores the URL verbatim instead.

### Configuration

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `sign` | `(request: SignRequest) => Promise<SignedTarget>` | `(required)` | Called with { fileName, mimeType, size, kind }; must return { uploadUrl, publicUrl, headers? } from your backend. |

### Storage-side setup

- A backend endpoint that mints a presigned PUT URL (e.g. S3 PutObjectCommand) and returns it as SignedTarget.
- A CORS rule on the bucket allowing PUT (and the headers this preset sends, notably Content-Type) from your app's origin — this is what actually blocks people, since the browser PUTs directly to the bucket.

### Usage Example

TypeScript

```
import { presignedStorage } from '@bloklabs/presets';

new Blok({
  holder: 'editor',
  uploader: presignedStorage({ sign: (request) => api.sign(request) }),
});
```

## Cloudinary

Uploads directly to Cloudinary using an unsigned upload preset — no backend required.

Re-hosting a remote URL

Yes — Cloudinary fetches the remote URL itself once the browser hands it over, so re-hosting works without any server of the consumer's.

### Configuration

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `cloudName` | `string` | `(required)` | Your Cloudinary cloud name. |
| `uploadPreset` | `string` | `(required)` | Must be an UNSIGNED upload preset — a signed one would need a server. |
| `folder` | `string` | `undefined` | Optional folder to upload into. |

### Storage-side setup

- A Cloudinary account and its cloud name.
- An upload preset with signing mode set to Unsigned (Settings → Upload → Upload presets) — a signed preset silently fails from the browser since there is no server to sign the request.
- Audio assets ride Cloudinary's video pipeline by Cloudinary's own design; this preset maps kinds onto resource types accordingly, no setup needed for that part.

### Usage Example

TypeScript

```
import { cloudinaryStorage } from '@bloklabs/presets';

new Blok({
  holder: 'editor',
  uploader: cloudinaryStorage({ cloudName: 'my-cloud', uploadPreset: 'blok-unsigned' }),
});
```

## IndexedDB

Stores uploaded files as blobs in the browser's own IndexedDB — nothing leaves the device.

Re-hosting a remote URL

No — there is nothing to re-host to; a remote URL just gets stored as-is. IndexedDB only has an uploadByFile.

Not for production

Demo and prototyping only. The bytes live in one visitor's browser: they're gone on another device, in another browser, or the moment the user clears site data. It exists because Blok's built-in fallback (a blob: URL) doesn't even survive a page reload, which makes demos look broken before you've configured any real storage.

### Configuration

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `dbName` | `string` | `"blok-assets"` | IndexedDB database name. |

### Storage-side setup

- None — this preset needs no external service, which is the point.
- Wire the exported resolveBlokObjectUrl(url) helper into wherever you render an uploaded asset: uploadByFile returns a blok:asset/… reference, not a directly usable URL, and nothing resolves it back into a blob: URL for you automatically.
- If you pass a custom dbName to indexedDBStorage(), pass that same dbName as resolveBlokObjectUrl(url, { dbName }) — a mismatch looks up the wrong database and resolves to null with no error, not a thrown exception.

### Usage Example

TypeScript

```
import { indexedDBStorage, resolveBlokObjectUrl } from '@bloklabs/presets';

new Blok({ holder: 'editor', uploader: indexedDBStorage() });

// Wherever you render a stored asset's url, e.g. an <img src>:
const displayUrl = await resolveBlokObjectUrl(asset.url);
```
