Membangun fitur unggah file yang benar: mengapa file tidak boleh disimpan di filesystem lokal atau database, validasi ukuran dan tipe, serta upload langsung ke object storage S3/R2 via presigned URL dengan streaming untuk file besar.

Hampir semua produk akhirnya butuh file: foto profil, lampiran dokumen, gambar catatan. Episode ini membahas file upload & storage dengan pola yang benar — karena "menyimpan file di folder project" adalah keputusan yang tampak mudah di awal, tetapi menjadi bencana saat deploy dan diskalakan.
Mengapa masalah ini spesifik fullstack? Karena melibatkan dua sisi sekaligus: frontend (mengunggah dari browser dengan UX yang baik) dan backend (validasi, keamanan, dan tempat penyimpanan yang benar). Kesalahan di salah satu sisi langsung terasa di sisi lain.
Dua pilihan "mudah" yang sering dipakai pemula, dan alasan keduanya salah:
| Penyimpanan | Masalah |
|---|---|
Filesystem lokal (public/uploads/) | Hilang saat redeploy, tidak bisa diskalakan, tidak bisa di-share antar instance |
| Database (BLOB) | Query lambat, database membengkak, backup membesar tanpa kendali |
Solusi standar industri: object storage — layanan yang dirancang khusus untuk file tak berubah (S3, Cloudflare R2, GCS). File disimpan sebagai objek dengan URL, database hanya menyimpan referensi (path/URL), dan biaya penyimpanannya jauh lebih murah.
Note
Aturan praktis: database menyimpan metadata, object storage menyimpan bytes. Kolom fileUrl di tabel Note itu wajar; kolom berisi binary file itu kesalahan. Skema kalian tetap bersih dan query tetap cepat.
Jangan pernah upload file melewati server aplikasi kalian (kecuali file kecil yang butuh proses khusus). Alur standar dengan presigned URL:
Upload melewati browser → storage langsung tanpa menyentuh server aplikasi. Keuntungannya: server tidak menjadi bottleneck, tidak ada batas body upload, dan skala tidak bergantung pada kapasitas server kalian.
Validasi wajib di dua tempat:
Cek cepat agar user tahu sejak awal:
const MAX_SIZE = 10 * 1024 * 1024 // 10 MB
function validateFile(file: File) {
if (!["image/png", "image/jpeg", "image/webp"].includes(file.type)) {
return "Hanya PNG, JPEG, atau WebP"
}
if (file.size > MAX_SIZE) {
return "Maksimal 10 MB"
}
return null
}Validasi browser bisa dilewati. Di server, periksa lagi — dan jangan percaya file.type dari client yang bisa dipalsukan:
import { z } from "zod"
const uploadSchema = z.object({
fileName: z.string().min(1).max(255),
contentType: z.enum(["image/png", "image/jpeg", "image/webp"]),
size: z.number().int().max(10 * 1024 * 1024),
})
export async function POST(request: Request) {
const parsed = uploadSchema.safeParse(await request.json())
if (!parsed.success) {
return Response.json({ error: "File tidak valid" }, { status: 400 })
}
const key = createUploadKey(parsed.data.contentType)
const url = await getPresignedUrl(key, parsed.data.contentType)
return Response.json({ url, key })
}Warning
Jangan pernah memakai ekstensi file atau Content-Type dari client sebagai satu-satunya penentu tipe. Gunakan magic bytes (inspeksi isi file) di server untuk file yang rawan (misal executable, HTML tersembunyi). File yang diunggah dan disajikan langsung bisa menjadi vektor XSS — kita bahas detail di episode 16.
Untuk file besar, fetch biasa gagal total jika koneksi putus di tengah. Solusinya upload multi-bagian (multipart) yang di-resume dari bagian terakhir. AWS SDK menyediakan ini dengan mudah:
import { S3Client, Upload } from "@aws-sdk/client-s3"
import { env } from "@/lib/env"
const s3 = new S3Client({
region: env.STORAGE_REGION,
endpoint: env.STORAGE_ENDPOINT,
credentials: {
accessKeyId: env.STORAGE_ACCESS_KEY,
secretAccessKey: env.STORAGE_SECRET_KEY,
},
})
export async function uploadObject(key: string, body: ReadableStream) {
const upload = new Upload({
client: s3,
params: { Bucket: env.STORAGE_BUCKET, Key: key, Body: body },
partSize: 5 * 1024 * 1024, // bagian 5 MB, bisa di-resume
})
await upload.done()
}Alasan pemilihan R2 (Cloudflare) untuk contoh: tanpa biaya egress, satu region global, dan API-nya kompatibel S3 — jadi kode di atas bisa berpindah ke AWS/GCS tanpa perubahan besar.
Setelah upload, simpan key di database dan tampilkan lewat URL. Satu detail keamanan: untuk file user (bukan aset publik), jangan ekspos URL publik permanen — beri akses terbatas:
export async function getSignedViewUrl(key: string) {
return await getSignedUrl(s3, new GetObjectCommand({
Bucket: env.STORAGE_BUCKET,
Key: key,
}), { expiresIn: 3600 })
}URL ini berlaku 1 jam, lalu harus dibuat ulang — kombinasi dengan validasi akses di server action (episode 6) menjaga privasi file.
Selesaikan dengan urutan berikut:
POST /api/upload yang memvalidasi dan mengembalikan presigned URL.key file di database via server action.curl -s -X POST http://localhost:3000/api/upload \
-H "Content-Type: application/json" \
-d '{"fileName":"foto.png","contentType":"image/png","size":2048}'Response harus berisi url dan key — bukti alur presigned bekerja.
Inti yang harus dibawa pulang:
Di episode 13 selanjutnya kita membahas background jobs & queues — queue, cron, dan long-running tasks — agar kerja berat tidak memblokir request pengguna. Sampai jumpa di episode 13!