Untuk upload file ke Supabase Storage dari Next.js, aplikasi perlu menyiapkan bucket, client Supabase, validasi file, serta policy Storage yang membatasi akses berdasarkan identitas pengguna. Bucket sebaiknya dibuat privat agar file tidak dapat diakses hanya dengan menebak URL publik.

Pola yang digunakan dalam panduan ini adalah menyimpan file di dalam folder dengan nama user.id. Dengan pola tersebut, policy dapat memastikan setiap pengguna hanya dapat mengunggah, membaca, dan menghapus file miliknya sendiri.

Prasyarat dan struktur penyimpanan

Siapkan project Next.js, project Supabase, serta package JavaScript client Supabase:

npm install @supabase/supabase-js

Jika aplikasi menggunakan autentikasi Supabase pada Next.js, konfigurasi client browser dan server sebaiknya dipisahkan. Gunakan hanya NEXT_PUBLIC_SUPABASE_URL dan NEXT_PUBLIC_SUPABASE_ANON_KEY di browser. Jangan pernah mengekspos service role key ke client.

NEXT_PUBLIC_SUPABASE_URL=https://project-id.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key

Contoh struktur path file yang digunakan adalah:

documents/<user-id>/<random-id>-<original-file-name>

Folder tersebut bukan folder fisik seperti pada filesystem biasa. Nama path disimpan pada kolom name di tabel storage.objects, sehingga policy dapat memeriksanya.

Membuat bucket Supabase Storage

Buat bucket bernama documents melalui menu Storage di dashboard Supabase. Pilih bucket private, bukan public. Bucket privat mengharuskan aplikasi menggunakan session pengguna atau signed URL ketika file dibaca.

Supabase juga menyediakan pengaturan batas ukuran dan MIME type pada konfigurasi bucket. Pengaturan tersebut dapat dibuat dari dashboard atau melalui SQL, misalnya:

insert into storage.buckets
  (id, name, public, file_size_limit, allowed_mime_types)
values
  (
    'documents',
    'documents',
    false,
    10485760,
    array['application/pdf', 'image/jpeg', 'image/png']
  );

Nilai 10485760 adalah 10 MiB. Batas di bucket merupakan lapisan validasi tambahan, tetapi tetap lakukan validasi di client dan server. Validasi client berguna untuk pengalaman pengguna, sedangkan validasi server dan policy diperlukan untuk keamanan.

Membuat policy Storage berbasis pengguna

Supabase Storage menggunakan Row Level Security pada tabel storage.objects. Policy berikut mengizinkan pengguna yang sudah login mengakses file pada folder yang namanya sama dengan auth.uid().

create policy "Users can upload files to own folder"
on storage.objects
for insert
to authenticated
with check (
  bucket_id = 'documents'
  and (storage.foldername(name))[1] = (select auth.uid()::text)
);

create policy "Users can read files from own folder"
on storage.objects
for select
to authenticated
using (
  bucket_id = 'documents'
  and (storage.foldername(name))[1] = (select auth.uid()::text)
);

create policy "Users can delete files from own folder"
on storage.objects
for delete
to authenticated
using (
  bucket_id = 'documents'
  and (storage.foldername(name))[1] = (select auth.uid()::text)
);

Policy insert menggunakan with check karena memvalidasi baris baru. Policy select dan delete menggunakan using karena memvalidasi baris yang sudah ada. Jika aplikasi juga melakukan update atau overwrite file, tambahkan policy update dengan pemeriksaan folder yang sama.

Catatan: policy Storage hanya efektif jika request menggunakan session pengguna. Service role key melewati RLS, sehingga harus digunakan secara terbatas pada server yang benar-benar dipercaya.

Membuat client Supabase di browser

Buat helper sederhana untuk komponen client Next.js:

import { createClient } from '@supabase/supabase-js'

export const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
)

Pada aplikasi yang memakai SSR dan autentikasi Supabase, gunakan helper SSR resmi dari ekosistem Supabase agar cookie session diproses dengan benar. Jangan mengandalkan client browser untuk operasi yang memerlukan secret atau hak akses administratif.

Upload file menggunakan FormData dan validasi

Komponen berikut memakai FormData dari elemen form. File tidak perlu diubah menjadi Base64 karena Supabase Storage dapat menerima objek File secara langsung. Nama file dibuat unik agar dua upload tidak menimpa file secara tidak sengaja.

'use client'

import { useState } from 'react'
import { supabase } from '@/lib/supabase-browser'

