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:
- Bungkus query dalam function bertanda
'use cache'(pluscacheLifedancacheTag) bila datanya sama untuk semua pengunjung, misalnya daftar artikel terbit. Hasilnya masuk ke static shell dan super cepat. - Panggil di komponen yang dibungkus
<Suspense>bila datanya harus selalu segar atau bergantung pada pengguna, misalnya dashboard admin.
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:
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. Paketserver-onlyversi 0.0.1 ditangani khusus oleh Next.js.- Rahasia hanya di environment variable tanpa awalan
NEXT_PUBLIC_(Modul 7), sehingga tidak ikut ter-bundle ke browser. - 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
- Import
dbdari Client Component. Kode koneksi dan kredensial bisa ikut terbundle. Pasangimport 'server-only'dilib/db.jsdanlib/dal.jssupaya kesalahan ini jadi error build, bukan kebocoran diam-diam. - 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. - 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>. - Menyimpan file SQLite di hosting serverless. Di Vercel, file yang ditulis saat runtime tidak permanen. Pakai database cloud (Turso, Postgres) untuk produksi.
- Merangkai SQL dari string input.
db.run(`... WHERE slug = '${slug}'`)membuka celah SQL injection. Pakai query builder (eq(posts.slug, slug)) atau placeholder?.
Latihan
- Mudah, Tambahkan kolom
views(integer, default 0) ke tabelposts, jalankandrizzle-kit push, lalu tampilkan jumlah views di daftar artikel. - Sedang, Buat fungsi
searchPosts(keyword)di DAL yang mencari artikel terbit berdasarkan judul (pakai operatorlikedaridrizzle-orm). Buat halaman/cari?q=yang menampilkan hasilnya di dalam<Suspense>. - Menantang, Buat tabel
comments(id, postId, nama, isi, createdAt) dengan foreign key keposts.id, lalu buat fungsigetComments(postId)yang di-cache dengan tagcomments-<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
- ORM seperti Drizzle (0.45.4) atau Prisma (7.10.1) membuat query aman dari SQL injection dan nyaman ditulis.
- libSQL memungkinkan kode yang sama memakai file SQLite lokal saat belajar dan database cloud (Turso) saat produksi.
- Schema ditulis dengan
sqliteTable, laludrizzle-kit pushmenerapkannya ke database. - Query bisa langsung di Server Component; dengan Cache Components, pilih
'use cache'untuk data publik atau<Suspense>untuk data segar/per pengguna. import 'server-only'+ DAL + pilih kolom eksplisit memastikan kode dan data sensitif tidak bocor ke client.