Implementasi upload file yang naif — form multipart ke route API Next.js, lalu re-upload ke S3 — tidak skalabel. Setiap file melewati server Anda, mengonsumsi memori, bandwidth, dan waktu eksekusi. Pola produksi menggunakan presigned URL S3: server Anda menghasilkan URL bertanda sementara, klien mengunggah langsung ke S3, dan server Anda hanya mencatat referensinya.
Alurnya memiliki tiga langkah: (1) Klien meminta presigned URL dari route API Next.js Anda, mengirimkan hanya metadata file (nama, ukuran, tipe konten). (2) Route API Anda memanggil AWS SDK untuk menghasilkan presigned PUT URL — URL bertanda sementara yang memungkinkan pemegangnya mengunggah satu file ke kunci S3 tertentu. (3) Klien menggunakan presigned URL untuk PUT file langsung ke S3 dari browser — server Anda tidak pernah menyentuh byte file. Setelah upload, klien memberi tahu API Anda untuk mencatat kunci S3 di database.
Presigned URL sangat powerful tapi server harus memvalidasi sebelum menghasilkannya: autentikasi pengguna, validasi batas ukuran file, validasi tipe konten yang diizinkan, dan hasilkan kunci S3 yang dikontrol server (jangan biarkan klien memilih jalur file — gunakan uuid() + ekstensi, bukan nama file asli). Tetapkan kedaluwarsa presigned URL singkat — 60 detik cukup untuk klien memulai upload.
Naive approach (DON'T do this at scale):
Browser ──── multipart form ──── Next.js API ──── re-upload ──── S3
(file bytes flow through your server = memory + bandwidth = bottleneck)
Production approach (presigned URL):
┌─────────┐ 1. POST /api/upload-url ┌──────────────┐
│ Browser │ {name, size, type} ──────►│ Next.js API │
│ │◄────── presignedUrl ─────── │ (server-side)│
│ │ │ AWS SDK call │
│ │ 2. PUT presignedUrl └──────────────┘
│ │ (file bytes direct) │
│ │ ──────────────────────────────────►│ S3
│ │◄── 200 OK (upload complete) │
│ │ │
│ │ 3. POST /api/confirm │
│ │ {s3Key, fileId} ┌──────────────┐
│ │ ────────────────────────► │ PostgreSQL │
└─────────┘ │ (record ref) │
└──────────────┘Dari membangun sistem manajemen dokumen ERP: gunakan kondisi server-side S3 dalam presigned URL untuk menegakkan batas di tingkat S3, bukan hanya di validasi API Anda. Tambahkan kondisi ContentLengthRange sehingga bahkan jika validasi Anda dilewati, S3 akan menolak upload di luar kisaran ukuran yang diizinkan. Pendekatan defense-in-depth ini menangkap beberapa percobaan penyalahgunaan di produksi.
Pembuatan presigned URL berada di Server Action atau Route Handler. Instal @aws-sdk/client-s3 dan @aws-sdk/s3-request-presigner. Jangan pernah mengekspos kredensial AWS ke klien — semua panggilan AWS SDK terjadi di sisi server. PutObjectCommand dikombinasikan dengan getSignedUrl() dari s3-request-presigner menghasilkan presigned URL. Tetapkan kedaluwarsa yang sesuai (60-300 detik) dan kembalikan hanya URL ke klien.
# Install AWS SDK v3 (modular)
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
# app/api/upload-url/route.ts
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
import { auth } from "@/lib/auth"
import { nanoid } from "nanoid"
const s3 = new S3Client({
region: process.env.AWS_REGION!,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
})
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "application/pdf"]
const MAX_SIZE_BYTES = 10 * 1024 * 1024 // 10MB
export async function POST(req: Request) {
const session = await auth()
if (!session) return Response.json({ error: "Unauthorized" }, { status: 401 })
const { name, size, contentType } = await req.json()
// Server-side validation (never trust client)
if (!ALLOWED_TYPES.includes(contentType))
return Response.json({ error: "File type not allowed" }, { status: 400 })
if (size > MAX_SIZE_BYTES)
return Response.json({ error: "File too large" }, { status: 400 })
// Server-controlled S3 key (never let client choose the path)
const ext = name.split(".").pop()?.toLowerCase()
const key = `uploads/${session.user.id}/${nanoid()}.${ext}`
const command = new PutObjectCommand({
Bucket: process.env.AWS_S3_BUCKET!,
Key: key,
ContentType: contentType,
ContentLength: size,
})
const presignedUrl = await getSignedUrl(s3, command, { expiresIn: 60 })
return Response.json({ presignedUrl, key })
}
# Client-side upload
const { presignedUrl, key } = await fetch("/api/upload-url", {
method: "POST",
body: JSON.stringify({ name: file.name, size: file.size, contentType: file.type }),
}).then(r => r.json())
await fetch(presignedUrl, {
method: "PUT",
body: file,
headers: { "Content-Type": file.type },
})
// Then confirm with your API
await fetch("/api/upload-confirm", { method: "POST", body: JSON.stringify({ key }) })Permintaan PUT presigned tunggal berfungsi untuk file hingga 5GB, tapi untuk file di atas ~100MB, gunakan API multipart upload S3. Klien membagi file menjadi chunk 5-10MB, meminta presigned URL untuk setiap bagian, mengunggah bagian secara paralel, lalu memanggil CompleteMultipartUpload. ETag dari setiap respons bagian diperlukan untuk menyelesaikan upload — tapi CORS harus mengekspos header ETag dari S3, atau browser Anda tidak bisa membacanya.
Upload langsung browser-ke-S3 memerlukan konfigurasi CORS yang tepat di bucket S3 Anda. Tanpanya, browser memblokir permintaan PUT. Konfigurasi CORS minimum: izinkan PUT (dan OPTIONS untuk preflight) dari domain aplikasi Anda, ekspos ETag dalam header untuk pelacakan multipart upload, dan tetapkan MaxAgeSeconds yang wajar untuk caching preflight. Uji CORS di staging sebelum produksi — error CORS mudah didiagnosis secara lokal tapi sering muncul berbeda di seluruh lingkungan.
Setelah klien menyelesaikan upload S3, ia harus memberi tahu API Anda untuk mencatat kunci S3 di database Anda. Ini adalah langkah konfirmasi — tanpanya, file yang diunggah ke S3 tidak memiliki catatan database dan secara efektif terlantar. Saya menggunakan pendekatan dua fase: buat catatan upload yang tertunda di PostgreSQL saat membuat presigned URL, lalu tandai dikonfirmasi saat klien melaporkan keberhasilan.
Pola presigned URL ini adalah pendekatan standar untuk aplikasi Next.js produksi apa pun. Saya menggunakan @aws-sdk/client-s3 v3 (SDK modular), menghasilkan presigned URL di Route Handler Next.js, dan menyimpan hanya kunci S3 di PostgreSQL via Prisma. Untuk menyajikan file, saya menggunakan CloudFront CDN di depan S3 untuk caching dan performa — URL S3 tidak pernah diekspos langsung ke klien.
Biaya egress AWS S3 bertambah untuk aplikasi dengan traffic unduhan yang berat. Cloudflare R2 kompatibel dengan S3 (SDK yang sama, pola presigned URL yang sama) dengan biaya egress nol. Untuk proyek baru, saya mengevaluasi R2 terlebih dahulu — satu-satunya alasan memilih S3 adalah jika Anda sudah dalam ekosistem AWS atau butuh fitur S3 tertentu yang belum didukung R2. Kode presigned URL identik antara S3 dan R2; Anda hanya mengubah URL endpoint.