Developer Guide
Supabase Storage: Complete Guide to File Uploads and Management
Master Supabase Storage with this comprehensive guide. Learn file uploads, image optimization, CDN delivery, security policies, and advanced patterns for production applications.
2026-02-19
Supabase Storage: Complete Guide to File Uploads and Management#
Supabase Storage provides S3-compatible object storage with built-in CDN, image transformations, and security policies. This guide covers everything from basic uploads to production-ready patterns.
Architecture Overview#
Client → Supabase Storage API → S3-Compatible Storage → CDN
↓ ↓
Upload Fast Delivery
↓ ↓
RLS Policies ← ← ← ← ← ← ← ← ← ← ← ← ← ← ← ← ← ← ← ← ← ←
Setup and Configuration#
1. Create Storage Buckets#
-- Create a public bucket for avatars
INSERT INTO storage.buckets (id, name, public)
VALUES ('avatars', 'avatars', true);
-- Create a private bucket for documents
INSERT INTO storage.buckets (id, name, public)
VALUES ('documents', 'documents', false);
2. Storage Policies#
-- Allow users to upload their own avatar
CREATE POLICY "Users can upload their own avatar"
ON storage.objects FOR INSERT
WITH CHECK (
bucket_id = 'avatars' AND
auth.uid()::text = (storage.foldername(name))[1]
);
-- Allow users to read their own avatar
CREATE POLICY "Users can read their own avatar"
ON storage.objects FOR SELECT
USING (
bucket_id = 'avatars' AND
auth.uid()::text = (storage.foldername(name))[1]
);
-- Allow users to update their own avatar
CREATE POLICY "Users can update their own avatar"
ON storage.objects FOR UPDATE
USING (
bucket_id = 'avatars' AND
auth.uid()::text = (storage.foldername(name))[1]
);
-- Allow users to delete their own avatar
CREATE POLICY "Users can delete their own avatar"
ON storage.objects FOR DELETE
USING (
bucket_id = 'avatars' AND
auth.uid()::text = (storage.foldername(name))[1]
);
Basic File Upload#
Simple Upload Component#
'use client';
import { useState } from 'react';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
export default function FileUpload() {
const [uploading, setUploading] = useState(false);
const [fileUrl, setFileUrl] = useState<string | null>(null);
const supabase = createClientComponentClient();
const uploadFile = async (event: React.ChangeEvent<HTMLInputElement>) => {
try {
setUploading(true);
if (!event.target.files || event.target.files.length === 0) {
throw new Error('You must select a file to upload.');
}
const file = event.target.files[0];
const fileExt = file.name.split('.').pop();
const fileName = `${Math.random()}.${fileExt}`;
const filePath = `${fileName}`;
const { error: uploadError } = await supabase.storage
.from('avatars')
.upload(filePath, file);
if (uploadError) {
throw uploadError;
}
// Get public URL
const { data } = supabase.storage
.from('avatars')
.getPublicUrl(filePath);
setFileUrl(data.publicUrl);
} catch (error) {
alert(error.message);
} finally {
setUploading(false);
}
};
return (
<div>
<input
type="file"
onChange={uploadFile}
disabled={uploading}
/>
{uploading && <p>Uploading...</p>}
{fileUrl && (
<div>
<p>File uploaded successfully!</p>
<img src={fileUrl} alt="Uploaded file" />
</div>
)}
</div>
);
}
Advanced Upload Patterns#
1. Avatar Upload with Preview#
'use client';
import { useState, useRef } from 'react';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
import Image from 'next/image';
export default function AvatarUpload({ userId, currentAvatar }) {
const [avatar, setAvatar] = useState(currentAvatar);
const [uploading, setUploading] = useState(false);
const [preview, setPreview] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const supabase = createClientComponentClient();
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
// Validate file type
if (!file.type.startsWith('image/')) {
alert('Please select an image file');
return;
}
// Validate file size (max 2MB)
if (file.size > 2 * 1024 * 1024) {
alert('File size must be less than 2MB');
return;
}
// Create preview
const reader = new FileReader();
reader.onloadend = () => {
setPreview(reader.result as string);
};
reader.readAsDataURL(file);
};
const uploadAvatar = async () => {
try {
setUploading(true);
const file = fileInputRef.current?.files?.[0];
if (!file) return;
// Delete old avatar if exists
if (avatar) {
const oldPath = avatar.split('/').pop();
await supabase.storage
.from('avatars')
.remove([`${userId}/${oldPath}`]);
}
// Upload new avatar
const fileExt = file.name.split('.').pop();
const fileName = `${Date.now()}.${fileExt}`;
const filePath = `${userId}/${fileName}`;
const { error: uploadError } = await supabase.storage
.from('avatars')
.upload(filePath, file, {
cacheControl: '3600',
upsert: false
});
if (uploadError) throw uploadError;
// Get public URL
const { data } = supabase.storage
.from('avatars')
.getPublicUrl(filePath);
// Update user profile
const { error: updateError } = await supabase
.from('profiles')
.update({ avatar_url: data.publicUrl })
.eq('id', userId);
if (updateError) throw updateError;
setAvatar(data.publicUrl);
setPreview(null);
alert('Avatar updated successfully!');
} catch (error) {
alert(error.message);
} finally {
setUploading(false);
}
};
return (
<div className="flex flex-col items-center gap-4">
<div className="relative w-32 h-32 rounded-full overflow-hidden bg-gray-200">
{(preview || avatar) && (
<Image
src={preview || avatar}
alt="Avatar"
fill
className="object-cover"
/>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileSelect}
className="hidden"
/>
<div className="flex gap-2">
<button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Choose File
</button>
{preview && (
<button
onClick={uploadAvatar}
disabled={uploading}
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600"
>
{uploading ? 'Uploading...' : 'Upload'}
</button>
)}
</div>
</div>
);
}
2. Drag and Drop Upload#
'use client';
import { useState, useCallback } from 'react';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
import { Upload } from 'lucide-react';
export default function DragDropUpload({ userId, onUploadComplete }) {
const [isDragging, setIsDragging] = useState(false);
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const supabase = createClientComponentClient();
const handleDrag = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDragIn = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragOut = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
await uploadFiles(files);
}, []);
const uploadFiles = async (files: File[]) => {
setUploading(true);
const uploadedUrls: string[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
const fileExt = file.name.split('.').pop();
const fileName = `${Date.now()}-${i}.${fileExt}`;
const filePath = `${userId}/${fileName}`;
try {
const { error } = await supabase.storage
.from('documents')
.upload(filePath, file, {
cacheControl: '3600',
upsert: false
});
if (error) throw error;
const { data } = supabase.storage
.from('documents')
.getPublicUrl(filePath);
uploadedUrls.push(data.publicUrl);
setProgress(((i + 1) / files.length) * 100);
} catch (error) {
console.error('Upload error:', error);
}
}
setUploading(false);
setProgress(0);
onUploadComplete(uploadedUrls);
};
return (
<div
onDragEnter={handleDragIn}
onDragLeave={handleDragOut}
onDragOver={handleDrag}
onDrop={handleDrop}
className={`
border-2 border-dashed rounded-lg p-8 text-center
transition-colors cursor-pointer
${isDragging ? 'border-blue-500 bg-blue-50' : 'border-gray-300'}
${uploading ? 'opacity-50 pointer-events-none' : ''}
`}
>
<Upload className="w-12 h-12 mx-auto mb-4 text-gray-400" />
{uploading ? (
<div>
<p className="text-gray-600 mb-2">Uploading...</p>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-500 h-2 rounded-full transition-all"
style={{ width: `${progress}%` }}
/>
</div>
</div>
) : (
<div>
<p className="text-gray-600 mb-2">
Drag and drop files here, or click to select
</p>
<p className="text-sm text-gray-400">
Supports: Images, PDFs, Documents
</p>
</div>
)}
</div>
);
}
3. Multiple File Upload with Progress#
'use client';
import { useState } from 'react';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
import { X, CheckCircle, AlertCircle } from 'lucide-react';
interface UploadFile {
file: File;
progress: number;
status: 'pending' | 'uploading' | 'success' | 'error';
url?: string;
error?: string;
}
export default function MultiFileUpload({ userId }) {
const [files, setFiles] = useState<UploadFile[]>([]);
const supabase = createClientComponentClient();
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = Array.from(e.target.files || []);
const newFiles: UploadFile[] = selectedFiles.map(file => ({
file,
progress: 0,
status: 'pending'
}));
setFiles(prev => [...prev, ...newFiles]);
};
const uploadFile = async (index: number) => {
const fileData = files[index];
setFiles(prev => prev.map((f, i) =>
i === index ? { ...f, status: 'uploading' } : f
));
try {
const file = fileData.file;
const fileExt = file.name.split('.').pop();
const fileName = `${Date.now()}-${file.name}`;
const filePath = `${userId}/${fileName}`;
// Simulate progress (in real app, use XMLHttpRequest for actual progress)
const progressInterval = setInterval(() => {
setFiles(prev => prev.map((f, i) =>
i === index && f.progress < 90
? { ...f, progress: f.progress + 10 }
: f
));
}, 200);
const { error } = await supabase.storage
.from('documents')
.upload(filePath, file);
clearInterval(progressInterval);
if (error) throw error;
const { data } = supabase.storage
.from('documents')
.getPublicUrl(filePath);
setFiles(prev => prev.map((f, i) =>
i === index
? { ...f, progress: 100, status: 'success', url: data.publicUrl }
: f
));
} catch (error) {
setFiles(prev => prev.map((f, i) =>
i === index
? { ...f, status: 'error', error: error.message }
: f
));
}
};
const uploadAll = async () => {
for (let i = 0; i < files.length; i++) {
if (files[i].status === 'pending') {
await uploadFile(i);
}
}
};
const removeFile = (index: number) => {
setFiles(prev => prev.filter((_, i) => i !== index));
};
return (
<div className="space-y-4">
<input
type="file"
multiple
onChange={handleFileSelect}
className="block w-full text-sm text-gray-500
file:mr-4 file:py-2 file:px-4
file:rounded-full file:border-0
file:text-sm file:font-semibold
file:bg-blue-50 file:text-blue-700
hover:file:bg-blue-100"
/>
{files.length > 0 && (
<button
onClick={uploadAll}
disabled={files.every(f => f.status !== 'pending')}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
>
Upload All
</button>
)}
<div className="space-y-2">
{files.map((fileData, index) => (
<div
key={index}
className="flex items-center gap-3 p-3 border rounded-lg"
>
<div className="flex-1">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium">
{fileData.file.name}
</span>
<button
onClick={() => removeFile(index)}
className="text-gray-400 hover:text-gray-600"
>
<X className="w-4 h-4" />
</button>
</div>
{fileData.status === 'uploading' && (
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-500 h-2 rounded-full transition-all"
style={{ width: `${fileData.progress}%` }}
/>
</div>
)}
{fileData.status === 'success' && (
<div className="flex items-center gap-2 text-green-600 text-sm">
<CheckCircle className="w-4 h-4" />
<span>Uploaded successfully</span>
</div>
)}
{fileData.status === 'error' && (
<div className="flex items-center gap-2 text-red-600 text-sm">
<AlertCircle className="w-4 h-4" />
<span>{fileData.error}</span>
</div>
)}
</div>
</div>
))}
</div>
</div>
);
}
Image Optimization#
1. Image Transformations#
// Get optimized image URL
const getOptimizedImageUrl = (path: string, options: {
width?: number;
height?: number;
quality?: number;
format?: 'webp' | 'avif';
}) => {
const { data } = supabase.storage
.from('images')
.getPublicUrl(path, {
transform: {
width: options.width,
height: options.height,
quality: options.quality || 80,
format: options.format || 'webp'
}
});
return data.publicUrl;
};
// Usage
const thumbnailUrl = getOptimizedImageUrl('user-photo.jpg', {
width: 200,
height: 200,
quality: 75,
format: 'webp'
});
2. Responsive Images Component#
'use client';
import Image from 'next/image';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
interface ResponsiveImageProps {
path: string;
alt: string;
sizes?: string;
}
export default function ResponsiveImage({
path,
alt,
sizes = '100vw'
}: ResponsiveImageProps) {
const supabase = createClientComponentClient();
const getImageUrl = (width: number) => {
const { data } = supabase.storage
.from('images')
.getPublicUrl(path, {
transform: {
width,
quality: 80,
format: 'webp'
}
});
return data.publicUrl;
};
return (
<Image
src={getImageUrl(1200)}
alt={alt}
width={1200}
height={800}
sizes={sizes}
srcSet={`
${getImageUrl(400)} 400w,
${getImageUrl(800)} 800w,
${getImageUrl(1200)} 1200w,
${getImageUrl(1600)} 1600w
`}
className="w-full h-auto"
/>
);
}
Security Best Practices#
1. File Type Validation#
const ALLOWED_FILE_TYPES = {
images: ['image/jpeg', 'image/png', 'image/webp', 'image/gif'],
documents: ['application/pdf', 'application/msword', 'text/plain'],
videos: ['video/mp4', 'video/webm']
};
const validateFileType = (file: File, category: keyof typeof ALLOWED_FILE_TYPES) => {
return ALLOWED_FILE_TYPES[category].includes(file.type);
};
// Usage
if (!validateFileType(file, 'images')) {
throw new Error('Invalid file type. Only images are allowed.');
}
2. File Size Limits#
const MAX_FILE_SIZE = {
avatar: 2 * 1024 * 1024, // 2MB
document: 10 * 1024 * 1024, // 10MB
video: 100 * 1024 * 1024 // 100MB
};
const validateFileSize = (file: File, category: keyof typeof MAX_FILE_SIZE) => {
return file.size <= MAX_FILE_SIZE[category];
};
3. Secure File Names#
const generateSecureFileName = (originalName: string, userId: string) => {
const ext = originalName.split('.').pop();
const timestamp = Date.now();
const random = Math.random().toString(36).substring(7);
return `${userId}/${timestamp}-${random}.${ext}`;
};
Advanced Patterns#
1. Signed URLs for Private Files#
// Generate signed URL (expires in 1 hour)
const { data, error } = await supabase.storage
.from('private-documents')
.createSignedUrl('document.pdf', 3600);
if (data) {
console.log('Signed URL:', data.signedUrl);
}
2. Batch Operations#
// Delete multiple files
const filesToDelete = ['file1.jpg', 'file2.jpg', 'file3.jpg'];
const { data, error } = await supabase.storage
.from('images')
.remove(filesToDelete);
3. Move/Copy Files#
// Move file
const { data, error } = await supabase.storage
.from('images')
.move('old-path/image.jpg', 'new-path/image.jpg');
// Copy file
const { data, error } = await supabase.storage
.from('images')
.copy('source/image.jpg', 'destination/image.jpg');
Production Checklist#
- [ ] Configure storage policies for all buckets
- [ ] Implement file type validation
- [ ] Set file size limits
- [ ] Use secure file naming
- [ ] Enable CDN caching
- [ ] Implement image optimization
- [ ] Add upload progress indicators
- [ ] Handle upload errors gracefully
- [ ] Clean up unused files periodically
- [ ] Monitor storage usage
- [ ] Implement rate limiting
- [ ] Add virus scanning for user uploads
Conclusion#
Supabase Storage provides a complete solution for file management with built-in security, CDN delivery, and image transformations. Key takeaways:
- Always implement RLS policies
- Validate file types and sizes
- Use image transformations for optimization
- Implement proper error handling
- Clean up unused files regularly
Related Guides#
- Supabase Authentication & Authorization
- Supabase Realtime Complete Guide
- Building SaaS with Next.js and Supabase
Last updated: February 19, 2026