const MAX_SIZE = 10 * 1024 * 1024
const ALLOWED_TYPES = new Set([
  'application/pdf',
  'image/jpeg',
  'image/png'
])

export default function FileUpload({ userId }) {
  const [message, setMessage] = useState('')
  const [busy, setBusy] = useState(false)

  async function handleSubmit(event) {
    event.preventDefault()
    const formData = new FormData(event.currentTarget)
    const file = formData.get('file')

    if (!(file instanceof File) || file.size === 0) {
      setMessage('Pilih file terlebih dahulu.')
      return
    }

    if (!ALLOWED_TYPES.has(file.type)) {
      setMessage('Tipe file tidak didukung.')
      return
    }

    if (file.size > MAX_SIZE) {
      setMessage('Ukuran file maksimal 10 MiB.')
      return
    }

    setBusy(true)
    setMessage('')

    const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_')
    const path = `${userId}/${crypto.randomUUID()}-${safeName}`
    const { error } = await supabase.storage
      .from('documents')
      .upload(path, file, {
        contentType: file.type,
        upsert: false
      })

    setBusy(false)
    setMessage(error ? error.message : 'File berhasil diunggah.')
    if (!error) event.currentTarget.reset()
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="file" type="file" accept="application/pdf,image/jpeg,image/png" />
      <button type="submit" disabled={busy}>
        {busy ? 'Mengunggah...' : 'Upload'}
      </button>
      <p>{message}</p>
    </form>
  )
}

Nilai userId pada contoh harus berasal dari session yang sudah terverifikasi, bukan dari input bebas pengguna. Alternatif yang lebih aman adalah mengambil user aktif di server lalu meneruskan identitas yang sudah diverifikasi ke komponen atau endpoint upload.

Upload melalui endpoint server

Upload dari browser langsung ke Storage lebih efisien karena file tidak melewati server Next.js. Namun, endpoint server berguna jika aplikasi perlu melakukan validasi tambahan, mencatat metadata, atau menggabungkan upload dengan proses backend. Endpoint harus menggunakan server client berbasis cookie session, bukan menerima userId dari request sebagai sumber kebenaran.

import { NextResponse } from 'next/server'
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'

export async function POST(request) {
  const cookieStore = await cookies()
  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll()
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            )
          } catch {
            // Cookie dapat diatur oleh middleware pada sebagian konfigurasi SSR.
          }
        }
      }
    }
  )

  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')
  if (!(file instanceof File) || file.size === 0) {
    return NextResponse.json({ error: 'File tidak valid' }, { status: 400 })
  }

  const allowed = new Set(['application/pdf', 'image/jpeg', 'image/png'])
  if (!allowed.has(file.type) || file.size > 10 * 1024 * 1024) {
    return NextResponse.json({ error: 'Tipe atau ukuran file ditolak' }, { status: 400 })
  }

  const path = `${user.id}/${crypto.randomUUID()}-${file.name.replace(/[^a-zA-Z0-9._-]/g, '_')}`
  const { error } = await supabase.storage
    .from('documents')
    .upload(path, file, { contentType: file.type, upsert: false })

  if (error) {
    return NextResponse.json({ error: error.message }, { status: 400 })
  }

  return NextResponse.json({ path }, { status: 201 })
}

Detail implementasi helper server dapat berbeda sesuai konfigurasi SSR project. Intinya, endpoint harus membaca session dari cookie, memanggil auth.getUser(), dan membentuk path dari user.id yang diverifikasi.

Menampilkan daftar file dan signed URL

Untuk bucket privat, operasi list hanya mengembalikan metadata file sesuai policy select. Agar file dapat ditampilkan atau diunduh, buat signed URL dengan masa berlaku terbatas.

async function listUserFiles(userId) {
  const { data, error } = await supabase.storage
    .from('documents')
    .list(userId, {
      limit: 100,
      offset: 0,
      sortBy: { column: 'created_at', order: 'desc' }
    })

  if (error) throw error

  const files = await Promise.all(
    data
      .filter((item) => item.name !== '.emptyFolderPlaceholder')
      .map(async (item) => {
        const path = `${userId}/${item.name}`
        const { data: signed, error: signedError } = await supabase.storage
          .from('documents')
          .createSignedUrl(path, 300)

        if (signedError) throw signedError
        return { ...item, path, url: signed.signedUrl }
      })
  )

  return files
}

