kodingup.camp

Beranda/Next.js Mastery: Full-Stack, Auth & Deploy

Database

Modul 4 dari 116 menit bacaDatabase Integration

Isi modul

    Kenapa ini penting

    Data di variabel JavaScript hilang setiap kali server restart. Data di localStorage hanya ada di satu browser. Kalau kamu ingin artikel yang ditulis admin bisa dibaca semua pengunjung dari perangkat mana pun, besok, minggu depan, dan tahun depan, kamu butuh database: tempat penyimpanan permanen yang bisa dicari, diurutkan, dan diubah dengan aman.

    Kabar baiknya, dengan Server Component dan Server Action, Next.js membuat akses database terasa sangat langsung. Kamu bisa menulis query di dalam komponen, tanpa membuat API perantara. Kabar yang perlu diwaspadai: karena terasa begitu dekat dengan UI, pemula sering tidak sadar kapan kode database "bocor" ke browser. Modul ini membahas keduanya: cara memakai database dengan nyaman, dan cara memastikan kodenya tidak pernah sampai ke client.

    Analogi: database itu gudang arsip kantor. ORM adalah petugas arsip yang mengerti bahasamu ("ambilkan semua surat yang sudah disetujui, urutkan dari terbaru") lalu menerjemahkannya ke bahasa gudang (SQL). Dan server-only adalah tanda "khusus staf" di pintu gudang: tamu (browser) tidak boleh masuk sama sekali.

    Konsep inti

    SQL, SQLite, dan ORM

    SQL adalah bahasa standar untuk bicara dengan database relasional (data berbentuk tabel: baris dan kolom). SQLite adalah database yang seluruh isinya disimpan dalam satu file di disk. Tidak perlu memasang server database terpisah, sehingga sangat cocok untuk belajar dan proyek kecil.

    ORM (Object-Relational Mapper) adalah library yang memungkinkan kamu menulis query dengan JavaScript, bukan string SQL mentah. Keuntungannya: autocompletion di editor, lebih sulit salah ketik nama kolom, dan terlindung dari SQL injection (serangan dengan menyisipkan perintah SQL lewat input) karena nilai selalu dikirim sebagai parameter.

    Pilihan ORM

    Dua ORM paling populer di ekosistem Next.js:

    Drizzle ORM Prisma
    Versi saat ini drizzle-orm 0.45.4, drizzle-kit 0.31.11 @prisma/client 7.10.1
    Cara menulis schema File JavaScript/TypeScript biasa File .prisma dengan bahasa khusus
    Gaya query Mirip SQL (select().from().where()) API objek (findMany({ where }))
    Generate client Tidak perlu Perlu prisma generate
    Cocok untuk Yang ingin dekat dengan SQL, setup ringan Yang ingin API tingkat tinggi dan tooling lengkap

    Materi ini memakai Drizzle, karena schema-nya ditulis sebagai JavaScript biasa (tidak ada bahasa baru untuk dipelajari) dan contoh di dokumentasi Next.js juga memakai gaya Drizzle. Prisma tetap pilihan yang sangat baik; konsep di modul ini berlaku sama. Catatan kecil yang bagus untuk melatih kebiasaan cek versi: saat materi ini ditulis, tag latest paket CLI prisma di npm menunjuk ke 8.0.0-rc.27 (versi rilis kandidat), sementara @prisma/client stabil di 7.10.1. Kalau memilih Prisma, pastikan versi CLI dan client-nya sama.

    Driver libSQL: lokal dulu, cloud kemudian

    Kita memakai driver libSQL (@libsql/client), yaitu turunan SQLite yang bisa membuka file lokal (file:local.db) maupun database di cloud (Turso, alamat libsql://...). Kenapa penting? Platform seperti Vercel menjalankan aplikasimu di server serverless yang tidak menyimpan file secara permanen. File SQLite yang ditulis di sana bisa hilang kapan saja. Dengan libSQL, kode yang sama jalan di laptop dengan file lokal, lalu cukup ganti DATABASE_URL saat deploy (Modul 10).

    Schema dan migrasi

    Schema adalah definisi struktur tabel: nama kolom, tipe data, aturan (wajib diisi, unik). Di Drizzle, schema ditulis dengan sqliteTable(). Lalu drizzle-kit push membaca schema dan membuat atau mengubah tabel di database agar cocok. Untuk proyek tim dan produksi, kamu biasanya memakai drizzle-kit generate (membuat file migrasi SQL yang bisa di-review dan di-commit) lalu drizzle-kit migrate. Untuk belajar, push sudah cukup.

    Query di Server Component

    Karena Server Component berjalan di server, kamu bisa memanggil database langsung: const posts = await db.select().from(posts). Hasilnya dirender jadi HTML; browser tidak pernah melihat query atau kredensialnya.

    Di Next.js 16 dengan Cache Components, query database async dianggap data yang belum di-cache. Ada dua pilihan:

    Kalau lupa keduanya, saat next dev muncul peringatan "blocking route" dan saat build bisa gagal. Ini bukan bug, melainkan Next.js memaksamu memutuskan secara sadar. (Catatan: driver sinkron seperti better-sqlite3 atau node:sqlite diperlakukan sebagai nilai yang bisa diprediksi dan diprerender otomatis; libSQL async, jadi aturan di atas berlaku.)

    Keamanan: kode DB tidak pernah ke client

    Tiga lapis pengaman:

    1. import 'server-only' di baris pertama file yang mengakses database. Kalau suatu hari file itu tidak sengaja di-import dari Client Component, build langsung gagal dengan pesan jelas. Paket server-only versi 0.0.1 ditangani khusus oleh Next.js.
    2. Rahasia hanya di environment variable tanpa awalan NEXT_PUBLIC_ (Modul 7), sehingga tidak ikut ter-bundle ke browser.
    3. Data Access Layer (DAL): satu folder (lib/dal.js) yang menjadi satu-satunya pintu ke database, mengecek hak akses, dan hanya mengembalikan kolom yang dibutuhkan. Jangan mengirim seluruh baris user (termasuk hash password) ke Client Component.

    Contoh kode

    Pasang paket dan siapkan schema:

    npm install drizzle-orm @libsql/client server-only
    npm install -D drizzle-kit
    
    // db/schema.js
    import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core'
    
    // Satu tabel: posts. Setiap kolom = satu "laci" di lemari arsip.
    export const posts = sqliteTable('posts', {
      id: integer('id').primaryKey({ autoIncrement: true }),
      slug: text('slug').notNull().unique(),         // dipakai di URL, tidak boleh kembar
      title: text('title').notNull(),
      excerpt: text('excerpt').notNull(),
      content: text('content').notNull(),
      published: integer('published', { mode: 'boolean' }).notNull().default(false),
      createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), // disimpan detik, dibaca Date
    })
    
    // drizzle.config.js  (dibaca oleh drizzle-kit, bukan oleh Next.js)
    import { defineConfig } from 'drizzle-kit'
    
    export default defineConfig({
      dialect: 'turso', // dialect untuk libSQL: file lokal maupun Turso
      schema: './db/schema.js',
      dbCredentials: {
        url: process.env.DATABASE_URL ?? 'file:local.db',
        authToken: process.env.DATABASE_AUTH_TOKEN,
      },
    })
    
    npx drizzle-kit push
    # → [✓] Pulling schema from database...
    # → [✓] Changes applied          (file local.db berisi tabel posts)
    

    Koneksi database, dikunci khusus server:

    // lib/db.js
    import 'server-only' // import dari Client Component → build gagal
    import { drizzle } from 'drizzle-orm/libsql'
    import * as schema from '@/db/schema'
    
    export const db = drizzle({
      connection: {
        url: process.env.DATABASE_URL ?? 'file:local.db',
        authToken: process.env.DATABASE_AUTH_TOKEN,
      },
      schema,
    })
    

    Data Access Layer: query yang dipakai halaman, lengkap dengan caching.

    // lib/dal.js  (potongan)
    import 'server-only'
    import { cacheLife, cacheTag } from 'next/cache'
    import { eq, desc, and } from 'drizzle-orm'
    import { db } from '@/lib/db'
    import { posts } from '@/db/schema'
    
    // Data publik: sama untuk semua pengunjung → aman di-cache
    export async function getPublishedPosts() {
      'use cache'
      cacheLife('hours') // segar ±1 jam, bisa dipaksa lebih cepat via updateTag
      cacheTag('posts')  // label untuk invalidasi on-demand
      return db
        .select({ slug: posts.slug, title: posts.title, excerpt: posts.excerpt, createdAt: posts.createdAt })
        .from(posts)
        .where(eq(posts.published, true))
        .orderBy(desc(posts.createdAt))
    }
    // → [{ slug: 'halo-dunia', title: 'Halo Dunia', excerpt: '...', createdAt: Date }]
    
    export async function getPublishedPost(slug) {
      'use cache'
      cacheLife('hours')
      cacheTag('posts', `post-${slug}`) // slug ikut jadi bagian cache key otomatis
      const rows = await db
        .select()
        .from(posts)
        .where(and(eq(posts.slug, slug), eq(posts.published, true)))
        .limit(1)
      return rows[0] ?? null
    }
    

    Server Component yang memakainya. Tidak ada useEffect, tidak ada fetch ke API sendiri:

    // app/page.js
    import Link from 'next/link'
    import { getPublishedPosts } from '@/lib/dal'
    
    export default async function HomePage() {
      const posts = await getPublishedPosts() // di-cache, ikut static shell
    
      return (
        <section>
          <h1>Artikel Terbaru</h1>
          {posts.length === 0 && <p>Belum ada artikel terbit.</p>}
          {posts.map((post) => (
            <article key={post.slug}>
              <h2><Link href={`/blog/${post.slug}`}>{post.title}</Link></h2>
              <p>{post.excerpt}</p>
            </article>
          ))}
        </section>
      )
    }
    

    Operasi tulis (dipakai di Server Action, Modul 2):

    import { eq } from 'drizzle-orm'
    
    // INSERT
    await db.insert(posts).values({ slug: 'baru', title: 'Baru', excerpt: '...', content: '...', createdAt: new Date() })
    
    // UPDATE + ambil kembali kolom tertentu
    const [row] = await db.update(posts).set({ published: true }).where(eq(posts.id, 3)).returning({ slug: posts.slug })
    // → row = { slug: 'baru' }
    
    // DELETE
    await db.delete(posts).where(eq(posts.id, 3))
    

    Hasil npm run build proyek akhir menunjukkan efek caching ini: / bertanda ○ (Static) dengan kolom Revalidate 1h dan Expire 1d (sesuai profil hours), sedangkan /admin bertanda ◐ (Partial Prerender) karena daftar admin dibaca segar di dalam <Suspense>.

    Kesalahan umum

    1. Import db dari Client Component. Kode koneksi dan kredensial bisa ikut terbundle. Pasang import 'server-only' di lib/db.js dan lib/dal.js supaya kesalahan ini jadi error build, bukan kebocoran diam-diam.
    2. Mengirim seluruh baris ke client. select() tanpa kolom pada tabel user ikut membawa email, hash password, atau token. Pilih kolom secara eksplisit (select({ id, name })) sebelum diteruskan ke Client Component.
    3. Query async tanpa 'use cache' atau <Suspense>. Dengan Cache Components, Next.js akan menegur. Putuskan: data publik → 'use cache'; data per pengguna atau harus segar → <Suspense>.
    4. Menyimpan file SQLite di hosting serverless. Di Vercel, file yang ditulis saat runtime tidak permanen. Pakai database cloud (Turso, Postgres) untuk produksi.
    5. Merangkai SQL dari string input. db.run(`... WHERE slug = '${slug}'`) membuka celah SQL injection. Pakai query builder (eq(posts.slug, slug)) atau placeholder ?.

    Latihan

    1. Mudah, Tambahkan kolom views (integer, default 0) ke tabel posts, jalankan drizzle-kit push, lalu tampilkan jumlah views di daftar artikel.
    2. Sedang, Buat fungsi searchPosts(keyword) di DAL yang mencari artikel terbit berdasarkan judul (pakai operator like dari drizzle-orm). Buat halaman /cari?q= yang menampilkan hasilnya di dalam <Suspense>.
    3. Menantang, Buat tabel comments (id, postId, nama, isi, createdAt) dengan foreign key ke posts.id, lalu buat fungsi getComments(postId) yang di-cache dengan tag comments-<postId>.
    Lihat solusi
    // 1. db/schema.js → tambahkan di objek kolom posts:
    views: integer('views').notNull().default(0),
    // lalu: npx drizzle-kit push
    // dan di getPublishedPosts, tambahkan views: posts.views di select({...})
    
    // 2a. lib/dal.js
    import { like } from 'drizzle-orm'
    
    export async function searchPosts(keyword) {
      'use cache'
      cacheLife('minutes')
      cacheTag('posts')
      return db
        .select({ slug: posts.slug, title: posts.title })
        .from(posts)
        .where(and(eq(posts.published, true), like(posts.title, `%${keyword}%`)))
    }
    
    // 2b. app/cari/page.js
    import { Suspense } from 'react'
    import { searchPosts } from '@/lib/dal'
    
    async function Results({ searchParams }) {
      const { q = '' } = await searchParams // searchParams = data request → di dalam Suspense
      const hasil = await searchPosts(q)
      return <ul>{hasil.map((p) => <li key={p.slug}>{p.title}</li>)}</ul>
    }
    
    export default function CariPage({ searchParams }) {
      return (
        <Suspense fallback={<p>Mencari...</p>}>
          <Results searchParams={searchParams} />
        </Suspense>
      )
    }
    
    // 3a. db/schema.js
    export const comments = sqliteTable('comments', {
      id: integer('id').primaryKey({ autoIncrement: true }),
      postId: integer('post_id').notNull().references(() => posts.id, { onDelete: 'cascade' }),
      nama: text('nama').notNull(),
      isi: text('isi').notNull(),
      createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
    })
    
    // 3b. lib/dal.js
    export async function getComments(postId) {
      'use cache'
      cacheLife('hours')
      cacheTag(`comments-${postId}`)
      return db.select().from(comments).where(eq(comments.postId, postId)).orderBy(desc(comments.createdAt))
    }
    

    Ringkasan

    Video pendamping

    Kalau penjelasan di atas belum masuk, tonton ini. Video dari kanal YouTube pihak lain.

    Terhubung ke Database di Next.js (ft. Drizzle & Turso) Gratis!Fahrezi Adha · Bahasa IndonesiaBuka di YouTube

    Kuis pemahaman

    Pilih satu jawaban per soal. Penjelasan muncul setelah kamu menjawab.