Storage

Supabase Storage

Store VitNode uploads in a Supabase Storage bucket with @vitnode/supabase-storage - a secret key, a bucket name and one adapter call.

@vitnode/supabase-storage puts your uploads in a bucket on your Supabase project. If Supabase is already your database, this is the shortest path off the local disk: no IAM policy, no endpoint, three values and you are done.

CloudSelf-hostedPackage
✅ Supported✅ Supported@vitnode/supabase-storage

Quick start

src/vitnode.api.config.ts
import { SupabaseStorageAdapter } from '@vitnode/supabase-storage'
import { buildApiConfig } from '@vitnode/core/vitnode.config'

export const vitNodeApiConfig = buildApiConfig({
  storage: {
    adapter: SupabaseStorageAdapter({
      url: process.env.SUPABASE_URL,
      secretKey: process.env.SUPABASE_SECRET_KEY,
      bucket: process.env.SUPABASE_STORAGE_BUCKET,
    }),
  },
})

Set it up

Install the adapter

bun i @vitnode/supabase-storage
pnpm i @vitnode/supabase-storage
npm i @vitnode/supabase-storage

A runtime dependency of the app that serves your API, not a dev dependency - the API imports it on every boot. @supabase/storage-js comes with it, so there is nothing else to add.

Create the bucket

In the Supabase dashboard, open Storage → Buckets → New bucket, give it a name and turn Public bucket on. Public is not optional here for anything a visitor has to see: VitNode serves files by URL, and so do the built-in download routes - see the private-bucket gotcha below.

Leave the bucket's own file size limit and allowed MIME types generous and enforce your real limits in your route with maxBytes and allowedMimeTypes - the bucket rejects a file with a Supabase error, your route rejects it with a sentence somebody can act on.

Copy the secret key

Open Project Settings → API Keys and copy a secret key - the value starting sb_secret_. That is the modern replacement for the legacy service_role key; a still-valid legacy key works too, because the adapter sends whatever you give it verbatim as the apikey and Authorization: Bearer header.

A secret key is a server-only key

It bypasses row level security, which is exactly why the API can write to the bucket. Never put it in client code, a VITE_* variable or anything else that reaches a browser: a secret key that ships to a visitor is a secret key you have to rotate.

Put the credentials in the environment

The adapter takes plain arguments, so the names are yours. These are the ones the API configs in this repository read, and the ones the snippets here use:

VariableRequiredWhat it is for
SUPABASE_URLYesProject URL, e.g. https://abcdefgh.supabase.co. The adapter appends /storage/v1 itself
SUPABASE_SECRET_KEYYesThe sb_secret_… key. Server-side only
SUPABASE_STORAGE_BUCKETYesBucket name, exactly as you created it
.env
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_SECRET_KEY=sb_secret_...
SUPABASE_STORAGE_BUCKET=your-bucket

Miss any one of the three and the first upload throws Missing Supabase Storage configuration - the client is created lazily, so a typo waits for a file rather than failing at boot.

Register the adapter

src/vitnode.api.config.ts
import { SupabaseStorageAdapter } from '@vitnode/supabase-storage'
import { buildApiConfig } from '@vitnode/core/vitnode.config'

export const vitNodeApiConfig = buildApiConfig({
  storage: {
    adapter: SupabaseStorageAdapter({
      url: process.env.SUPABASE_URL,
      secretKey: process.env.SUPABASE_SECRET_KEY,
      bucket: process.env.SUPABASE_STORAGE_BUCKET,
    }),
    image: {
      quality: 85,
    },
  },
})

That is the config apps/web in this repository actually runs, image pipeline included.

Upload a test file

Open AdminCP → Core → System → Integrations (/admin/core/system/integrations). The Storage card now reads active; click Test storage and upload an image.

On success the object appears in the bucket under month_{month}_{year}/admin-storage-test/…, and the row appears in AdminCP → Core → System → Files with its size and pixel dimensions - proof that both the object and its core_files row were written. On failure the API log carries the Supabase error, and the two usual suspects are a bucket name that does not exist and a key pasted with its sb_secret_ prefix trimmed.

How the public URL is built

getUrl asks @supabase/storage-js for the bucket's public URL, which is a string it builds rather than a request it makes:

{SUPABASE_URL}/storage/v1/object/public/{bucket}/{key}

So every file URL in the installation is that shape, and on a private bucket Supabase's object API refuses it rather than serving the file - which is why public matters more here than it looks.

Options

Prop

Type

Gotchas

A private bucket breaks previews and downloads

getUrl returns the bucket's public URL, and the built-in download routes fetch that URL server-side before re-streaming it. On a private bucket Supabase refuses that URL, so the thumbnails in the AdminCP Files table never load and both download routes turn the refused fetch into a 404. Make the bucket public, or front it with a CDN.

The bucket's own limits answer with a 500

A bucket configured with a file size limit or an allowed-MIME-type list rejects the upload inside Supabase, and that error is not an HTTPException - so it reaches the browser as a 500 with the useful sentence in the API log. Set maxBytes and allowedMimeTypes on your upload() call so the refusal happens in VitNode, where the message is written for a person.

Uploads upsert

The adapter uploads with upsert: true, so writing to an existing key replaces it instead of failing. Keys carry a UUID, so this is not a collision risk - it is what makes a retried upload idempotent rather than a duplicate.

Next