- Learn
- Stack Essentials
- Supabase
- File Storage
Intermediate15 min1 prerequisite
Upload, manage, and serve files with Supabase Storage including buckets, policies, and image transformations.
File Storage
Supabase Storage provides file storage with the same RLS policies you use for database tables.
Storage Concepts
Buckets
Buckets are containers for files:
Terminal
Storage
├── avatars/ # Public bucket
│ ├── user-123.jpg
│ └── user-456.png
├── documents/ # Private bucket
│ ├── report.pdf
│ └── contract.docx
└── attachments/ # Private bucket
└── file.zip
Public vs Private
| Type | Access | Use Case |
|---|---|---|
| Public | Anyone with URL | Profile pics, logos |
| Private | Auth required | Documents, sensitive files |
Creating Buckets
Via Dashboard
- Go to Storage → New Bucket
- Set name and privacy settings
- Configure file size limits
Via SQL
Terminal
-- Create public bucket
INSERT INTO storage.buckets (id, name, public)
VALUES ('avatars', 'avatars', true);
-- Create private bucket
INSERT INTO storage.buckets (id, name, public)
VALUES ('documents', 'documents', false);
Uploading Files
Basic Upload
Terminal
'use client'
import { createClient } from '@/lib/supabase/client'
async function uploadFile(file: File, path: string) {
const supabase = createClient()
const { data, error } = await supabase.storage
.from('avatars')
.upload(path, file)
if (error) throw error
return data
}
// Usage
const file = event.target.files[0]
await uploadFile(file, `user-${userId}/avatar.jpg`)
Upload with Options
Terminal
async function uploadWithOptions(file: File, path: string) {
const supabase = createClient()
const { data, error } = await supabase.storage
.from('avatars')
.upload(path, file, {
cacheControl: '3600', // Cache for 1 hour
upsert: true, // Overwrite if exists
contentType: 'image/jpeg', // Explicit content type
})
if (error) throw error
return data
}
Upload Form Component
Terminal
'use client'
import { useState } from 'react'
import { createClient } from '@/lib/supabase/client'
export function AvatarUpload({ userId }: { userId: string }) {
const [uploading, setUploading] = useState(false)
const [avatarUrl, setAvatarUrl] = useState<string | null>(null)
const supabase = createClient()
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
setUploading(true)
// Create unique filename
const fileExt = file.name.split('.').pop()
const fileName = `${userId}.${fileExt}`
const { error } = await supabase.storage
.from('avatars')
.upload(fileName, file, { upsert: true })
if (error) {
alert('Error uploading file')
setUploading(false)
return
}
// Get public URL
const { data } = supabase.storage
.from('avatars')
.getPublicUrl(fileName)
setAvatarUrl(data.publicUrl)
setUploading(false)
}
return (
<div>
{avatarUrl && (
<img src={avatarUrl} alt="Avatar" className="w-20 h-20 rounded-full" />
)}
<input
type="file"
accept="image/*"
onChange={handleUpload}
disabled={uploading}
/>
{uploading && <span>Uploading...</span>}
</div>
)
}
Getting File URLs
Public URL
For public buckets:
Terminal
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.jpg
Signed URL (Private)
For private buckets:
Terminal
// Create signed URL valid for 1 hour
const { data, error } = await supabase.storage
.from('documents')
.createSignedUrl('report.pdf', 3600)
if (data) {
console.log(data.signedUrl)
// URL with signature, expires in 1 hour
}
Download File
Terminal
const { data, error } = await supabase.storage
.from('documents')
.download('report.pdf')
if (data) {
// data is a Blob
const url = URL.createObjectURL(data)
window.open(url)
}
Managing Files
List Files
Terminal
const { data, error } = await supabase.storage
.from('documents')
.list('folder-name', {
limit: 100,
offset: 0,
sortBy: { column: 'created_at', order: 'desc' }
})
// data: array of file objects
// { name, id, created_at, updated_at, metadata }
Move/Rename
Terminal
const { data, error } = await supabase.storage
.from('documents')
.move('old-path/file.pdf', 'new-path/file.pdf')
Copy
Terminal
const { data, error } = await supabase.storage
.from('documents')
.copy('original/file.pdf', 'backup/file.pdf')
Delete
Terminal
// Delete single file
const { error } = await supabase.storage
.from('documents')
.remove(['path/to/file.pdf'])
// Delete multiple files
const { error } = await supabase.storage
.from('documents')
.remove([
'file1.pdf',
'file2.pdf',
'folder/file3.pdf'
])
Storage Policies
Enable RLS on Storage
Terminal
-- Enable RLS on storage.objects
ALTER TABLE storage.objects ENABLE ROW LEVEL SECURITY;
Policy Examples
Public Read, Authenticated Write:
Terminal
-- Anyone can view avatars
CREATE POLICY "Public avatar access"
ON storage.objects FOR SELECT
USING (bucket_id = 'avatars');
-- Authenticated users can upload avatars
CREATE POLICY "Authenticated users can upload avatars"
ON storage.objects FOR INSERT
TO authenticated
WITH CHECK (
bucket_id = 'avatars'
AND (storage.foldername(name))[1] = auth.uid()::text
);
Users Own Their Files:
Terminal
-- Users can access own documents
CREATE POLICY "Users access own documents"
ON storage.objects FOR SELECT
TO authenticated
USING (
bucket_id = 'documents'
AND (storage.foldername(name))[1] = auth.uid()::text
);
-- Users can upload to own folder
CREATE POLICY "Users upload own documents"
ON storage.objects FOR INSERT
TO authenticated
WITH CHECK (
bucket_id = 'documents'
AND (storage.foldername(name))[1] = auth.uid()::text
);
-- Users can delete own documents
CREATE POLICY "Users delete own documents"
ON storage.objects FOR DELETE
TO authenticated
USING (
bucket_id = 'documents'
AND (storage.foldername(name))[1] = auth.uid()::text
);
Folder-Based Organization
Terminal
// Organize by user ID
const path = `${userId}/documents/${file.name}`
// Organize by date
const date = new Date().toISOString().split('T')[0]
const path = `${userId}/${date}/${file.name}`
Image Transformations
Supabase can transform images on-the-fly:
Resize
Terminal
const { data } = supabase.storage
.from('avatars')
.getPublicUrl('profile.jpg', {
transform: {
width: 200,
height: 200,
resize: 'cover' // cover, contain, fill
}
})
Quality and Format
Terminal
const { data } = supabase.storage
.from('avatars')
.getPublicUrl('profile.jpg', {
transform: {
width: 400,
quality: 80,
format: 'webp'
}
})
Responsive Images
Terminal
function ResponsiveImage({ path }: { path: string }) {
const supabase = createClient()
const sizes = [200, 400, 800]
const srcSet = sizes.map(size => {
const { data } = supabase.storage
.from('images')
.getPublicUrl(path, {
transform: { width: size }
})
return `${data.publicUrl} ${size}w`
}).join(', ')
return (
<img
srcSet={srcSet}
sizes="(max-width: 640px) 200px, (max-width: 1024px) 400px, 800px"
alt=""
/>
)
}
Server-Side Uploads
Route Handler
Terminal
// app/api/upload/route.ts
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
export async function POST(request: Request) {
const supabase = await createClient()
// Verify authentication
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const formData = await request.formData()
const file = formData.get('file') as File
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
}
// Validate file
const maxSize = 5 * 1024 * 1024 // 5MB
if (file.size > maxSize) {
return NextResponse.json({ error: 'File too large' }, { status: 400 })
}
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']
if (!allowedTypes.includes(file.type)) {
return NextResponse.json({ error: 'Invalid file type' }, { status: 400 })
}
// Upload
const path = `${user.id}/${Date.now()}-${file.name}`
const { data, error } = await supabase.storage
.from('uploads')
.upload(path, file)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
const { data: urlData } = supabase.storage
.from('uploads')
.getPublicUrl(path)
return NextResponse.json({ url: urlData.publicUrl })
}
Complete Upload Pattern
Terminal
// lib/storage.ts
import { createClient } from '@/lib/supabase/client'
interface UploadOptions {
bucket: string
path: string
file: File
onProgress?: (percent: number) => void
}
export async function uploadFile({ bucket, path, file }: UploadOptions) {
const supabase = createClient()
// Validate
const maxSize = 10 * 1024 * 1024 // 10MB
if (file.size > maxSize) {
throw new Error('File size exceeds 10MB limit')
}
// Upload
const { data, error } = await supabase.storage
.from(bucket)
.upload(path, file, {
cacheControl: '3600',
upsert: false
})
if (error) throw error
// Get URL
const { data: urlData } = supabase.storage
.from(bucket)
.getPublicUrl(path)
return {
path: data.path,
url: urlData.publicUrl
}
}
export async function deleteFile(bucket: string, path: string) {
const supabase = createClient()
const { error } = await supabase.storage
.from(bucket)
.remove([path])
if (error) throw error
}
Error Handling
Terminal
async function safeUpload(file: File, path: string) {
const supabase = createClient()
const { data, error } = await supabase.storage
.from('uploads')
.upload(path, file)
if (error) {
if (error.message.includes('already exists')) {
throw new Error('A file with this name already exists')
}
if (error.message.includes('too large')) {
throw new Error('File is too large')
}
if (error.message.includes('not allowed')) {
throw new Error('File type not allowed')
}
throw new Error('Upload failed')
}
return data
}
Summary
- Buckets: Containers for files (public or private)
- Upload:
.upload(path, file, options) - Download:
.download(path)or.getPublicUrl(path) - Signed URLs: For private file access with expiration
- Policies: RLS policies protect storage like database
- Transforms: Resize and convert images on-the-fly
Next Steps
Learn to deploy your Supabase-powered application with Vercel.
Mark this lesson as complete to track your progress