Menghubungkan aplikasi Next.js ke PostgreSQL melalui ORM Prisma: membuat skema, menjalankan migration, melakukan CRUD dari server components dan server actions, serta prinsip query optimization untuk mencegah N+1 dan query lambat.

Setelah di episode 3 kalian membangun fitur fullstack dengan data dummy, pada episode ini kita mengganti dummy tersebut dengan database sungguhan. Kita memakai Prisma — ORM (Object-Relational Mapping) paling populer untuk TypeScript — karena ia memberi kalian keamanan tipe end-to-end: jika kolom berubah di database, error muncul di editor, bukan di production.
Mengapa perlu ORM? Karena menulis SQL mentah di aplikasi adalah sumber bug yang mahal: string concatenation rawan SQL injection, tipe hasil query tidak terdefinisi, dan migration harus dikelola manual. ORM mengotomatiskan semua itu sambil tetap memberi kalian escape hatch ke SQL mentah saat perlu query yang sangat kompleks.
Pastikan PostgreSQL dari episode 0 masih berjalan, lalu install Prisma:
pnpm add -D prisma
pnpm add @prisma/client
npx prisma init --datasource-provider postgresqlPrisma membuat folder prisma/ dan file .env dengan DATABASE_URL. Sesuaikan isinya dengan container PostgreSQL kalian:
DATABASE_URL="postgresql://postgres:password@localhost:5432/fullstack"Skema Prisma adalah tempat kalian mendeskripsikan database dengan tipe aman. Mari buat dua model untuk aplikasi catatan kita:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String
notes Note[]
createdAt DateTime @default(now())
}
model Note {
id String @id @default(cuid())
title String
body String
published Boolean @default(false)
authorId String
author User @relation(fields: [authorId], references: [id])
createdAt DateTime @default(now())
@@index([authorId])
}Perhatikan detail penting:
@id @default(cuid()): primary key string unik — tidak perlu menebak UUID.@unique pada email: unique constraint di level database, bukan cuma di aplikasi.Note.author ↔ User.notes: foreign key authorId otomatis dibuat.@@index([authorId]): index untuk query yang sering memfilter note per user.Skema Prisma belum berarti apa-apa sampai di-migrate. Migration menghasilkan file SQL, menjalankannya ke database, dan menyimpan histori perubahan:
npx prisma migrate dev --name init
npx prisma generatemigrate dev menciptakan tabel User dan Note di PostgreSQL serta regenerasi Prisma Client. File SQL hasilnya bisa kalian periksa di prisma/migrations/ — inilah yang dipakai untuk mereproduksi skema yang sama di production (episode 17).
Note
Dua perintah yang sering tertukar: migrate dev dipakai saat mengubah skema di development (membuat + menjalankan migration), sedangkan migrate deploy dipakai untuk menerapkan migration yang sudah ada ke production. Jangan pernah menjalankan migrate dev di production.
Sekarang sambungkan Prisma ke aplikasi Next.js. Buat satu klien Prisma global agar koneksi tidak dibuat ulang setiap request:
import { PrismaClient } from "@prisma/client"
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({ log: ["query", "error"] })
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prismaLalu CRUD lengkap — baca di server component, tulis di server action:
import { prisma } from "@/lib/prisma"
export default async function NotesPage() {
const notes = await prisma.note.findMany({
where: { published: true },
orderBy: { createdAt: "desc" },
include: { author: { select: { name: true } } },
})
return (
<ul>
{notes.map((note) => (
<li key={note.id}>
<b>{note.title}</b> — {note.author.name}
</li>
))}
</ul>
)
}import { prisma } from "@/lib/prisma"
import { revalidatePath } from "next/cache"
async function createNote(formData: FormData) {
"use server"
const title = String(formData.get("title") ?? "")
const body = String(formData.get("body") ?? "")
await prisma.note.create({
data: { title, body, authorId: "user-1" },
})
revalidatePath("/notes")
}
export default function NewNote() {
return (
<form action={createNote}>
<input name="title" required />
<textarea name="body" required />
<button type="submit">Simpan</button>
</form>
)
}findMany + create adalah dua dari lima operasi CRUD dasar. Sisanya — findUnique, update, delete — memiliki bentuk simetris:
await prisma.note.update({
where: { id: noteId },
data: { title: "Judul baru" },
})
await prisma.note.delete({ where: { id: noteId } })ORAM membuat menulis query mudah — tapi mudah juga menghasilkan query jelek. Tiga pola yang wajib dikuasai:
Prisma findMany default mengambil semua kolom. Untuk daftar ringkas, pilih kolom yang benar-benar dipakai:
const titles = await prisma.note.findMany({
where: { published: true },
select: { id: true, title: true },
})Masalah N+1: untuk 100 note, query pertama mengambil 100 note, lalu 100 query tambahan untuk author masing-masing — total 101 query. Solusinya: include (join) dalam satu query.
const notes = await prisma.note.findMany({
include: { author: { select: { name: true } } },
})Jangan pernah mengambil semua baris untuk daftar. Prisma menyediakan skip/take:
const page = 1
const perPage = 20
const notes = await prisma.note.findMany({
skip: (page - 1) * perPage,
take: perPage,
})Tip
Aktifkan log query (sudah ada di klien Prisma di atas) dan perhatikan outputnya di terminal saat development. Jika melihat pola query berulang-ulang untuk data yang sama, itu sinyal N+1 atau cache yang hilang — keduanya kita selesaikan di episode 8 dan 15.
Inti yang harus dibawa pulang:
migrate dev untuk development, migrate deploy untuk production — jangan tertukar.findMany, findUnique, create, update, delete.select kolom, include untuk menghindari N+1, dan skip/take untuk pagination.Di episode 5 selanjutnya kita akan membahas API design & integration — kapan harus membuat REST/GraphQL di luar server actions, bagaimana integrasi server-side, dan pola error handling yang konsisten. Sampai jumpa di episode 5!