Panduan Lengkap Membangun Backend Next.js 16 dengan Cloudflare R2 dan PostgreSQL

Halo semuanya! 👋
Ketika membangun aplikasi web modern berbasis Next.js 16 dan men-deploy-nya ke platform serverless seperti Vercel, salah satu tantangan terbesar yang sering dihadapi developer adalah pengelolaan media file (gambar/video) dan database relasional.
Banyak developer pemula mencoba menyimpan file hasil upload ke folder lokal /public/uploads. Hasilnya? Saat aplikasi di-redeploy, seluruh file yang baru saja diupload tiba-tiba hilang karena sifat filesystem serverless yang ephemeral (sementara).
Di artikel ini, saya akan membagikan tata cara lengkap arsitektur backend modern yang baru saja saya terapkan: Next.js 16 + Cloud PostgreSQL + Cloudflare R2 Object Storage + Better-Auth.
💡 Mengapa Memilih Cloudflare R2?
Mungkin Anda bertanya: "Kenapa tidak menggunakan AWS S3 atau database langsung?"
- Zero Egress Fees (Bebas Biaya Bandwidth 100%): Pada AWS S3, Anda dikenakan biaya setiap kali pengunjung mengunduh atau melihat gambar di website Anda (egress bandwidth). Di Cloudflare R2, biaya bandwidth adalah Rp 0 alias GRATIS.
- Kapasitas Gratis Melimpah: Cloudflare memberikan 10 GB penyimpanan gratis setiap bulan dan jutaan request gratis.
- Kompatibel Penuh dengan S3 API:
Kita bisa menggunakan library standar
@aws-sdk/client-s3tanpa perlu library proprietary aneh-aneh. - Global Edge CDN: Aset media otomatis didistribusikan melalui jaringan server Cloudflare di seluruh dunia dengan latensi sangat rendah.
🏛️ Arsitektur Sistem
Berikut adalah rancangan alur data yang kita bangun:
- Frontend & Compute: Next.js 16 (App Router, Server Actions, API Routes) di Vercel.
- Database Relasional: Cloud PostgreSQL menggunakan Drizzle ORM dan connection pooling
@vercel/functions. - Media Object Storage: Cloudflare R2 untuk menyimpan cover artikel, galeri proyek, dan aset foto.
- Autentikasi: Better-Auth dengan Role-Based Access Control (RBAC: Admin & Editor).
🚀 Langkah Demi Langkah Penerapan
Langkah 1: Membuat Bucket & API Token di Cloudflare R2
- Buka dashboard Cloudflare > pilih menu R2 Object Storage.
- Klik Create Bucket, beri nama bucket Anda (contoh:
portfolio-blog), lalu pilih lokasi Asia-Pacific (APAC). - Masuk ke tab Settings bucket:
- Pada bagian Public Development URL, klik Enable (Anda akan mendapatkan URL publik seperti
https://pub-xxxxxx.r2.dev), atau hubungkan ke Custom Domain (misal:media.domainanda.com).
- Pada bagian Public Development URL, klik Enable (Anda akan mendapatkan URL publik seperti
- Buat API Token untuk autentikasi S3:
- Klik R2 Overview > Manage R2 API Tokens > Create API Token.
- Berikan izin: Object Read & Write.
- Salin
Access Key IDdanSecret Access Key.
Langkah 2: Konfigurasi Environment Variables
Tambahkan variabel kredensial berikut ke file .env.local dan di dashboard hosting Anda:
# Database PostgreSQL
DATABASE_URL="postgresql://username:password@host:5432/postgres?sslmode=require"
# Cloudflare R2 Storage
R2_ACCOUNT_ID="account_id_cloudflare_anda"
R2_BUCKET_NAME="portfolio-blog"
R2_ACCESS_KEY_ID="access_key_id_anda"
R2_SECRET_ACCESS_KEY="secret_access_key_anda"
R2_PUBLIC_DOMAIN="https://pub-xxxxxx.r2.dev"Langkah 3: Install SDK & Buat Helper Cloudflare R2
Jalankan instalasi AWS S3 SDK di terminal:
npm install @aws-sdk/client-s3Kemudian buat file lib/r2.ts:
import {
S3Client,
PutObjectCommand,
DeleteObjectCommand,
} from '@aws-sdk/client-s3'
const accountId = process.env.R2_ACCOUNT_ID
const accessKeyId = process.env.R2_ACCESS_KEY_ID
const secretAccessKey = process.env.R2_SECRET_ACCESS_KEY
const bucketName = process.env.R2_BUCKET_NAME || 'portfolio-blog'
const publicDomain = process.env.R2_PUBLIC_DOMAIN
export const isR2Configured = Boolean(accessKeyId && secretAccessKey)
export const r2Client = isR2Configured
? new S3Client({
region: 'auto',
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: accessKeyId!,
secretAccessKey: secretAccessKey!,
},
})
: null
// Fungsi Upload Gambar ke R2
export async function uploadToR2(
fileBuffer: Buffer,
fileName: string,
contentType: string,
folder = 'general',
): Promise<string> {
if (!r2Client || !isR2Configured) {
throw new Error('Cloudflare R2 belum terkonfigurasi.')
}
const key = `${folder}/${fileName}`
await r2Client.send(
new PutObjectCommand({
Bucket: bucketName,
Key: key,
Body: fileBuffer,
ContentType: contentType,
}),
)
if (publicDomain) {
const cleanDomain = publicDomain.replace(/\/$/, '')
return `${cleanDomain}/${key}`
}
return `https://${bucketName}.${accountId}.r2.cloudflarestorage.com/${key}`
}
// Fungsi Hapus Gambar dari R2
export async function deleteFromR2(fileUrl: string): Promise<boolean> {
if (!r2Client || !isR2Configured) return false
try {
const url = new URL(fileUrl)
const key = url.pathname.replace(/^\//, '')
await r2Client.send(
new DeleteObjectCommand({
Bucket: bucketName,
Key: key,
}),
)
return true
} catch (err) {
console.warn('Gagal menghapus file dari R2:', err)
return false
}
}Langkah 4: Membuat API Route Upload (/api/upload)
Buat file endpoint upload app/api/upload/route.ts:
import { NextRequest, NextResponse } from 'next/server'
import path from 'path'
import { getSession } from '@/lib/auth/guard'
import { isR2Configured, uploadToR2 } from '@/lib/r2'
export const runtime = 'nodejs'
const ALLOWED_MIME_TYPES = [
'image/jpeg',
'image/png',
'image/webp',
'image/avif',
'image/gif',
]
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
export async function POST(request: NextRequest) {
try {
// 1. Proteksi Hak Akses (Role Admin / Editor)
const session = await getSession()
const role = (session?.user as { role?: string })?.role
if (
process.env.NODE_ENV === 'production' &&
(!session || (role !== 'admin' && role !== 'editor'))
) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
const formData = await request.formData()
const file = formData.get('file') as File | null
const folder = (formData.get('folder') as string) || 'general'
if (!file)
return NextResponse.json(
{ error: 'File tidak ditemukan' },
{ status: 400 },
)
if (!ALLOWED_MIME_TYPES.includes(file.type)) {
return NextResponse.json(
{ error: 'Format file tidak didukung' },
{ status: 400 },
)
}
// 2. Buat Nama File Unik & Aman
const sanitizedFolder = folder.replace(/[^a-zA-Z0-9_-]/g, '') || 'general'
const ext = path.extname(file.name).toLowerCase() || '.png'
const fileName = `${Date.now()}-${crypto.randomUUID().slice(0, 8)}${ext}`
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
// 3. Upload Langsung ke Cloudflare R2
const publicUrl = await uploadToR2(
buffer,
fileName,
file.type,
sanitizedFolder,
)
return NextResponse.json({
success: true,
url: publicUrl,
fileName,
})
} catch (error) {
return NextResponse.json(
{ error: 'Gagal mengunggah file' },
{ status: 500 },
)
}
}Langkah 5: Auto-Delete Media Saat Hapus Konten
Agar bucket R2 tidak dipenuhi file sampah (orphan files), sambungkan deleteFromR2() di dalam Server Action penghapusan data:
import { deleteFromR2 } from '@/lib/r2'
import { db } from '@/lib/db'
import { posts } from '@/lib/db/schema'
import { eq } from 'drizzle-orm'
export async function deletePostAction(id: string) {
// 1. Ambil URL Cover Gambar
const [post] = await db.select().from(posts).where(eq(posts.id, id))
if (post?.coverImage) {
// 2. Hapus file fisik dari Cloudflare R2
await deleteFromR2(post.coverImage)
}
// 3. Hapus baris data dari database PostgreSQL
await db.delete(posts).where(eq(posts.id, id))
return { success: true }
}Langkah 6: Konfigurasi next.config.mjs untuk Domain R2
Agar Next.js dapat merender gambar dari domain publik Cloudflare R2 tanpa terblokir, tambahkan pola hostname di next.config.mjs:
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
formats: ['image/webp', 'image/avif'],
remotePatterns: [
{ protocol: 'https', hostname: '**.r2.dev' },
{ protocol: 'https', hostname: '**.cloudflarestorage.com' },
{ protocol: 'https', hostname: '**.danyakmallun.dev' },
],
},
}
export default nextConfig🎯 Hasil & Manfaat yang Didapatkan
Dengan menerapkan arsitektur ini:
- Reliabilitas Tinggi: Gambar portofolio dan blog tersimpan permanen di cloud dan tidak akan hilang saat redeploy.
- Kinerja Maksimal: Gambar dimuat melalui Cloudflare Global CDN dengan waktu respon di bawah 50ms.
- Biaya Rp 0: Menggunakan kuota gratis Cloudflare R2, PlanetScale PostgreSQL, dan Vercel secara optimal tanpa rasa takut akan tagihan tak terduga.
Semoga panduan ini bermanfaat bagi Anda yang sedang membangun sistem backend modern berbasis Next.js! Jangan ragu untuk bereksperimen dan menyesuaikan dengan kebutuhan proyek Anda. 🚀


