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.
| Cloud | Self-hosted | Package |
|---|---|---|
| ✅ Supported | ✅ Supported | @vitnode/supabase-storage |
Quick start
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-storagepnpm i @vitnode/supabase-storagenpm i @vitnode/supabase-storageA 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:
| Variable | Required | What it is for |
|---|---|---|
SUPABASE_URL | Yes | Project URL, e.g. https://abcdefgh.supabase.co. The adapter appends /storage/v1 itself |
SUPABASE_SECRET_KEY | Yes | The sb_secret_… key. Server-side only |
SUPABASE_STORAGE_BUCKET | Yes | Bucket name, exactly as you created it |
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_SECRET_KEY=sb_secret_...
SUPABASE_STORAGE_BUCKET=your-bucketMiss 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
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
AWS S3 / Cloudflare R2
Store VitNode uploads in an S3-compatible bucket with @vitnode/s3 - one adapter for AWS S3, Cloudflare R2 and anything that speaks the S3 API.
Custom adapter
Write your own VitNode storage adapter - implement StorageApiPlugin's upload, delete and getUrl and store uploaded files anywhere.