Supabase Storage is the file storage layer built into the Supabase platform. It gives you an S3-compatible object store, Row Level Security integration for access control, presigned URLs for temporary private file access, image transformations, and a CDN — all configured through the same Supabase project as your database and auth.
For a SaaS app already using Supabase, the appeal is obvious: one platform, one set of credentials, consistent access policies across database and storage. But like every bundled service, Supabase Storage makes trade-offs that are worth understanding before committing to it.
This post covers how Supabase Storage works, how to use it in a Next.js app, how to control access with RLS policies, and when a dedicated service like AWS S3 is the better choice.
How Supabase Storage Works
Supabase Storage is built on top of S3-compatible object storage (backed by AWS S3 in Supabase Cloud). On top of that foundation, Supabase adds:
- Buckets — named containers for organizing files, each with its own access policy
- RLS policies — Row Level Security rules that control who can read, insert, update, or delete objects, using the same PostgreSQL policy syntax as your database tables
- Presigned URLs — temporary signed URLs for granting time-limited access to private files
- Transformations — on-the-fly image resizing and format conversion via URL parameters
- CDN — automatic CDN caching for public buckets
The storage objects table lives in your Supabase PostgreSQL database, which is what makes RLS integration possible. Every file object is a row — you can write SQL policies against it just like any other table.
Setting Up Buckets
A bucket is the top-level container for files. You create buckets in the Supabase dashboard or via the client SDK.
Public vs private buckets
Each bucket is either public or private:
- Public buckets — files are accessible by anyone with the URL, no authentication required. Suitable for assets that should be publicly accessible: user avatars, product images, marketing assets.
- Private buckets — files require authentication or a presigned URL to access. Suitable for user-uploaded documents, private data, anything that should not be publicly accessible by URL alone.
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
// Upload to a public bucket
const { data, error } = await supabase.storage
.from('avatars')
.upload(`${userId}/avatar.jpg`, file, {
contentType: 'image/jpeg',
upsert: true,
});
// Get the public URL
const { data: { publicUrl } } = supabase.storage
.from('avatars')
.getPublicUrl(`${userId}/avatar.jpg`);Organizing files within buckets
Files are organized by path within a bucket. A common convention for SaaS apps is to prefix each file with the user or organization ID:
avatars/
user_abc123/avatar.jpg
user_def456/avatar.png
documents/
org_xyz789/invoices/2026-06.pdf
org_xyz789/exports/data.csvThis path structure makes RLS policies straightforward to write — you can restrict access based on whether the path starts with the authenticated user's ID.
Access Control with RLS Policies
This is where Supabase Storage diverges most significantly from alternatives like S3. Instead of IAM policies or bucket ACLs, access is controlled by PostgreSQL Row Level Security policies on the storage.objects table.
Writing RLS policies
Policies are SQL expressions evaluated against the storage objects table. The authenticated user's JWT claims are available via auth.uid() and auth.jwt().
A policy that lets users access only their own files:
-- Allow users to upload to their own folder
CREATE POLICY "Users can upload to own folder"
ON storage.objects
FOR INSERT
TO authenticated
WITH CHECK (
bucket_id = 'documents' AND
(storage.foldername(name))[1] = auth.uid()::text
);
-- Allow users to read their own files
CREATE POLICY "Users can read own files"
ON storage.objects
FOR SELECT
TO authenticated
USING (
bucket_id = 'documents' AND
(storage.foldername(name))[1] = auth.uid()::text
);
-- Allow users to delete their own files
CREATE POLICY "Users can delete own files"
ON storage.objects
FOR DELETE
TO authenticated
USING (
bucket_id = 'documents' AND
(storage.foldername(name))[1] = auth.uid()::text
);storage.foldername(name) is a Supabase helper function that splits the file path and returns segments as an array. [1] gets the first segment — the user ID prefix in the structure above.
Organization-level access
For B2B SaaS apps where multiple users share access to files within an organization, you can join against your own database tables in storage policies:
-- Allow org members to access org files
CREATE POLICY "Org members can access org files"
ON storage.objects
FOR SELECT
TO authenticated
USING (
bucket_id = 'documents' AND
(storage.foldername(name))[1] IN (
SELECT organization_id::text
FROM organization_members
WHERE user_id = auth.uid()
)
);This is Supabase Storage's most powerful feature: access control that can reference your application data. With S3 IAM policies, you cannot join against a users table to determine access. With Supabase RLS, you can express arbitrarily complex access rules in SQL.
Uploading Files in a Next.js App
Client-side uploads
For files uploaded directly from the browser, use the Supabase client on the frontend:
'use client';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
export function FileUpload({ userId }: { userId: string }) {
const supabase = createClientComponentClient();
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
const path = `${userId}/${Date.now()}-${file.name}`;
const { error } = await supabase.storage
.from('documents')
.upload(path, file);
if (error) {
console.error('Upload failed:', error.message);
return;
}
console.log('Uploaded:', path);
}
return <input type="file" onChange={handleUpload} />;
}The RLS policy on the bucket determines whether this upload is allowed. If the policy only permits uploads to ${auth.uid()}/... paths, an attempt to upload to a different path will fail with a permission error.
Server-side uploads
For server-side uploads — processing a file after receiving it in an API route, or generating and storing a file programmatically — use the service role client:
// app/api/upload/route.ts
import { createClient } from '@supabase/supabase-js';
import { NextRequest, NextResponse } from 'next/server';
// Service role client bypasses RLS — only use server-side
const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
export async function POST(req: NextRequest) {
const formData = await req.formData();
const file = formData.get('file') as File;
const userId = formData.get('userId') as string;
const path = `${userId}/${Date.now()}-${file.name}`;
const buffer = Buffer.from(await file.arrayBuffer());
const { error } = await supabaseAdmin.storage
.from('documents')
.upload(path, buffer, {
contentType: file.type,
});
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ path });
}The service role key bypasses RLS entirely. Never expose it to the browser — only use it in server-side code.
Accessing Private Files
Presigned URLs
For private buckets, generate a time-limited presigned URL on the server and pass it to the client:
// Generate a presigned URL valid for 1 hour
const { data, error } = await supabase.storage
.from('documents')
.createSignedUrl(`${userId}/report.pdf`, 3600);
if (data) {
const signedUrl = data.signedUrl;
// Pass this URL to the client
}The client can use the presigned URL to download the file without any authentication — the URL itself is the credential. When it expires, a new one must be generated.
Presigned upload URLs
For large file uploads, generate a presigned upload URL on the server and let the client upload directly to storage without routing through your API:
const { data, error } = await supabase.storage
.from('documents')
.createSignedUploadUrl(`${userId}/${fileName}`);
if (data) {
const { signedUrl, token, path } = data;
// Send signedUrl to client for direct upload
}This pattern reduces server load significantly for large file uploads — the file goes directly from the user's browser to storage, not through your server.
Image Transformations
Supabase Storage includes on-the-fly image transformation via URL parameters. You can resize, crop, and convert images without any additional service:
// Get a transformed image URL
const { data } = supabase.storage
.from('avatars')
.getPublicUrl(`${userId}/avatar.jpg`, {
transform: {
width: 100,
height: 100,
resize: 'cover',
format: 'webp',
},
});This generates a URL that serves a 100×100 WebP thumbnail, generated on demand and cached by the CDN on subsequent requests. For user avatars, product thumbnails, and other common image use cases in a SaaS app, this eliminates the need for a separate image processing service.
Transformations are available on public buckets. For private buckets, you use signed URLs with transformation parameters.
Tradeoffs vs. Dedicated Storage Services
Supabase Storage makes sense in specific contexts and less sense in others. Here is where the tradeoffs land.
Where Supabase Storage works well
When you are already using Supabase. If your database and auth are on Supabase, adding storage keeps your architecture consistent and avoids additional credentials to manage.
When access control is complex and data-driven. RLS policies that join against your database tables are genuinely more expressive than S3 bucket policies or IAM roles. If your access rules depend on application state — user roles, organization membership, subscription tier — Supabase's RLS approach is a cleaner fit.
When image transformations are a key requirement. Built-in on-the-fly resizing and format conversion without a third-party service is a real convenience.
For simpler SaaS apps. If your storage needs are relatively standard — user avatars, document uploads, file exports — Supabase Storage handles them well with minimal configuration.
Where dedicated S3 makes more sense
At significant scale. AWS S3 is the most widely used object storage in the world. At high throughput — many concurrent uploads, large files, complex access patterns — S3's operational maturity, tooling ecosystem, and fine-grained control become meaningful advantages.
When you need advanced S3 features. Lifecycle rules, cross-region replication, S3 Select for querying file contents, intelligent tiering for cost optimization — S3 has a depth of features that Supabase Storage does not match.
When the rest of your infrastructure is on AWS. If your application already runs on AWS (EC2, ECS, Lambda), using S3 keeps IAM roles, VPC networking, and CloudFront CDN integration consistent. The operational overhead of a cross-platform storage solution disappears.
When you are not using Supabase for the database. If you are using Neon, Railway, or your own Postgres, adding Supabase solely for storage is awkward. You are taking on an additional platform dependency for a service that S3 handles just as well.
Plainform uses AWS S3 directly for this reason. The storage client is initialized with the AWS SDK, uploads use PutObjectCommand, and private file access uses presigned URLs generated by @aws-sdk/s3-request-presigner. Since Plainform uses Prisma directly rather than the Supabase client as the primary data layer, adding Supabase Storage would have added a platform dependency without a meaningful benefit over S3 + presigned URLs.
// lib/amazon/s3.ts
import { S3Client } from '@aws-sdk/client-s3';
import { env } from '@/env';
export const s3 = new S3Client({
region: env.AWS_S3_REGION,
credentials: {
accessKeyId: env.AWS_ACCESS_KEY_ID,
secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
},
});// Generate a presigned URL for a private file
import { GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
export async function getPrivateFileUrl(key: string, expiresIn = 3600) {
const command = new GetObjectCommand({
Bucket: env.AWS_S3_BUCKET,
Key: key,
});
return getSignedUrl(s3, command, { expiresIn });
}The pattern is nearly identical to Supabase Storage's presigned URL API — the difference is where the access control lives (IAM policies vs. RLS policies) and which dependencies you take on.
Summary
Supabase Storage is a solid choice for SaaS apps already built on the Supabase platform, particularly when access control needs to reference application data via RLS policies. The key capabilities:
- Buckets for organizing files, each with public or private access
- RLS policies for data-driven access control that joins against your database
- Presigned URLs for time-limited access to private files
- On-the-fly image transformations via URL parameters
- CDN caching for public assets
The main tradeoffs to consider:
- Less mature than AWS S3 at scale and for advanced storage features
- Adds a platform dependency if you are not otherwise using Supabase
- RLS-based access control requires understanding PostgreSQL policies
- Service role key must be kept server-side and never exposed to the browser
For apps that are not already on Supabase, or that have significant storage scale requirements, AWS S3 with presigned URLs provides the same core capabilities with more operational depth and a larger ecosystem of tooling around it.
