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

Dany Akmallun Ni'am
Dany Akmallun Ni'amMas-mas Jawa
Development

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?"

  1. 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.
  2. Kapasitas Gratis Melimpah: Cloudflare memberikan 10 GB penyimpanan gratis setiap bulan dan jutaan request gratis.
  3. Kompatibel Penuh dengan S3 API: Kita bisa menggunakan library standar @aws-sdk/client-s3 tanpa perlu library proprietary aneh-aneh.
  4. 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

  1. Buka dashboard Cloudflare > pilih menu R2 Object Storage.
  2. Klik Create Bucket, beri nama bucket Anda (contoh: portfolio-blog), lalu pilih lokasi Asia-Pacific (APAC).
  3. 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).
  4. Buat API Token untuk autentikasi S3:
    • Klik R2 Overview > Manage R2 API Tokens > Create API Token.
    • Berikan izin: Object Read & Write.
    • Salin Access Key ID dan Secret Access Key.

Langkah 2: Konfigurasi Environment Variables

Tambahkan variabel kredensial berikut ke file .env.local dan di dashboard hosting Anda:

freedesktop
# 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:

bash
npm install @aws-sdk/client-s3

Kemudian buat file lib/r2.ts:

typescript
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:

typescript
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:

typescript
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:

javascript
/** @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:

  1. Reliabilitas Tinggi: Gambar portofolio dan blog tersimpan permanen di cloud dan tidak akan hilang saat redeploy.
  2. Kinerja Maksimal: Gambar dimuat melalui Cloudflare Global CDN dengan waktu respon di bawah 50ms.
  3. 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. 🚀

Bagikan artikel ini