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

Last updated: February 19, 2026

Frequently Asked Questions

|

Have more questions? Contact us