Storage
Typed bucket paths, generated policies, and the upload flows apps keep rewriting.
defineBucket turns a path template into typed path builders, bucket limits,
storage.objects policies and a config.toml section. Its client wraps
supabase-js Storage calls in the same Result as the repository.
import { defineBucket } from "better-supabase/storage";
export const logos = defineBucket({
id: "customer-logos",
path: "{organizationId}/{customerId}/logo/{version}.webp",
policy: "tenant",
fileSizeLimit: "5MiB",
allowedMimeTypes: ["image/png", "image/jpeg", "image/webp"],
});Or declare it in better-supabase.config.ts under buckets and use the
generated constant: defineBucket(buckets.customerLogos). The path values
stay typed either way.
Paths
logos.path({ organizationId, customerId, version: 3 }); // { ok: true, data: 'o1/c1/logo/3.webp' }
logos.match("o1/c1/logo/3.webp"); // { organizationId: 'o1', customerId: 'c1', version: '3' }
logos.prefix({ organizationId }); // 'o1', the folder to listMissing values are type errors. At runtime every value is checked: it can't
be empty, . or .., contain /, or use characters Storage rejects. A
failed check is an invalid_input error, and no request is sent. path()
returns a Result like every other bucket method, so a value from user input
never throws:
const path = logos.path({ organizationId, customerId, version });
if (!path.ok) return problemResponse(path.error); // invalid_input, 400Client methods accept values or an existing path string, and a path string must match the template.
Several layouts
A bucket often holds objects in more than one shape: file versions,
exports and attachments side by side, and paths written by an older layout
that rows still reference. Pass path an array of templates. A path from
values uses the template whose placeholders are exactly the keys you give,
and a stored path string is accepted when any template matches it, so put
the current layout first and keep older ones after it:
export const files = defineBucket({
id: "files",
path: [
"{organizationId}/files/{fileId}/v{version}.{ext}",
"{organizationId}/exports/{exportId}.zip",
"{organizationId}/{...rest}",
],
policy: "tenant",
tenant: {},
});
files.path({ organizationId, exportId }).data; // 'o1/exports/e1.zip'
files.match("o1/2023/report.pdf"); // { organizationId: 'o1', rest: '2023/report.pdf' }A last segment written as {...rest} matches one or more segments, for
stored paths whose shape varies. Each of its segments is checked like any
other value: no empty segments, no . or .., and only characters Storage
accepts. A path with a leading / or outside the templates is refused.
Every template must hold the tenant placeholder, and a tenant, owner or
permission policy needs its placeholder at the same segment in every
template, so the generated policies and the connected client check one
segment for all of them. The write policies accept a name that matches any
template. prefix(values) returns the folder that every template holding
those values shares.
Path columns
logos.path() (inside its Result) and upload() return a
StoragePath<'customer-logos'>: a string branded with the bucket id. Client methods take plain strings too, but
reject a path branded for another bucket.
Store that path in the row, never a URL. Signed URLs expire and public URLs
pin the project host, so build the URL when you render. List the column in
storagePaths and gen types it:
export default defineConfig({
buckets: {
customerLogos: {
path: "{organizationId}/{customerId}/logo/{version}.webp",
policy: "tenant",
},
},
storagePaths: { "customers.logo_path": "customerLogos" }, // a buckets key or a bucket id
});customer.logoPath; // StoragePath<'customer-logos'> | null
await db.customers.update(id, { logoPath: "o1/c1/logo/3.webp" }); // type error: not a path of that bucket
await db.customers.update(id, {
logoPath: (await storage.upload(values, file).orThrow()).path,
}); // okgen fails on a key that names no column or a column that isn't text. The
storagePathColumns rule warns when a Storage URL or
bucket path is written to a *_url column.
Rows that store a bucket id
Tables for files, attachments or knowledge sources often keep the bucket id
next to the path, because their objects live in more than one bucket.
defineBuckets registers the bucket definitions by id, so a stored
{ bucket, path } reaches its definition without a map of your own:
import { defineBuckets } from "better-supabase/storage";
export const buckets = defineBuckets({ files, attachments, sources });const ref = buckets.connectStored(
supabase,
{ bucket: row.bucketId, path: row.path },
{ context: db.$context },
);
if (!ref.ok) return problemResponse(ref.error);
const url = await ref.data.storage.signedUrl(ref.data.path).orThrow();| Method | Returns |
|---|---|
byId(id) | The bucket definition, as a Result |
has(id) | Whether a bucket has that id, as a type guard |
resolve({ bucket, path }) | The definition and the branded StoragePath, after checking the path against its templates |
connectStored(client, ref, options) | resolve, plus a connected client that has checked the path, tenant included |
ids, buckets | The registered ids, and the definitions by the names you gave |
The registry fails closed. An id no bucket has is an invalid_input error,
never a default bucket, and so is a path outside the bucket's templates.
connectStored takes the same options as connect(), so a bucket with
tenant refuses another tenant's path with forbidden. None of these
methods sends a request. Two buckets with the same id throw when the
registry is defined.
Policies and config
logos.sql() returns idempotent SQL. It upserts the bucket row and creates
storage.objects policies for the authenticated role:
policy | Access |
|---|---|
tenant | The {organizationId} segment must equal the JWT tenant_id (or app_metadata.tenant_id) |
owner | The {userId} segment must equal auth.uid() |
public | Anyone can read. Writes need the secret key. A public: true bucket gets no select policy, so its public URLs work and nobody can list it |
none | No policies. Only the secret key has access |
{ access } | The SQL modules' access contract decides, for any access model (see below) |
bucket.owner names the placeholder that holds the owning user's id: the
owner param for owner buckets, otherwise userId when the template has
it. deleteAccount removes the objects it
fills.
Writes are also checked against the template's shape
(name ~ '^[^/]+/[^/]+/logo/[^/]+\.webp$'), so users can't upload objects
outside the paths your code builds. Use tenant: { param, claim } or
tenant: { sql } to change which JWT claim or SQL expression is compared.
When the tenant or owner segment comes first in the template, the
policies also bound name to that segment's prefix in the "C" collation,
so Postgres reads one tenant's objects through the storage name index instead
of scanning the bucket. Put the tenant first in new templates to get this.
Access contract permissions
With the access contract installed, a bucket can check
the app's permissions whatever model backs them: default roles, your own
role tables, an authorization provider or your own functions. The tenant id in the path must
be one of tenant_ids_with(key), or scope: 'platform' checks
is_platform(key):
export const contracts = defineBucket({
id: "contracts",
path: "{organizationId}/{contractId}/{file}",
policy: {
access: { read: "contracts.read", write: "contracts.write" },
},
tenant: { param: "organizationId" },
});list and delete take their own keys (see
provider permissions), and segment picks another
path segment for the tenant id.
Avatars and organization logos
avatarBucket() and organizationLogoBucket() define the two buckets most apps
need. Both are public, take 2 MiB of images (IMAGE_TYPES) and accept
id, path, public, fileSizeLimit, allowedMimeTypes and policy:
| Preset | Path | Who writes |
|---|---|---|
avatarBucket | {userId}/avatar-{version}.{ext} | the user (owner policy) |
organizationLogoBucket | {organizationId}/logo-{version}.{ext} | members with organization.update, through the access contract |
import { avatarBucket, organizationLogoBucket } from "better-supabase/storage";
export const avatars = avatarBucket();
export const logos = organizationLogoBucket({
permission: "organization.branding",
});organizationLogoBucket holds connected clients to the {organizationId} of their tenant;
pass tenant: false to upload for any organization the policy allows. A new
{version} per upload keeps CDN caches correct; store the path in the
profile's avatar_url or the organization's logo column.
Provider permissions
With an authorization provider, an
access policy can call the provider's SQL functions directly, for any scope
they take, instead of the access contract. sql: "provider" uses the
config's authorization.functions, which better-supabase gen writes into the
generated bucket policies. An object with idsWith and isPlatform copies
the templates instead; doctor (BS214) warns when the copy differs from the
provider.
export const files = defineBucket({
id: "organization-files",
path: "{organizationId}/{file}",
policy: {
access: {
read: "files.download",
list: "files.browse",
write: "files.upload",
},
scope: "organization", // or 'platform'
sql: "provider",
},
});read covers downloads, signed URLs, image renders and metadata reads.
With list, listing gets its own policy (through
storage.allow_any_operation), so a user can download a file they were sent
without being able to browse the folder, or the other way around. write
covers uploads, updates and moves, and deletes unless delete is set. The
scope id comes from the {organizationId} segment; set segment (1-based) for
another one. The id is compared as text, so a malformed path is denied instead
of raising a cast error, and the segment must be the id's canonical lowercase
form: a path with an uppercase uuid is denied.
The policies are only ever for authenticated.
Only for permissions without row conditions
The policies check role and scope, nothing else. A permission's row conditions (for example "only files the member uploaded") are applied by the provider's table policies, and its functions called on their own ignore them. For a permission with row conditions, the bucket would grant every object in the scope.
This bucket is wrong if files.download is granted to members only for files
they own: every member can download every file in the organization.
// Wrong: files.download has a row condition, which the policy ignores
policy: { access: { read: "files.download", write: "files.upload" }, scope: "organization", sql },
// Right: files.browse is granted per organization, with no row condition
policy: { access: { read: "files.browse", write: "files.upload" }, scope: "organization", sql },Keep row-conditioned permissions on tables, where the provider's policies
enforce them. The provider marks the keys its functions answer completely
with sqlComplete: true. For buckets in better-supabase.config.ts,
better-supabase gen refuses to write policies for any other key, and
doctor reports BS214 for them and for generated
bucket policies in your SQL files.
logos.toml() gives the [storage.buckets.customer-logos] section for
supabase/config.toml. logos.drift(actual) compares the bucket with what
the database has. doctor reports those differences.
Client
const storage = logos.connect(supabase); // user client: policies apply
await storage.upload({ organizationId, customerId, version }, file).orThrow();
const url = await storage
.signedUrl({ organizationId, customerId, version }, { ttl: "hour" })
.orThrow();
const thumb = await storage
.renderUrl(path, { width: 128, height: 128, resize: "cover" })
.orThrow();publicUrl(target) builds the URL of an object in a public bucket without a
request. Like path(target), it returns a Result: invalid_input for a
path outside the templates and forbidden for another tenant's path.
const logo = storage.publicUrl(customer.logoPath);
const src = logo.ok ? logo.data : fallbackLogo;Uploads are checked against fileSizeLimit and allowedMimeTypes before any
request. Failures become DbErrors: invalid_input with status 413 or 415,
forbidden for policy denials, conflict for existing objects, and network
for 5xx responses. TTL presets are minute, hour, day and week, or pass
a number of seconds.
copy(from, to) and move(from, to) copy or move an object to another path
in the same bucket in one Storage request, and return the new path. Both
paths are checked against the template (and the tenant, below) first; an
object already at to is a conflict:
await storage
.copy(
{ organizationId, customerId, version: "v1" },
{ organizationId, customerId, version: "v2" },
)
.orThrow();A page that renders the same object many times can reuse its signed URL:
const storage = logos.connect(supabase, { cacheSignedUrls: true });The connection then signs each path, ttl, transform and download
combination once and reuses the URL until a tenth of its ttl (at most a
minute) remains. It keeps at most 500 URLs and drops the oldest first. The
cache lives on that connection only, so connect once per request and URLs
signed for one user never reach another.
Tenant scope
Setting tenant on the bucket also makes connected clients check paths
before they reach Storage, which matters for the secret-key client that
policies don't limit. Every path the client builds, accepts, signs, uploads,
copies, moves, removes or reserves must hold the caller's tenant in the tenant.param
segment ({organizationId} by default), and list() lists only under it:
const logos = defineBucket({
id: "customer-logos",
path: "{organizationId}/{customerId}/logo/{version}.webp",
policy: "tenant",
tenant: { claim: "tenant_id" },
});
const storage = logos.connect(supabase, { context: db.$context });
await storage.upload(
{ organizationId: otherOrganization, customerId, version },
file,
);
// { ok: false, error: { kind: "forbidden" } }, no request madeThe tenant comes from { tenant }, else from context: context.tenant
(which the tenant() plugin fills from its claims in db.$context), then
the tenant.claim claims. A
connection without a tenant gets forbidden for every call. Cross-tenant
admin work (account deletion does this) connects with { allTenants: true }.
storage.path(target) and publicUrl(), which make no request, return
the checked path or URL as a Result, with the forbidden error for
another tenant's path.
Image transformations
renderUrl(target, { width, height, resize, quality }) returns a
Storage image transformation
URL. For public buckets it builds /storage/v1/render/image/public/... without
a request. For private buckets it signs one, and Storage answers with a
/storage/v1/render/image/sign/... URL whose token covers the transform, so
sign at the size you render.
With image transformations off (the Free plan, or a local stack without
[storage.image_transformation] enabled = true), Storage signs a plain
/storage/v1/object/sign/... URL instead, and the original image is served.
next/image
better-supabase/next/image is a loaderFile
that serves public objects through Storage transformations instead of the
Next.js optimizer:
import { createImageLoader } from "better-supabase/next/image";
export default createImageLoader({
url: process.env.NEXT_PUBLIC_SUPABASE_URL!,
});export default {
images: { loader: "custom", loaderFile: "./src/image-loader.ts" },
};The loader rewrites /object/public/ and /render/image/public/ URLs of your
project to /render/image/public/ with the requested width (at most 2500)
and quality (20 to 100), and sets resize when you pass one. Signed URLs
pass through unchanged, since changing their parameters would break the
token. Local and other hosts' images go to fallback, which returns src
unchanged by default. Next.js warns in development when a loader ignores
width, so give those images unoptimized or a fallback.
Replace
Swapping an avatar or logo takes three steps, and each can fail: upload the
new file, point the row at it, delete the old file. replace runs them in
that order and cleans up after a failure:
const { path } = await storage
.replace({ organizationId, customerId, version: crypto.randomUUID() }, file, {
previous: customer.logoPath,
commit: (path) => db.customers.update(customer.id, { logoPath: path }),
})
.orThrow();previous is checked like any other target before the upload: a path
outside the templates is invalid_input and another tenant's path is
forbidden, so replace never removes an object the client could not
address. If commit throws or returns an error Result, the new object is
removed and the old one is kept. If removing the old object fails, the result is still ok,
with cleanup set to the error.
Signed uploads
Let the browser upload directly with a server-issued reservation:
// server
const reservation = await logos
.connect(supabase)
.reserve({ organizationId, customerId, version })
.orThrow();
// browser
await logos.connect(supabase).uploadReserved(reservation, file).orThrow();Several files and progress
The dropzone block in the Supabase library uploads every file again when one
fails and reports no byte progress. Since upload returns a Result per
file, keep the files that failed and retry only those:
const storage = files.connect(supabase); // path: "{organizationId}/{file}"
const results = await Promise.all(
selected.map(async (file) => ({
file,
result: await storage.upload({ organizationId, file: file.name }, file),
})),
);
const failed = results
.filter(({ result }) => !result.ok)
.map(({ file }) => file);
// show the errors, then call the same code with `failed` when the user retriesA retry can find the object already there when an earlier attempt reached
Storage but its response didn't reach the browser. That is a conflict; pass
upsert: true when overwriting is fine.
upload and uploadReserved send the file with fetch, which reports no
upload progress. For a progress bar, reserve the path on the server and send
the file to reservation.signedUrl with XMLHttpRequest:
const body = new FormData();
body.append("cacheControl", "3600");
body.append("", file);
const request = new XMLHttpRequest();
request.upload.onprogress = (event) => setProgress(event.loaded / event.total);
request.open("PUT", reservation.signedUrl);
request.send(body);This skips the client-side size and type checks, so Storage's
fileSizeLimit and allowedMimeTypes are the ones that apply. For files
large enough to need resuming, use a TUS client against Storage's
resumable uploads
endpoint. The attachments block records each
upload as a row and scans it before anyone can download it.
Orphan sweep
Run it from a job to remove objects nothing references anymore:
const { removed } = await logos
.connect(adminClient)
.sweep({
within: { organizationId },
olderThan: Temporal.Duration.from({ hours: 24 }), // skip uploads that may still be committing
referenced: async (paths) =>
(
await db.customers
.findMany({
where: { logoPath: { in: paths } },
select: ["logoPath"],
})
.orThrow()
).map((row) => row.logoPath!),
})
.orThrow();olderThan takes a Temporal.Duration (a day counts as 24 hours) or a cutoff
Temporal.Instant; see Temporal.
Only objects that match a template and every within value are candidates,
also when a value sits after a placeholder within leaves out. Use dryRun: true to see
the orphans without deleting them.
Versions and lifecycle
A bucket with versioning: true keeps the earlier versions of an object when
it is overwritten or removed. lifecycle expires noncurrent versions, so it
needs versioning:
export const contracts = defineBucket({
id: "contracts",
path: "{organizationId}/{contractId}.pdf",
policy: "tenant",
versioning: true,
lifecycle: {
rules: [
{
noncurrentVersionExpiration: {
noncurrentDays: 90,
newerNoncurrentVersions: 5,
},
},
],
},
});Storage keeps both settings behind its API, so sql() can't write them.
apply(client) creates or updates the bucket through the API with the secret
key, sets versioning, and sets the lifecycle policy (or removes a stored one
when lifecycle is unset). It is idempotent, so run it from a deploy script:
await contracts.apply(adminClient).orThrow();Turning versioning off on a bucket that had it suspends versioning; Storage
never returns a bucket to DISABLED. A project where versioning or lifecycles
aren't enabled answers unsupported, and apply ignores that for the
lifecycle removal. better-supabase doctor (BS302) compares both settings
with storage.buckets when the Storage version has the columns.
The connected bucket reads and writes single versions:
const files = contracts.connect(adminClient);
const versions = await files.versions(path).orThrow(); // newest first
const previous = versions.find((version) => !version.current);
if (previous) {
const pdf = await files
.download(path, { versionId: previous.versionId })
.orThrow();
await files.copy(
path,
{ organizationId, contractId: "restored" },
{ versionId: previous.versionId },
);
await files.removeVersions(path, [previous.versionId]);
}signedUrl and publicUrl take versionId too. Each entry in versions()
has versionId, current, deleteMarker, size, contentType, createdAt
and archivedAt.
CDN cache
cacheNonce on signedUrl, signedUrls, publicUrl and renderUrl adds a
query parameter, so the CDN and browsers fetch the object again after it
changes. Pass something that changes with the object, such as its
updatedAt. purgeCache(target) invalidates the cached object on the CDN
instead; { transformations: true } purges only the transformed variants. It
needs the secret key, and self-hosted Storage needs a configured CDN purge
endpoint.
Vector buckets
defineVectorBucket declares a Storage vector bucket and its indexes. Storage
keeps the vectors outside Postgres; for pgvector in a table, use the
vector-search module.
import { defineVectorBucket } from "better-supabase/storage";
export const embeddings = defineVectorBucket({
id: "embeddings",
indexes: {
documents: { dimension: 1536, distanceMetric: "cosine" },
},
});const vectors = embeddings.connect(adminClient);
await vectors.apply().orThrow(); // creates the bucket and missing indexes
const documents = vectors.index<{ organizationId: string }>("documents");
await documents
.put([{ key: doc.id, vector, metadata: { organizationId } }])
.orThrow();
const hits = await documents
.query(queryVector, { topK: 5, filter: { organizationId } })
.orThrow(); // [{ key, distance, metadata }], closest firstput sends batches of 500 and checks every vector's length against the
index's dimension first, so a wrong length is an invalid_input error
instead of a Storage server error. apply refuses an existing index with
another dimension or metric with conflict. get(keys) and remove(keys)
complete the set.
Analytics buckets
defineAnalyticsBucket({ id }) declares a Storage analytics bucket, which
holds Apache Iceberg tables. connect(client).apply() creates it when it is
missing, and catalog() returns its Iceberg REST catalog
(storage.analytics.from(id)) for namespaces and tables. A stack without
analytics buckets answers unsupported.
Porting supabase.storage calls
Each supabase.storage.from(bucket) call has a method on the connected
bucket that takes a template target or a path and returns a Result:
| supabase-js | Connected bucket |
|---|---|
.upload(path, file, { upsert }) | upload(target, file, { upsert }) |
.download(path) | download(target) |
.remove([path]) | remove([target]) |
.copy(from, to) and .move(from, to) | copy(from, to) and move(from, to) |
.exists(path) | exists(target), an error unless 400 or 404 |
.list(folder) | list(within), recursive and paginated |
.createSignedUrl(path, ttl) | signedUrl(target, { ttl }) |
.createSignedUrls(paths, ttl) | signedUrls(targets, { ttl }) |
.getPublicUrl(path) | publicUrl(target), a Result |
.getPublicUrl(path, { transform }) | renderUrl(target, transform) |
.createSignedUploadUrl(path) | reserve(target) |
.uploadToSignedUrl(path, token, file) | uploadReserved(reservation, file) |
.download(path, { versionId }) | download(target, { versionId }) |
.listV2({ noncurrentVersions }) | versions(target) |
.remove([{ path, versionId }]) | removeVersions(target, [versionId]) |
.purgeCache(path) | purgeCache(target) |
storage.updateBucket, lifecycle calls | bucket.apply(client) |
storage.vectors | defineVectorBucket |
storage.analytics | defineAnalyticsBucket |
exists is false only when Storage answers 400 or 404; a denied or failed
check is an error, not a missing file. list walks sibling folders four at
a time and returns objects in name order.
Store the path a call returns (StoragePath) in a *_path column and
resolve URLs when you read the row, so a URL never goes stale in the
database. Port one bucket at a time: defineBucket reads and writes the
same storage.objects rows, so code that still calls supabase.storage
keeps working on the objects the bucket writes.
Last updated on