Angka 300 berarti URL berlaku selama 300 detik. Signed URL bersifat bearer token: siapa pun yang memperoleh URL tersebut dapat menggunakannya sampai masa berlaku berakhir. Karena itu, jangan menyimpan URL permanen di database atau mengirimkannya ke pihak yang tidak berwenang.

Membuat signed URL dari server

Jika daftar file dan pembuatan URL dilakukan melalui server, verifikasi session terlebih dahulu. Gunakan client server berbasis cookie agar policy select tetap berlaku.

const { data: signed, error } = await supabase.storage
  .from('documents')
  .createSignedUrl(path, 300)

if (error) {
  return NextResponse.json({ error: error.message }, { status: 400 })
}

return NextResponse.json({ url: signed.signedUrl })

Untuk file yang akan diakses berulang kali tanpa kontrol pengguna, bucket public memang lebih sederhana. Namun, bucket public tidak cocok untuk dokumen privat karena siapa pun yang memiliki URL dapat mengakses aset tersebut. Untuk dokumen pengguna, invoice, identitas, atau data internal, gunakan bucket privat dan signed URL.

Menghapus file

Gunakan path lengkap yang berasal dari data aplikasi, bukan nama file yang diketik ulang pengguna. Policy delete akan mencegah penghapusan file milik pengguna lain.

async function deleteUserFile(path) {
  const { error } = await supabase.storage
    .from('documents')
    .remove([path])

  if (error) throw error
}

Contoh pemanggilan:

await deleteUserFile('user-id/550e8400-e29b-41d4-a716-446655440000-report.pdf')

Jika penghapusan dipicu dari server, tetap panggil auth.getUser() dan gunakan client dengan session pengguna. Service role hanya diperlukan untuk operasi administratif seperti pembersihan lintas pengguna, dan endpoint yang menggunakannya harus memiliki autentikasi serta otorisasi tambahan.

Validasi dan keamanan yang perlu diperhatikan

  • Jangan percaya MIME type dari client. Nilai file.type dapat dimanipulasi. Untuk kebutuhan keamanan tinggi, lakukan pemeriksaan signature atau magic bytes di server dan pertimbangkan pemindaian antivirus.
  • Batasi ukuran di beberapa lapisan. Validasi di UI memberi umpan balik cepat, validasi endpoint mencegah request tidak valid, dan konfigurasi bucket menjadi pengaman tambahan.
  • Gunakan nama file acak. UUID mengurangi risiko collision dan mencegah nama file asli menjadi identifier yang mudah ditebak.
  • Jangan membuat bucket privat menjadi public hanya untuk mengatasi error URL. Periksa session, policy, path, dan cara pembuatan signed URL.
  • Pastikan policy memeriksa bucket dan folder. Policy yang hanya memeriksa auth.uid() tanpa membatasi bucket_id dapat memberikan akses lebih luas dari yang dimaksudkan.
  • Hindari path traversal. Sanitasi nama file dan jangan mengizinkan pengguna mengirim path arbitrer yang dapat menempatkan file di folder pengguna lain.
  • Atur pagination. list hanya mengembalikan sebagian data jika jumlah file banyak. Gunakan limit dan offset sesuai kebutuhan antarmuka.

Debugging masalah upload

  • 401 atau policy violation: periksa apakah pengguna sudah login, cookie session tersedia, dan request menggunakan client yang benar.
  • 403 atau object tidak terlihat: pastikan path diawali folder user yang sama dengan auth.uid() dan policy select sudah dibuat.
  • Ukuran atau tipe ditolak: cocokkan validasi client, endpoint, dan konfigurasi bucket. Perhatikan bahwa ekstensi file saja bukan validasi MIME type.
  • Signed URL gagal: gunakan path object yang tepat, bukan URL lama atau nama file tanpa folder user.
  • File tertimpa: gunakan UUID pada path dan pertahankan upsert: false jika overwrite tidak diperlukan.

Ringkasan arsitektur yang disarankan

Untuk sebagian besar aplikasi, gunakan upload langsung dari client ke bucket privat dengan anon key dan policy berbasis session. Client melakukan validasi awal, mengunggah ke path user.id, lalu membuat signed URL ketika file perlu ditampilkan. Gunakan endpoint server ketika diperlukan validasi isi file, pencatatan metadata, pemrosesan tambahan, atau integrasi dengan layanan lain.

Dengan kombinasi bucket privat, FormData, batas tipe dan ukuran file, policy Storage berbasis auth.uid(), serta signed URL berumur pendek, aplikasi Next.js dapat mengelola upload, daftar, dan penghapusan file tanpa mengekspos aset privat secara permanen.