Last updated: August 2026
File uploads are one of the most common features in modern web apps, but implementing them correctly with Supabase Storage can be tricky. Many developers struggle with permission errors, slow uploads, or insecure configurations.
In this article, I'll show you step by step how to implement file uploads with Supabase Storage securely and performantly - from bucket configuration and RLS policies to uploads, downloads, and production-ready frontend integration.
What is Supabase Storage?
Supabase Storage is an S3-compatible object storage system seamlessly integrated into your Supabase project. It offers:
- Row Level Security for secure file access
- Automatic image optimization and transformation
- CDN integration for fast delivery
- Resumable uploads for large files
- Webhook integration for automatic processing
Step 1: Setting Up Storage Bucket
Creating a Bucket
Go to your Supabase Dashboard → Storage and create a new bucket:
-- Option 1: Via Dashboard (recommended for beginners)
-- Storage > Create bucket > "uploads" > Choose Public/Private
-- Option 2: Via SQL
INSERT INTO storage.buckets (id, name, public)
VALUES ('uploads', 'uploads', false);
Important: Only set public to true if all files should be publicly accessible (e.g., for product images).
Configuring RLS Policies
This is the critical part! Without correct RLS policies, uploads won't work:
-- Policy for uploads (INSERT)
CREATE POLICY "Authenticated users can upload files"
ON storage.objects
FOR INSERT
TO authenticated
WITH CHECK (
bucket_id = 'uploads' AND
(storage.foldername(name))[1] = auth.uid()::text
);
-- Policy for reading own files (SELECT)
CREATE POLICY "Users can view own files"
ON storage.objects
FOR SELECT
TO authenticated
USING (
bucket_id = 'uploads' AND
(storage.foldername(name))[1] = auth.uid()::text
);
-- Policy for deleting own files (DELETE)
CREATE POLICY "Users can delete own files"
ON storage.objects
FOR DELETE
TO authenticated
USING (
bucket_id = 'uploads' AND
(storage.foldername(name))[1] = auth.uid()::text
);
-- Policy for updating own files (UPDATE)
CREATE POLICY "Users can update own files"
ON storage.objects
FOR UPDATE
TO authenticated
USING (
bucket_id = 'uploads' AND
(storage.foldername(name))[1] = auth.uid()::text
)
WITH CHECK (
bucket_id = 'uploads' AND
(storage.foldername(name))[1] = auth.uid()::text
);
Explanation: These policies allow users to only manage files in their own folders (folder name = user ID).
The same error text – new row violates row-level security policy – also appears on table inserts. That is a different layer (PostgREST SELECT after INSERT). Fix it here: new row violates row-level security policy. Storage uploads need INSERT and SELECT on storage.objects – see Storage 403 RLS on upload.
Bucket Policies: Size Limits, MIME Types, and Access
RLS on storage.objects decides who may read or write a file. Bucket settings decide what the bucket accepts. Mix them up and you get 413 / 415 errors that look like permission failures.
Create or update a bucket with SQL
This is the exact setup I use for a private user-upload bucket:
-- Private bucket, 10 MB max, images + PDF only
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
VALUES (
'uploads',
'uploads',
false,
10485760,
ARRAY['image/jpeg', 'image/png', 'image/webp', 'application/pdf']
)
ON CONFLICT (id) DO UPDATE SET
public = EXCLUDED.public,
file_size_limit = EXCLUDED.file_size_limit,
allowed_mime_types = EXCLUDED.allowed_mime_types;
-- Tighten an existing public avatar bucket
UPDATE storage.buckets
SET
public = true,
file_size_limit = 2 * 1024 * 1024,
allowed_mime_types = ARRAY['image/jpeg', 'image/png', 'image/webp']
WHERE id = 'avatars';
Dashboard path: Storage → your bucket → Configuration (public toggle, max file size, allowed MIME types). SQL and Dashboard write the same storage.buckets row.
Which policies do you actually need?
| Operation | SQL command | When you need it |
|---|---|---|
| Upload | INSERT |
Always – otherwise 403 on .upload() |
| Read / list / public URL | SELECT |
Always – Storage uses RETURNING *; listing and signed URLs also SELECT |
Overwrite (upsert: true) |
UPDATE |
Avatars, replace-in-place paths |
| Delete | DELETE |
User can remove their own files |
Public bucket ≠ no RLS. Setting public = true only skips the signed-URL step for reads. Uploads still need INSERT (+ SELECT) unless you intentionally allow the anon role – which you should not do for user content.
Step 2: Frontend Implementation
The upload call (JavaScript)
This is the exact API people search for. You need a signed-in user if your policies use authenticated + auth.uid():
const { data: { user } } = await supabase.auth.getUser()
if (!user) throw new Error('Not signed in')
const path = `${user.id}/${file.name}`
const { data, error } = await supabase.storage
.from('uploads')
.upload(path, file, {
cacheControl: '3600',
upsert: false,
contentType: file.type,
})
if (error) {
console.error(error.message, error.statusCode)
throw error
}
console.log('Stored at', data.path)
React/Next.js Upload Component
Here's a complete, production-ready upload component:
// components/FileUpload.tsx
import { useState, useRef } from 'react'
import { createClient } from '@supabase/supabase-js'
import { useUser } from '@/hooks/useUser' // your auth hook
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
interface FileUploadProps {
onUploadComplete: (url: string) => void
allowedTypes?: string[]
maxSize?: number // in MB
bucket?: string
}
export default function FileUpload({
onUploadComplete,
allowedTypes = ['image/jpeg', 'image/png', 'image/webp'],
maxSize = 5,
bucket = 'uploads'
}: FileUploadProps) {
const [uploading, setUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
const [error, setError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const { user } = useUser()
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file || !user) return
setError(null)
// Validation
if (!allowedTypes.includes(file.type)) {
setError(`File type not allowed. Allowed: ${allowedTypes.join(', ')}`)
return
}
if (file.size > maxSize * 1024 * 1024) {
setError(`File too large. Maximum: ${maxSize}MB`)
return
}
await uploadFile(file)
}
const uploadFile = async (file: File) => {
setUploading(true)
setUploadProgress(0)
try {
// Generate unique filename
const fileExt = file.name.split('.').pop()
const fileName = `${Date.now()}-${Math.random().toString(36).substring(2)}.${fileExt}`
const filePath = `${user.id}/${fileName}`
// Upload with progress tracking
const { data, error } = await supabase.storage
.from(bucket)
.upload(filePath, file, {
cacheControl: '3600',
upsert: false
})
if (error) {
throw error
}
// Generate public URL
const { data: { publicUrl } } = supabase.storage
.from(bucket)
.getPublicUrl(filePath)
onUploadComplete(publicUrl)
// Reset input
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
} catch (error: any) {
console.error('Upload error:', error)
setError(error.message || 'Upload failed')
} finally {
setUploading(false)
setUploadProgress(0)
}
}
return (
<div className="space-y-4">
<div className="flex items-center justify-center w-full">
<label className="flex flex-col items-center justify-center w-full h-32 border-2 border-gray-300 border-dashed rounded-lg cursor-pointer bg-gray-50 hover:bg-gray-100">
<div className="flex flex-col items-center justify-center pt-5 pb-6">
<svg className="w-8 h-8 mb-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p className="mb-2 text-sm text-gray-500">
<span className="font-semibold">Click to upload</span> or drag and drop
</p>
<p className="text-xs text-gray-500">
{allowedTypes.join(', ')} (max. {maxSize}MB)
</p>
</div>
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={handleFileSelect}
accept={allowedTypes.join(',')}
disabled={uploading || !user}
/>
</label>
</div>
{uploading && (
<div className="w-full bg-gray-200 rounded-full h-2.5">
<div
className="bg-blue-600 h-2.5 rounded-full transition-all duration-300"
style={{ width: `${uploadProgress}%` }}
></div>
</div>
)}
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md">
{error}
</div>
)}
{uploading && (
<p className="text-sm text-gray-600 text-center">
Uploading... {uploadProgress}%
</p>
)}
</div>
)
}
Usage Example
// pages/profile.tsx
import FileUpload from '@/components/FileUpload'
export default function ProfilePage() {
const handleAvatarUpload = (url: string) => {
console.log('Avatar uploaded:', url)
// Update user profile with new avatar URL
updateUserProfile({ avatar_url: url })
}
return (
<div>
<h1>Edit Profile</h1>
<FileUpload
onUploadComplete={handleAvatarUpload}
allowedTypes={['image/jpeg', 'image/png']}
maxSize={2}
bucket="avatars"
/>
</div>
)
}
Common Upload Scenarios (Copy-Paste)
These are the patterns I use most often in production. Pick the one that matches your use case.
1. Avatar Upload (Overwrite Allowed)
One fixed path per user, so you can overwrite safely with upsert: true:
const path = `${user.id}/avatar.jpg`
const { data, error } = await supabase.storage
.from('avatars')
.upload(path, file, {
upsert: true,
contentType: file.type,
cacheControl: '3600',
})
if (error) throw error
// Bust CDN cache after overwrite
const { data: { publicUrl } } = supabase.storage
.from('avatars')
.getPublicUrl(path)
const avatarUrl = `${publicUrl}?t=${Date.now()}`
Important: Upsert needs an UPDATE policy in addition to INSERT + SELECT. See also Storage 403 RLS on upload.
2. Document / PDF Upload (Private Bucket + Signed URL)
const path = `${user.id}/docs/${crypto.randomUUID()}.pdf`
const { error } = await supabase.storage
.from('documents')
.upload(path, file, {
contentType: 'application/pdf',
upsert: false,
})
if (error) throw error
// Private bucket: never use getPublicUrl for access control
const { data, error: signError } = await supabase.storage
.from('documents')
.createSignedUrl(path, 60 * 60) // 1 hour
if (signError) throw signError
console.log('Temporary download URL:', data.signedUrl)
3. Multiple Images in Parallel
async function uploadImages(files: File[], userId: string) {
const results = await Promise.allSettled(
files.map(async (file, index) => {
const ext = file.name.split('.').pop()
const path = `${userId}/gallery/${Date.now()}-${index}.${ext}`
const { data, error } = await supabase.storage
.from('uploads')
.upload(path, file, { contentType: file.type })
if (error) throw error
return data.path
})
)
const ok = results.filter(r => r.status === 'fulfilled').map(r => r.value)
const failed = results.filter(r => r.status === 'rejected')
return { ok, failed }
}
4. Large File with Resumable Upload (> 6MB)
Standard .upload() works up to several GB, but for reliability above ~6MB use the resumable (TUS) API:
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(url, anonKey)
async function uploadLargeFile(file: File, userId: string) {
const path = `${userId}/videos/${file.name}`
const { data, error } = await supabase.storage
.from('uploads')
.upload(path, file, {
upsert: false,
// supabase-js uses resumable uploads automatically for larger files
// when the runtime supports it; otherwise use tus-js-client
})
if (error) throw error
return data.path
}
If you need progress UI for very large files, use tus-js-client against Supabase's TUS endpoint (/storage/v1/upload/resumable) with your user's access token.
Public URLs vs Signed URLs
getPublicUrl() always returns a string – even on a private bucket. That does not mean the file is reachable. Use a public URL only when the bucket is public; use a signed URL when it is private.
| Need | Bucket | API |
|---|---|---|
| Product images, avatars, anything you would put on a CDN | public = true |
getPublicUrl(path) |
| Invoices, ID scans, chat attachments, anything user-private | public = false |
createSignedUrl(path, expiresIn) |
Public URL after upload
const { data, error } = await supabase.storage
.from('avatars')
.upload(`${user.id}/avatar.jpg`, file, { upsert: true, contentType: file.type })
if (error) throw error
const { data: { publicUrl } } = supabase.storage
.from('avatars')
.getPublicUrl(`${user.id}/avatar.jpg`)
// Optional: bust CDN cache after overwrite
const avatarUrl = `${publicUrl}?t=${Date.now()}`
Signed URL for a private file
const path = `${user.id}/docs/${file.name}`
await supabase.storage.from('documents').upload(path, file, {
contentType: file.type,
upsert: false,
})
// expiresIn is seconds. Caller must pass SELECT RLS (usually the owner).
const { data, error } = await supabase.storage
.from('documents')
.createSignedUrl(path, 60 * 60) // 1 hour
if (error) throw error
console.log(data.signedUrl)
// Force a download filename instead of inline preview
const { data: dl } = await supabase.storage
.from('documents')
.createSignedUrl(path, 300, { download: file.name })
Several signed URLs at once
const { data, error } = await supabase.storage
.from('documents')
.createSignedUrls(
['user-id/a.pdf', 'user-id/b.pdf'],
60 * 15
)
if (error) throw error
data.forEach((row) => {
if (row.error) console.error(row.error)
else console.log(row.signedUrl)
})
If createSignedUrl returns 403, the session cannot SELECT that object – same root cause as a failed upload. Fix the Storage policies, or see the table-level twin error on new row violates row-level security policy.
Downloading Files from Supabase Storage
Getting files back out is the half of the job most tutorials skip. There are three ways, and the right one depends on the bucket:
| Method | Bucket | Use case |
|---|---|---|
getPublicUrl(path) |
public | <img> tags, CDN-cached assets |
createSignedUrl(path, expiresIn) |
private | Temporary browser access (preview, email link) |
download(path) |
private | Fetch the file into your app as a Blob |
Download a file as a Blob
.download() fetches the object through the authenticated client. The caller must pass the SELECT policy on storage.objects – the same policy the upload's RETURNING * already needs:
const { data, error } = await supabase.storage
.from('documents')
.download(`${user.id}/report.pdf`)
if (error) {
// Typical shape: { status: 400, message: 'Object not found' }
// – wrong path OR blocked by RLS (see Problem 9 below)
console.error(error.message)
throw error
}
// data is a Blob
const objectUrl = URL.createObjectURL(data)
// e.g. show a PDF preview: <iframe src={objectUrl} />
Trigger a "Save as" download in the browser
async function saveFile(path: string, filename: string) {
const { data, error } = await supabase.storage
.from('documents')
.download(path)
if (error) throw error
const url = URL.createObjectURL(data)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
No Blob handling needed if a signed URL is enough: createSignedUrl(path, 300, { download: 'report.pdf' }) makes the browser save the file instead of previewing it (see Public URLs vs Signed URLs).
Download a resized image (transform)
Transformations work on download too – useful when you stored the original and only need a thumbnail:
const { data, error } = await supabase.storage
.from('uploads')
.download(`${user.id}/photo.jpg`, {
transform: {
width: 800,
quality: 75,
},
})
Server-side download (API Route / Edge Function)
// Node: turn the Blob into a Buffer for further processing
const { data, error } = await supabase.storage
.from('documents')
.download(path)
if (error) throw error
const buffer = Buffer.from(await data.arrayBuffer())
// pipe into a PDF parser, attach to an email, re-upload elsewhere …
RLS applies to downloads too. .download() and createSignedUrl() both require SELECT on storage.objects – but a blocked download returns Object not found instead of 403, because Storage does not reveal whether a file exists. If downloads fail, re-check the SELECT policy from Step 1; background on the table-level variant of this error: new row violates row-level security policy.
Step 3: Advanced Features
Progress Tracking for Large Files
For real progress tracking, you need to chunk the upload:
const uploadFileWithProgress = async (file: File) => {
const chunkSize = 1024 * 1024 // 1MB chunks
const totalChunks = Math.ceil(file.size / chunkSize)
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
const start = chunkIndex * chunkSize
const end = Math.min(start + chunkSize, file.size)
const chunk = file.slice(start, end)
// Upload chunk...
const progress = ((chunkIndex + 1) / totalChunks) * 100
setUploadProgress(progress)
}
}
Image Resize Before Upload
const resizeImage = (file: File, maxWidth: number, maxHeight: number): Promise<File> => {
return new Promise((resolve) => {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')!
const img = new Image()
img.onload = () => {
// Calculate aspect ratio
const ratio = Math.min(maxWidth / img.width, maxHeight / img.height)
canvas.width = img.width * ratio
canvas.height = img.height * ratio
// Draw image on canvas
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
// Convert back to File
canvas.toBlob((blob) => {
if (blob) {
const resizedFile = new File([blob], file.name, {
type: file.type,
lastModified: Date.now()
})
resolve(resizedFile)
}
}, file.type, 0.9)
}
img.src = URL.createObjectURL(file)
})
}
// Usage:
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
// Resize image before upload
const resizedFile = await resizeImage(file, 1920, 1080)
await uploadFile(resizedFile)
}
Multiple File Upload
const uploadMultipleFiles = async (files: FileList) => {
const uploadPromises = Array.from(files).map(async (file, index) => {
const fileExt = file.name.split('.').pop()
const fileName = `${Date.now()}-${index}.${fileExt}`
const filePath = `${user.id}/${fileName}`
return supabase.storage
.from('uploads')
.upload(filePath, file)
})
try {
const results = await Promise.all(uploadPromises)
console.log('All files uploaded:', results)
} catch (error) {
console.error('Some uploads failed:', error)
}
}
Step 4: Server-Side Upload (Next.js API Route)
For sensitive uploads or server-side processing:
// pages/api/upload.ts
import { createServerSupabaseClient } from '@supabase/auth-helpers-nextjs'
import { NextApiRequest, NextApiResponse } from 'next'
import formidable from 'formidable'
import fs from 'fs'
export const config = {
api: {
bodyParser: false, // Disable body parsing for file uploads
},
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' })
}
const supabase = createServerSupabaseClient({ req, res })
// Check authentication
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (!user) {
return res.status(401).json({ error: 'Unauthorized' })
}
try {
const form = formidable({
maxFileSize: 10 * 1024 * 1024, // 10MB
keepExtensions: true,
})
const [fields, files] = await form.parse(req)
const file = Array.isArray(files.file) ? files.file[0] : files.file
if (!file) {
return res.status(400).json({ error: 'No file provided' })
}
// Read file
const fileBuffer = fs.readFileSync(file.filepath)
// Generate unique filename
const fileExt = file.originalFilename?.split('.').pop()
const fileName = `${Date.now()}-${Math.random().toString(36).substring(2)}.${fileExt}`
const filePath = `${user.id}/${fileName}`
// Upload to Supabase
const { data, error } = await supabase.storage
.from('uploads')
.upload(filePath, fileBuffer, {
contentType: file.mimetype || 'application/octet-stream',
cacheControl: '3600'
})
if (error) {
throw error
}
// Get public URL
const { data: { publicUrl } } = supabase.storage
.from('uploads')
.getPublicUrl(filePath)
// Clean up temp file
fs.unlinkSync(file.filepath)
res.status(200).json({
message: 'Upload successful',
url: publicUrl,
path: data.path
})
} catch (error: any) {
console.error('Upload error:', error)
res.status(500).json({ error: error.message || 'Upload failed' })
}
}
Step 5: File Management
Listing Files
const getUserFiles = async () => {
const { data, error } = await supabase.storage
.from('uploads')
.list(user.id, {
limit: 100,
offset: 0,
sortBy: { column: 'created_at', order: 'desc' }
})
if (error) {
console.error('Error listing files:', error)
return []
}
return data
}
Deleting Files
const deleteFile = async (filePath: string) => {
const { error } = await supabase.storage
.from('uploads')
.remove([filePath])
if (error) {
console.error('Error deleting file:', error)
return false
}
return true
}
Image Transformations
Supabase offers automatic image transformations:
// Generate different sizes
const getImageUrl = (path: string, options?: {
width?: number
height?: number
quality?: number
format?: 'webp' | 'jpeg' | 'png'
}) => {
const { data } = supabase.storage
.from('uploads')
.getPublicUrl(path, {
transform: {
width: options?.width,
height: options?.height,
quality: options?.quality,
format: options?.format
}
})
return data.publicUrl
}
// Usage:
const thumbnailUrl = getImageUrl('user123/image.jpg', {
width: 200,
height: 200,
quality: 80,
format: 'webp'
})
Common Upload Errors
Most Supabase Storage failures are one of a handful of status codes. Map the code first – then fix the matching policy, limit, or URL type.
| Code | Typical message | Fix |
|---|---|---|
400 |
Invalid JWT / JWT expired | Sign in again; do not upload with a missing session |
400 |
Object not found (on .download()) |
Path wrong, or SELECT policy blocks the object – RLS hides its existence |
403 |
new row violates row-level security policy | INSERT + SELECT on storage.objects; path must match auth.uid() |
404 |
Bucket not found | Bucket name in .from('…') must match Dashboard |
409 |
The resource already exists | Unique path, or upsert: true plus an UPDATE policy |
413 |
The object exceeded the maximum allowed size | Raise file_size_limit or compress client-side |
415 |
mime type … is not supported | Add the MIME type to allowed_mime_types |
function explainStorageError(error) {
const code = String(error.statusCode ?? '')
switch (code) {
case '400':
return 'JWT missing or expired – sign in, then retry the upload.'
case '403':
return 'RLS blocked INSERT or the RETURNING SELECT. Add INSERT + SELECT on storage.objects.'
case '404':
return 'Bucket name does not match the Dashboard.'
case '409':
return 'Path exists. Use a unique path or { upsert: true } plus an UPDATE policy.'
case '413':
return 'File larger than the bucket/global size limit.'
case '415':
return 'MIME type is not on the bucket allow-list.'
default:
return error.message ?? 'Unknown storage error'
}
}
const { error } = await supabase.storage.from('uploads').upload(path, file)
if (error) throw new Error(explainStorageError(error))
Problem 1: 403 / "new row violates row-level security policy"
This message is used in two places. Do not mix them up:
- Tables (
supabase.from('projects').insert(…)) – PostgREST also SELECTs the new row. Fix: add a SELECT policy orreturning: 'minimal'. Full write-up: new row violates row-level security policy. - Storage uploads – the Storage API runs
INSERT … RETURNING *onstorage.objects. You need INSERT and SELECT. Walkthrough: Fix Storage 403 RLS on upload.
Solution for uploads: the user must be authenticated, the first folder must equal auth.uid(), and both policies must exist:
-- Debug: Check if user is authenticated
SELECT auth.uid(), auth.role();
-- Debug: Check bucket configuration
SELECT * FROM storage.buckets WHERE id = 'uploads';
Problem 2: Upload works but image doesn't display
Solution: Check the public URL and CORS settings:
// Generate correct URL
const { data: { publicUrl }, error } = supabase.storage
.from('uploads')
.getPublicUrl(filePath)
if (error) {
console.error('Error getting public URL:', error)
}
Problem 3: Slow Uploads
Solutions:
- Compress files before upload
- Chunked upload for large files
- Enable CDN (automatic with Supabase)
- Client-side image resize
Problem 4: 413 "The object exceeded the maximum allowed size"
Cause: The file is larger than the bucket or global file size limit in Storage settings.
const { data, error } = await supabase.storage
.from('uploads')
.upload(path, file)
if (error) {
// Typical shape:
// { statusCode: '413', message: 'The object exceeded the maximum allowed size' }
console.error(error.statusCode, error.message)
}
Solution: Dashboard → Storage → Configuration / bucket settings → raise Max file size. Also validate on the client before upload:
const MAX_MB = 10
if (file.size > MAX_MB * 1024 * 1024) {
throw new Error(`File too large (max ${MAX_MB}MB)`)
}
Problem 5: 415 "mime type … is not supported"
Cause: The bucket has an allowedMimeTypes list and your file's type is not on it.
// Explicit contentType helps when the extension is unusual
const { error } = await supabase.storage
.from('uploads')
.upload(path, file, { contentType: file.type || 'application/octet-stream' })
// error.message ≈ 'mime type application/zip is not supported'
Solution: In the bucket settings, add the MIME types you need (e.g. image/jpeg, image/png, application/pdf) – or clear the allow-list if you validate types in your own code.
Problem 6: 409 / 400 "The resource already exists"
Cause: You upload to a path that already exists and upsert is false (the default).
// Option A: unique path every time
const path = `${user.id}/${crypto.randomUUID()}-${file.name}`
// Option B: overwrite
await supabase.storage.from('uploads').upload(path, file, { upsert: true })
// Requires UPDATE policy on storage.objects
Problem 7: 404 "Bucket not found"
Solution: The bucket name in .from('…') must match the Dashboard exactly (including spelling). Create the bucket first, then deploy frontend code that references it.
SELECT id, name, public, file_size_limit, allowed_mime_types
FROM storage.buckets;
Problem 8: 400 "Invalid JWT" / upload with no session
Cause: .upload() runs as anon because there is no access token (expired session, SSR cookie not forwarded, or you created the client without the user session).
const { data: { session } } = await supabase.auth.getSession()
if (!session) {
throw new Error('No session – sign in before uploading')
}
console.log('role', session.user.role, 'uid', session.user.id)
const { error } = await supabase.storage
.from('uploads')
.upload(`${session.user.id}/${file.name}`, file)
On the server (Next.js Route Handler), create the client from the request cookies – a second createClient(url, anonKey) without the user JWT will always hit anon policies.
Problem 9: 400 "Object not found" on download
Cause: Either the path really does not exist – or your SELECT policy blocks the object. Storage deliberately answers with "Object not found" instead of 403 so it does not leak which files exist.
const { data, error } = await supabase.storage
.from('documents')
.download('user-id/report.pdf')
// error: { status: 400, message: 'Object not found' }
Solution: First rule out a path problem, then check the policy:
- No leading slash, and the bucket name is not part of the path:
.from('documents').download('uid/file.pdf'), not.download('documents/uid/file.pdf'). - Verify the object exists (SQL Editor runs as service role and bypasses RLS):
SELECT name FROM storage.objects
WHERE bucket_id = 'documents'
AND name LIKE 'USER-UUID/%';
- If the object exists, the SELECT policy from Step 1 does not match – typically because the first folder is not the user's
auth.uid(), or the download runs without a session. Same root cause as the upload 403: new row violates row-level security policy.
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', 'video/ogg']
}
const validateFileType = (file: File, category: keyof typeof ALLOWED_FILE_TYPES) => {
return ALLOWED_FILE_TYPES[category].includes(file.type)
}
2. File Size Limits
const MAX_FILE_SIZES = {
image: 5 * 1024 * 1024, // 5MB
document: 10 * 1024 * 1024, // 10MB
video: 100 * 1024 * 1024 // 100MB
}
3. Malware Scanning
For production apps, you should integrate a malware scanner:
// Example with ClamAV
const scanFile = async (fileBuffer: Buffer) => {
// Integration with malware scanner
// return scanResult
}
Performance Optimization
1. Lazy Loading for File Lists
const FileList = () => {
const [files, setFiles] = useState([])
const [loading, setLoading] = useState(false)
const [hasMore, setHasMore] = useState(true)
const loadMoreFiles = async () => {
if (loading || !hasMore) return
setLoading(true)
const newFiles = await getUserFiles(files.length)
if (newFiles.length === 0) {
setHasMore(false)
} else {
setFiles(prev => [...prev, ...newFiles])
}
setLoading(false)
}
return (
<div>
{files.map(file => (
<FileItem key={file.id} file={file} />
))}
{hasMore && (
<button onClick={loadMoreFiles} disabled={loading}>
{loading ? 'Loading...' : 'Load More'}
</button>
)}
</div>
)
}
2. Image Caching Strategy
// Service Worker for aggressive caching
const cacheImages = async (urls: string[]) => {
const cache = await caches.open('supabase-images-v1')
await cache.addAll(urls)
}
3. Progressive Image Loading
const ProgressiveImage = ({ src, placeholder, alt }: {
src: string
placeholder: string
alt: string
}) => {
const [imageLoaded, setImageLoaded] = useState(false)
const [imageSrc, setImageSrc] = useState(placeholder)
useEffect(() => {
const img = new Image()
img.onload = () => {
setImageSrc(src)
setImageLoaded(true)
}
img.src = src
}, [src])
return (
<img
src={imageSrc}
alt={alt}
className={`transition-opacity duration-300 ${
imageLoaded ? 'opacity-100' : 'opacity-50'
}`}
/>
)
}
Production Deployment Checklist
1. Environment Variables
# .env.local
NEXT_PUBLIC_SUPABASE_URL=your_supabase_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key
2. Bucket Configuration
-- Enable RLS on storage.objects
ALTER TABLE storage.objects ENABLE ROW LEVEL SECURITY;
-- Set appropriate bucket policies
UPDATE storage.buckets
SET public = false
WHERE id = 'uploads';
3. CDN Configuration
// Configure custom domain for storage
const getStorageUrl = (path: string) => {
const baseUrl = process.env.NODE_ENV === 'production'
? 'https://your-custom-domain.com/storage/v1/object/public'
: supabase.storage.from('uploads').getPublicUrl('').data.publicUrl
return `${baseUrl}/${path}`
}
4. Monitoring and Analytics
// Track upload metrics
const trackUpload = (fileSize: number, fileType: string, duration: number) => {
// Analytics integration
analytics.track('file_upload', {
file_size: fileSize,
file_type: fileType,
upload_duration: duration
})
}
Advanced Use Cases
1. Direct Upload to S3 (Bypass Supabase)
For very large files or special requirements:
const generatePresignedUrl = async (filename: string) => {
const response = await fetch('/api/generate-presigned-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename })
})
return response.json()
}
const uploadToS3Direct = async (file: File, presignedUrl: string) => {
await fetch(presignedUrl, {
method: 'PUT',
body: file,
headers: {
'Content-Type': file.type
}
})
}
2. Video Processing Pipeline
// Trigger video processing after upload
const handleVideoUpload = async (filePath: string) => {
// Trigger Edge Function for video processing
await supabase.functions.invoke('process-video', {
body: { filePath }
})
}
3. Collaborative File Sharing
// Share files with other users
const shareFile = async (filePath: string, userIds: string[]) => {
const { data, error } = await supabase
.from('file_shares')
.insert(
userIds.map(userId => ({
file_path: filePath,
shared_with: userId,
shared_by: user.id,
permissions: 'read'
}))
)
return { data, error }
}
Frequently Asked Questions About Supabase Storage Uploads
How do I upload a file to Supabase Storage with JavaScript / React?
Use supabase.storage.from('bucket').upload(path, file) after the user is signed in. Put files under {user.id}/… if your RLS policies check the first folder against auth.uid().
Why does Supabase Storage upload return 403 / "new row violates row-level security policy"?
Almost always missing or mismatched RLS on storage.objects. You need INSERT and SELECT (Storage uses RETURNING *). Full walkthrough: Fix Storage 403 RLS on upload. The same message on a table insert is a different layer: new row violates row-level security policy.
How do I download a file from Supabase Storage?
From a public bucket: use getPublicUrl(path). From a private bucket: supabase.storage.from('bucket').download(path) returns the file as a Blob, or use createSignedUrl(path, expiresIn) for a temporary link. All of them require a SELECT policy on storage.objects. Full examples: Downloading Files from Supabase Storage.
How do I get a public URL after upload?
Call getPublicUrl(path) – but only if the bucket is public. For private buckets use createSignedUrl(path, expiresIn) instead. Full examples: Public URLs vs Signed URLs.
How do I upload to a private Supabase bucket?
Keep public = false, restrict RLS to the owner, upload with the user's session, then share access via signed URLs (not public URLs).
How do I set the Supabase Storage file size limit?
Project Storage settings (global) and optionally per-bucket limits. A 413 error means your file exceeded one of those limits.
How do I allow only images (MIME types) in a bucket?
Set allowedMimeTypes on the bucket (e.g. image/jpeg, image/png, image/webp). Unsupported types return 415.
How do I overwrite / upsert a file in Supabase Storage?
Pass { upsert: true } and add an UPDATE policy that matches your INSERT path rules.
How do I upload multiple files to Supabase Storage?
Map over the FileList and Promise.all / Promise.allSettled individual .upload() calls. Prefer unique paths so one failure does not block the rest.
Can I upload without authentication (anon)?
Only if you intentionally create policies for the anon role – and you should still validate size/type server-side. For user content, prefer authenticated + folder = auth.uid().
Supabase Storage vs direct S3 upload – which should I use?
Use Supabase Storage when you want RLS, auth, and image transforms in one stack. Use presigned S3 only for special throughput or existing AWS pipelines.
Conclusion
Supabase Storage provides a powerful and flexible solution for file uploads. The key points:
- RLS policies are critical - nothing works without them
- Bucket policies (size limit, MIME types, public flag) are separate from RLS – 413/415 are not permission errors
- Public vs signed URLs -
getPublicUrlfor public buckets,createSignedUrlfor private files - Downloads need SELECT too -
.download()returns a Blob; a blocked download says "Object not found", not 403 - Client-side validation improves UX, but server-side is mandatory
- Image transformations save bandwidth and improve performance
- Chunked upload implementation for large files
- Security-first approach with file type and size validation
With these implementations, you have a production-ready file upload solution that's secure, performant, and user-friendly.
Additional Resources
- Fix RLS insert errors: new row violates row-level security policy
- Supabase Storage 403 RLS on upload
- permission denied for table (42501)
- PGRST116: multiple (or no) rows returned
- Supabase: infinite recursion detected in policy
- Supabase Auth: Invalid login credentials
- Supabase Edge Functions: Invalid JWT / 401
- Supabase Storage Documentation
- Storage RLS Policies Guide
- Image Transformations
- Storage REST API Reference
Having trouble with your file upload implementation? Leave a comment and I'll be happy to help!
Comments