Why Build Your Own File Upload Service
AWS S3 costs $0.023/GB/month for storage plus $0.09/GB for data transfer. For a Nigerian e-commerce site with 10,000 product images (5GB), that's $460/year before you even factor in transfer costs. And you still need CloudFront for CDN, Lambda for image processing, and IAM for access control.
Supabase Storage gives you S3-compatible storage with built-in CDN, row-level access control, and image transformations — all in the free tier. One platform. One API. Zero config.

The Free Stack
| Tool | Purpose | Free Limit | Cost After |
|---|---|---|---|
| Supabase Storage | File storage + CDN | 1GB storage, 5GB transfer | $0.021/GB |
| Next.js 14 | Frontend + API | Free forever | Free forever |
| Vercel | Hosting | Unlimited deploys | $20/month |
| Sharp (via API) | Image resizing | Free | Free |
Total cost: $0/month on free tier
Step 1: Set Up Supabase Storage
-- In Supabase SQL Editor, create a storage bucket policy
-- First, create the bucket via the Dashboard:
-- Storage → New Bucket → Name: "uploads", Public: true
-- Allow authenticated users to upload
create policy "Authenticated users can upload"
on storage.objects for insert
with check (auth.uid() = owner);
-- Allow public read access
create policy "Public read access"
on storage.objects for select
using (bucket_id = 'uploads');
-- Allow users to delete their own files
create policy "Users delete own files"
on storage.objects for delete
using (auth.uid() = owner);
Initialize the Supabase Client
// lib/supabase.js
import { createClient } from '@supabase/supabase-js'
export const supabasePublic = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
)
export const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.SUPABASE_SERVICE_KEY
)
Step 2: Build the Upload Component
// components/FileUpload.jsx
'use client'
import { useState, useRef } from 'react'
import { supabasePublic } from '@/lib/supabase'
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf']
const MAX_SIZE = 5 * 1024 * 1024 // 5MB
export default function FileUpload({ userId, onUploadComplete }) {
const [uploading, setUploading] = useState(false)
const [progress, setProgress] = useState(0)
const [error, setError] = useState('')
const fileInput = useRef(null)
async function handleUpload(e) {
const file = e.target.files[0]
if (!file) return
setError('')
if (!ALLOWED_TYPES.includes(file.type)) {
setError('Only JPEG, PNG, WebP, and PDF files are allowed')
return
}
if (file.size > MAX_SIZE) {
setError('File size must be under 5MB')
return
}
setUploading(true)
setProgress(10)
// Generate unique filename
const ext = file.name.split('.').pop()
const filename = userId + '/' + Date.now() + '.' + ext
const { data, error: uploadError } = await supabasePublic.storage
.from('uploads')
.upload(filename, file, {
cacheControl: '3600',
upsert: false
})
if (uploadError) {
setError(uploadError.message)
setUploading(false)
return
}
setProgress(80)
// Get the public URL
const { data: urlData } = supabasePublic.storage
.from('uploads')
.getPublicUrl(filename)
setProgress(100)
setUploading(false)
onUploadComplete({
name: file.name,
url: urlData.publicUrl,
size: file.size,
type: file.type
})
}
return (
<div className="space-y-3">
<div
onClick={() => fileInput.current?.click()}
className="border-2 border-dashed border-gray-300 rounded-xl p-8 text-center cursor-pointer hover:border-blue-400 transition">
<p className="text-gray-500">Click to upload or drag and drop</p>
<p className="text-xs text-gray-400 mt-1">JPEG, PNG, WebP, PDF — Max 5MB</p>
</div>
<input ref={fileInput} type="file" onChange={handleUpload} className="hidden" accept=".jpg,.jpeg,.png,.webp,.pdf" />
{uploading && (
<div className="w-full bg-gray-200 rounded-full h-2">
<div className="bg-blue-600 h-2 rounded-full transition-all" style={{ width: progress + '%' }} />
</div>
)}
{error && <p className="text-red-500 text-sm">{error}</p>}
</div>
)
}
Step 3: Server-Side Upload With Validation
For sensitive uploads, use a server-side route instead of client-side:
// app/api/upload/route.js
import { supabaseAdmin } from '@/lib/supabase'
export async function POST(req) {
const formData = await req.formData()
const file = formData.get('file')
const userId = formData.get('userId')
if (!file) {
return Response.json({ error: 'No file provided' }, { status: 400 })
}
// Validate file type
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf']
if (!allowedTypes.includes(file.type)) {
return Response.json({ error: 'File type not allowed' }, { status: 400 })
}
// Validate file size (5MB)
if (file.size > 5 * 1024 * 1024) {
return Response.json({ error: 'File too large (max 5MB)' }, { status: 400 })
}
// Upload to Supabase
const ext = file.name.split('.').pop()
const filename = userId + '/' + Date.now() + '.' + ext
const bytes = await file.arrayBuffer()
const { data, error } = await supabaseAdmin.storage
.from('uploads')
.upload(filename, bytes, {
contentType: file.type,
cacheControl: '3600'
})
if (error) {
return Response.json({ error: error.message }, { status: 500 })
}
const { data: urlData } = supabaseAdmin.storage
.from('uploads')
.getPublicUrl(filename)
return Response.json({ url: urlData.publicUrl, filename, size: file.size })
}
Step 4: Image Transformation With Supabase
Supabase Storage has built-in image transformations — no Sharp or ImageMagick needed:
// Resize an image on-the-fly via URL parameters
const thumbnailUrl = supabasePublic.storage
.from('uploads')
.getPublicUrl('user123/image.jpg', {
transform: {
width: 200,
height: 200,
resize: 'cover' // 'contain' or 'fill' also available
}
}).data.publicUrl
// The URL looks like:
// https://project.supabase.co/storage/v1/render/image/public/uploads/user123/image.jpg?width=200&height=200&resize=cover
This means you upload one high-res image and serve different sizes everywhere:
| Use Case | Transform | Size Saved |
|---|---|---|
| Avatar | 100x100 cover | ~95% smaller |
| Card thumbnail | 400x300 cover | ~85% smaller |
| Hero image | 1200x600 contain | ~50% smaller |
| Full original | No transform | Original |
Step 5: List and Manage Files
// app/api/files/route.js
import { supabaseAdmin } from '@/lib/supabase'
export async function GET(req) {
const userId = new URL(req.url).searchParams.get('userId')
const { data, error } = await supabaseAdmin.storage
.from('uploads')
.list(userId, {
limit: 100,
sortBy: { column: 'created_at', order: 'desc' }
})
if (error) {
return Response.json({ error: error.message }, { status: 500 })
}
const files = data.map(file => ({
name: file.name,
url: supabaseAdmin.storage.from('uploads').getPublicUrl(userId + '/' + file.name).data.publicUrl,
size: file.metadata?.size,
createdAt: file.created_at
}))
return Response.json({ files })
}
export async function DELETE(req) {
const { filename } = await req.json()
const { error } = await supabaseAdmin.storage
.from('uploads')
.remove([filename])
if (error) {
return Response.json({ error: error.message }, { status: 500 })
}
return Response.json({ ok: true })
}
Step 6: Cost Analysis as You Scale
| Storage Used | Monthly Users | Supabase Cost | Equivalent AWS Cost |
|---|---|---|---|
| 1GB | 100 | $0 (free) | $1.50 |
| 10GB | 1,000 | $0 (free tier covers it) | $12 |
| 50GB | 5,000 | $25 (Pro plan) | $55 |
| 200GB | 20,000 | $25 + storage overages | $200+ |
For most African startups, you won't exceed the free tier until you have thousands of active users. By then, you're generating revenue to cover the $25/month.
Common Mistakes
| Mistake | What Happens | Fix |
|---|---|---|
| Storing files in the database | DB bloats, queries slow down | Use Supabase Storage, store only URLs in DB |
| No file type validation | Users upload executables, scripts | Whitelist allowed MIME types |
| No file size limit | Someone uploads a 2GB video | Enforce max size (5MB for images, 50MB for docs) |
| Using private buckets without signed URLs | Images don't load for users | Use getPublicUrl or generateSignedUrl |
| Not compressing images before upload | 5MB JPEG displayed as 5MB | Use Supabase image transforms: ?width=800&quality=80 |

