Build a File Upload Service: Supabase Storage + Next.js
Build14 min read·March 18, 2026·--

Build a File Upload Service: Supabase Storage + Next.js

Build a production file upload service with Supabase Storage and Next.js — image resizing, file validation, CDN delivery, and access control. Free until you hit 1GB of stored files.

@
@kivorablog
March 18, 2026
Share

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


ToolPurposeFree LimitCost After
Supabase StorageFile storage + CDN1GB storage, 5GB transfer$0.021/GB
Next.js 14Frontend + APIFree foreverFree forever
VercelHostingUnlimited deploys$20/month
Sharp (via API)Image resizingFreeFree

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 CaseTransformSize Saved
Avatar100x100 cover~95% smaller
Card thumbnail400x300 cover~85% smaller
Hero image1200x600 contain~50% smaller
Full originalNo transformOriginal



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 UsedMonthly UsersSupabase CostEquivalent AWS Cost
1GB100$0 (free)$1.50
10GB1,000$0 (free tier covers it)$12
50GB5,000$25 (Pro plan)$55
200GB20,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


MistakeWhat HappensFix
Storing files in the databaseDB bloats, queries slow downUse Supabase Storage, store only URLs in DB
No file type validationUsers upload executables, scriptsWhitelist allowed MIME types
No file size limitSomeone uploads a 2GB videoEnforce max size (5MB for images, 50MB for docs)
Using private buckets without signed URLsImages don't load for usersUse getPublicUrl or generateSignedUrl
Not compressing images before upload5MB JPEG displayed as 5MBUse Supabase image transforms: ?width=800&quality=80
Read more on Kivora Blog

Read more on Kivora Blog

Get started →