Storage: Upload, Transform and Deliver Files

Kevin Dávila
Quick question: how much of your life have you lost fighting buckets, CORS, signed URLs, and image workers? If just reading that brought back a bad memory, keep reading.
Supabase Storage takes care of all of it: direct uploads from the client, image transformations on the fly, and everything protected with RLS. Here's how to set it up.
What is Supabase Storage
Supabase Storage is a file storage service built on S3. It offers:
File upload/download
Image transformations (resize, crop, format)
Integrated CDN
Security policies with RLS
Bucket organization
Create a bucket
From the dashboard:
Go to Storage
Click New Bucket
Choose name and whether it's public or private
From code:
// src/storage/setup.ts
import { supabase } from '../lib/supabase'
// Create a bucket
const { data, error } = await supabase.storage.createBucket('avatars', {
public: false, // Private, requires auth to access
fileSizeLimit: 5 * 1024 * 1024, // 5MB
allowedMimeTypes: ['image/png', 'image/jpeg', 'image/webp']
})Upload files
Simple upload
// src/storage/upload.ts
import { supabase } from '../lib/supabase'
export async function uploadAvatar(userId: string, file: File) {
const filePath = `${userId}/avatar.${file.name.split('.').pop()}`
const { data, error } = await supabase.storage
.from('avatars')
.upload(filePath, file, {
cacheControl: '3600',
upsert: true // Overwrite if exists
})
if (error) throw error
return data
}Upload with HTML input
// src/components/UploadAvatar.ts
const fileInput = document.createElement('input')
fileInput.type = 'file'
fileInput.accept = 'image/*'
fileInput.addEventListener('change', async (e) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
const { data: { user } } = await supabase.auth.getUser()
if (!user) return
const result = await uploadAvatar(user.id, file)
console.log('Uploaded:', result)
})Download files
Public URL (public buckets)
// src/storage/public-url.ts
const { data } = supabase.storage
.from('avatars')
.getPublicUrl('user-123/avatar.jpg')
console.log(data.publicUrl)
// https://xxx.supabase.co/storage/v1/object/public/avatars/user-123/avatar.jpgSigned URL (private buckets)
// src/storage/signed-url.ts
const { data, error } = await supabase.storage
.from('avatars')
.createSignedUrl('user-123/avatar.jpg', 3600) // Expires in 1 hour
console.log(data.signedUrl)Download file
// src/storage/download.ts
const { data, error } = await supabase.storage
.from('avatars')
.download('user-123/avatar.jpg')
if (data) {
const url = URL.createObjectURL(data)
// Use url to display image
}Image transformations
Supabase allows transforming images on the fly when requesting them:
// src/storage/transform.ts
const { data } = supabase.storage
.from('avatars')
.getPublicUrl('user-123/avatar.jpg', {
transform: {
width: 200,
height: 200,
resize: 'cover', // 'cover', 'contain', 'fill'
format: 'webp', // 'origin', 'webp'
quality: 80
}
})Practical example: Thumbnails
// src/storage/thumbnails.ts
export function getAvatarUrl(userId: string, size: 'sm' | 'md' | 'lg' = 'md') {
const sizes = {
sm: { width: 64, height: 64 },
md: { width: 200, height: 200 },
lg: { width: 400, height: 400 }
}
const { data } = supabase.storage
.from('avatars')
.getPublicUrl(`${userId}/avatar.jpg`, {
transform: {
...sizes[size],
resize: 'cover',
format: 'webp'
}
})
return data.publicUrl
}List files
// src/storage/list.ts
const { data, error } = await supabase.storage
.from('avatars')
.list('user-123', {
limit: 10,
offset: 0,
sortBy: { column: 'created_at', order: 'desc' }
})
data?.forEach(file => {
console.log(file.name, file.metadata.size)
})Delete files
// src/storage/delete.ts
const { data, error } = await supabase.storage
.from('avatars')
.remove(['user-123/avatar.jpg'])Move and copy
// src/storage/move.ts
// Move file
await supabase.storage
.from('avatars')
.move('old-path/avatar.jpg', 'new-path/avatar.jpg')
// Copy file
await supabase.storage
.from('avatars')
.copy('original/avatar.jpg', 'backup/avatar.jpg')Storage policies
Just like tables, you can use RLS on Storage:
-- Users can only upload to their own folder
CREATE POLICY "Users can upload own avatars"
ON storage.objects
FOR INSERT
WITH CHECK (
bucket_id = 'avatars'
AND auth.uid()::text = (storage.foldername(name))[1]
);
-- Users can only view their own avatar (if bucket is private)
CREATE POLICY "Users can view own avatars"
ON storage.objects
FOR SELECT
USING (
bucket_id = 'avatars'
AND auth.uid()::text = (storage.foldername(name))[1]
);
-- Anyone can view avatars (if bucket is public)
CREATE POLICY "Anyone can view avatars"
ON storage.objects
FOR SELECT
USING (bucket_id = 'avatars');Complete example: Image gallery
// src/gallery.ts
import { supabase } from './lib/supabase'
export class ImageGallery {
private bucket = 'gallery'
async uploadImage(file: File, album: string) {
const { data: { user } } = await supabase.auth.getUser()
if (!user) throw new Error('Not authenticated')
const filePath = `${user.id}/${album}/${Date.now()}-${file.name}`
const { data, error } = await supabase.storage
.from(this.bucket)
.upload(filePath, file)
if (error) throw error
return data
}
async getImages(album: string) {
const { data: { user } } = await supabase.auth.getUser()
if (!user) throw new Error('Not authenticated')
const { data, error } = await supabase.storage
.from(this.bucket)
.list(`${user.id}/${album}`)
if (error) throw error
return data.map(file => ({
name: file.name,
url: this.getUrl(`${user.id}/${album}/${file.name}`),
thumbnail: this.getUrl(`${user.id}/${album}/${file.name}`, 200)
}))
}
private getUrl(path: string, size?: number) {
const { data } = supabase.storage
.from(this.bucket)
.getPublicUrl(path, {
transform: size ? { width: size, height: size, resize: 'cover' } : undefined
})
return data.publicUrl
}
}Advanced configuration
Maximum file size
In the dashboard or when creating the bucket:
await supabase.storage.createBucket('uploads', {
fileSizeLimit: 10 * 1024 * 1024 // 10MB
})Allowed file types
await supabase.storage.createBucket('documents', {
allowedMimeTypes: [
'application/pdf',
'application/msword',
'image/*'
]
})CDN and caching
Files are served through CDN automatically. The cacheControl parameter controls caching:
await supabase.storage
.from('avatars')
.upload(path, file, {
cacheControl: '86400' // 24 hours
})Common errors
"Bucket not found"
Verify the bucket exists
Check the name (case-sensitive)
"File size limit exceeded"
File exceeds bucket limit
Increase fileSizeLimit or compress the file
"Permission denied"
Storage policies don't allow the operation
Check policies in Storage > Policies
"Invalid mime type"
File type not in allowedMimeTypes
Add the type or use */* (not recommended)
What's next
In the next article we cover Realtime: how to make your app update in real-time without polling. We'll see subscriptions, broadcast, and presence.