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

TypeAccessUse Case
PublicAnyone with URLProfile pics, logos
PrivateAuth requiredDocuments, sensitive files

Creating Buckets

Via Dashboard

  1. Go to StorageNew Bucket
  2. Set name and privacy settings
  3. 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