BlogBlog · nextjs

Xây ứng dụng full-stack với Next.js App Router và Prisma 7: từ schema đến Server Actions, auth và deploy

Build-along một issue tracker với Next.js 16 và Prisma 7: schema có index, migration và seed, Server Component lọc và phân trang bằng cursor, Server Actions với zod, useActionState, optimistic UI, kiểm tra quyền và deploy lên Vercel.

Part of Deep Dive series
October 19, 2026
32 min read
By Leo Pham
nextjsprismafullstacktypescriptpostgresql
Share this post:

Hầu hết tutorial "Next.js + Prisma" dừng ở một trang findMany và một form create. Code đó chạy được trên máy, nhưng thiếu đúng những thứ làm app thật bị lỗi: ai được sửa issue nào, phân trang khi bảng có 50.000 dòng, form báo lỗi validation ra sao, chuyện gì xảy ra khi PrismaClient bị tạo lại mỗi lần hot reload, và migration chạy ở đâu khi deploy.

Trong bài này mình xây một issue tracker nhỏ tên Tracker: user thuộc nhiều project, mỗi project có nhiều issue với status và priority. Bạn sẽ có trang danh sách lọc theo status, tìm theo tiêu đề, phân trang bằng cursor; form tạo issue có validation và hiển thị lỗi từng field; dropdown đổi status cập nhật ngay lập tức (optimistic); mọi mutation đều kiểm tra quyền; và quy trình deploy lên Vercel với Postgres managed.

Stack: Next.js 16 (App Router, React 19.2), Prisma 7 với @prisma/adapter-pg, PostgreSQL, zod 4. Bài này là build-along nên mình chỉ giải thích Prisma ở mức cần dùng; phần vận hành sâu (migration zero-downtime, connection pool, transaction, raw SQL) nằm ở bài Prisma ORM trong production.

TL;DR:

  • Một module lib/prisma.ts duy nhất, cache client trên globalThis ở dev, có import 'server-only'.
  • Mọi trang và mọi Server Action gọi getCurrentUser() và kiểm tra membership; điều kiện quyền nằm luôn trong WHERE của query.
  • searchParams là input không tin cậy: parse bằng zod, giá trị sai thì bỏ qua thay vì crash.
  • Phân trang bằng id < cursor trên UUID v7 thay vì OFFSET.
  • Server Action trả về state (lỗi từng field) cho useActionState, gọi revalidatePath sau khi ghi.
  • Production: runtime dùng URL pooled, CLI dùng URL direct, prisma migrate deploy chạy trong CI trước khi code mới nhận traffic.

Cấu trúc dự án

tracker/
├─ prisma.config.ts
├─ prisma/
│  ├─ schema.prisma
│  ├─ seed.ts
│  └─ migrations/
└─ src/
   ├─ generated/prisma/            # do prisma generate sinh ra, không commit
   ├─ lib/
   │  ├─ prisma.ts                 # PrismaClient singleton
   │  ├─ auth.ts                   # getCurrentUser, getMembership
   │  └─ issues.ts                 # hằng số status/priority dùng chung
   └─ app/projects/[projectKey]/issues/
      ├─ page.tsx                  # danh sách: Server Component
      ├─ loading.tsx
      ├─ error.tsx
      ├─ not-found.tsx
      ├─ actions.ts                # Server Actions
      ├─ new-issue-form.tsx        # Client Component dùng useActionState
      ├─ issue-status-select.tsx   # Client Component dùng useOptimistic
      └─ [issueId]/page.tsx        # chi tiết issue

Nguyên tắc chia file: mọi thứ chạm database nằm ở Server Component, Server Action hoặc lib/ có server-only. Client Component chỉ nhận dữ liệu đã serialize qua props và gọi Server Action.

Bước 1: Khởi tạo Next.js và Prisma 7

npx create-next-app@latest tracker --ts --app --src-dir --tailwind --eslint --import-alias "@/*"
cd tracker

npm install @prisma/client@7 @prisma/adapter-pg pg dotenv zod server-only
npm install -D prisma@7 tsx @types/pg

npx prisma init --output ../src/generated/prisma

# PostgreSQL local
docker run -d --name tracker-pg -p 5432:5432 \
  -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=tracker postgres:18

Pin @7 cho cả hai gói là có chủ đích: dist-tag latest của gói prisma hiện đang trỏ tới Prisma 8 RC, một mô hình khác hẳn. File .env (Prisma CLI đọc qua dotenv, Next.js cũng tự load file này):

# .env — không commit
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/tracker"
DIRECT_URL="postgresql://postgres:postgres@localhost:5432/tracker"

Ở local hai URL giống nhau. Trên production, DATABASE_URL đi qua connection pooler cho app, DIRECT_URL nối thẳng PostgreSQL cho migration. Tách từ đầu để không phải sửa code lúc deploy.

// prisma.config.ts
import 'dotenv/config'
import { defineConfig, env } from 'prisma/config'

export default defineConfig({
  schema: 'prisma/schema.prisma',
  migrations: {
    path: 'prisma/migrations',
    seed: 'tsx prisma/seed.ts',
  },
  datasource: {
    url: env('DIRECT_URL'),
  },
})

Thêm vào package.json và .gitignore:

{
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "postinstall": "prisma generate",
    "db:migrate": "prisma migrate dev",
    "db:seed": "prisma db seed"
  }
}
# .gitignore
/src/generated/prisma
.env

Client do generator prisma-client sinh ra là file TypeScript trong src/generated/prisma, không nằm trong node_modules. Vì không commit, máy nào cũng phải chạy prisma generate sau khi cài dependency; postinstall lo việc đó, kể cả trên Vercel.

Bước 2: Schema, migration và seed

// prisma/schema.prisma
generator client {
  provider = "prisma-client"
  output   = "../src/generated/prisma"
}

datasource db {
  provider = "postgresql"
}

enum IssueStatus {
  TODO
  IN_PROGRESS
  DONE
  CANCELED
}

enum IssuePriority {
  LOW
  MEDIUM
  HIGH
}

enum MemberRole {
  OWNER
  MEMBER
}

model User {
  id             String          @id @default(uuid(7)) @db.Uuid
  email          String          @unique @db.VarChar(320)
  name           String?         @db.VarChar(120)
  createdAt      DateTime        @default(now()) @map("created_at") @db.Timestamptz(3)
  sessions       Session[]
  memberships    ProjectMember[]
  reportedIssues Issue[]         @relation("IssueReporter")
  assignedIssues Issue[]         @relation("IssueAssignee")

  @@map("users")
}

model Session {
  id        String   @id @default(uuid(7)) @db.Uuid
  tokenHash String   @unique @map("token_hash") @db.Char(64)
  userId    String   @map("user_id") @db.Uuid
  expiresAt DateTime @map("expires_at") @db.Timestamptz(3)
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@index([userId])
  @@map("sessions")
}

model Project {
  id        String          @id @default(uuid(7)) @db.Uuid
  key       String          @unique @db.VarChar(10)
  name      String          @db.VarChar(120)
  createdAt DateTime        @default(now()) @map("created_at") @db.Timestamptz(3)
  members   ProjectMember[]
  issues    Issue[]

  @@map("projects")
}

model ProjectMember {
  projectId String     @map("project_id") @db.Uuid
  userId    String     @map("user_id") @db.Uuid
  role      MemberRole @default(MEMBER)
  project   Project    @relation(fields: [projectId], references: [id], onDelete: Cascade)
  user      User       @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@id([projectId, userId])
  @@index([userId])
  @@map("project_members")
}

model Issue {
  id          String        @id @default(uuid(7)) @db.Uuid
  projectId   String        @map("project_id") @db.Uuid
  title       String        @db.VarChar(200)
  description String?
  status      IssueStatus   @default(TODO)
  priority    IssuePriority @default(MEDIUM)
  reporterId  String        @map("reporter_id") @db.Uuid
  assigneeId  String?       @map("assignee_id") @db.Uuid
  createdAt   DateTime      @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt   DateTime      @updatedAt @map("updated_at") @db.Timestamptz(3)

  project  Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
  reporter User    @relation("IssueReporter", fields: [reporterId], references: [id])
  assignee User?   @relation("IssueAssignee", fields: [assigneeId], references: [id], onDelete: SetNull)

  @@index([projectId, id(sort: Desc)])
  @@index([projectId, status, id(sort: Desc)])
  @@index([assigneeId])
  @@map("issues")
}

Vài quyết định cần giải thích trước khi chạy migration:

  • UUID v7 làm khóa chính và khóa sort. UUID v7 bắt đầu bằng timestamp nên sắp theo id gần như là sắp theo thời gian tạo. Nhờ vậy trang danh sách sort id DESC và phân trang bằng id < cursor mà không cần cột created_at đi kèm. Lưu ý: @default(uuid(7)) do Prisma Client sinh ở tầng app; insert bằng SQL tay phải tự truyền id.
  • Hai index cho trang danh sách. (projectId, id DESC) phục vụ "tất cả issue của project", (projectId, status, id DESC) phục vụ "lọc theo status". Mỗi index khớp đúng một query, với cột so sánh bằng đứng trước cột sort. Nếu bạn chưa chắc vì sao thứ tự cột quan trọng, đọc phần composite index trong bài Tối ưu SQL query với PostgreSQL.
  • ProjectMember có khóa chính kép (projectId, userId): một user chỉ là thành viên một lần, và lookup "user này có trong project không" là một index lookup.
  • Session.tokenHash: lưu SHA-256 của token, không lưu token gốc. Database bị lộ thì token cũng không dùng lại được.
npx prisma migrate dev --name init
npx prisma generate
npx prisma db seed

Từ Prisma 7, migrate dev không tự chạy generate và seed nữa, nên ba lệnh này phải chạy tường minh. Seed script:

// prisma/seed.ts
import 'dotenv/config'
import { createHash } from 'node:crypto'
import { PrismaPg } from '@prisma/adapter-pg'
import { PrismaClient } from '../src/generated/prisma/client'

// Seed tự tạo client riêng: lib/prisma.ts có 'server-only', import ngoài Next.js sẽ lỗi
const prisma = new PrismaClient({
  adapter: new PrismaPg({ connectionString: process.env.DIRECT_URL }),
})

const STATUSES = ['TODO', 'IN_PROGRESS', 'DONE'] as const
const PRIORITIES = ['LOW', 'MEDIUM', 'HIGH'] as const
const DEV_SESSION_TOKEN = 'dev-only-alice-session' // chỉ dùng ở local

async function main() {
  const alice = await prisma.user.upsert({
    where: { email: 'alice@tracker.local' },
    update: {},
    create: { email: 'alice@tracker.local', name: 'Alice' },
  })
  const bob = await prisma.user.upsert({
    where: { email: 'bob@tracker.local' },
    update: {},
    create: { email: 'bob@tracker.local', name: 'Bob' },
  })

  const project = await prisma.project.upsert({
    where: { key: 'WEB' },
    update: {},
    create: {
      key: 'WEB',
      name: 'Website',
      members: {
        create: [
          { userId: alice.id, role: 'OWNER' },
          { userId: bob.id, role: 'MEMBER' },
        ],
      },
    },
  })

  // Idempotent: chạy seed nhiều lần không nhân bản dữ liệu
  if ((await prisma.issue.count({ where: { projectId: project.id } })) === 0) {
    await prisma.issue.createMany({
      data: Array.from({ length: 60 }, (_, i) => ({
        projectId: project.id,
        title: `Issue mẫu số ${i + 1}`,
        status: STATUSES[i % STATUSES.length],
        priority: PRIORITIES[i % PRIORITIES.length],
        reporterId: alice.id,
        assigneeId: i % 2 === 0 ? bob.id : null,
      })),
    })
  }

  const tokenHash = createHash('sha256').update(DEV_SESSION_TOKEN).digest('hex')
  await prisma.session.upsert({
    where: { tokenHash },
    update: { expiresAt: new Date(Date.now() + 7 * 24 * 3600 * 1000) },
    create: { tokenHash, userId: alice.id, expiresAt: new Date(Date.now() + 7 * 24 * 3600 * 1000) },
  })
  console.log(`Seed xong. Đặt cookie tracker_session=${DEV_SESSION_TOKEN} để đăng nhập dưới tên Alice.`)
}

main()
  .catch((e) => {
    console.error(e)
    process.exitCode = 1
  })
  .finally(() => prisma.$disconnect())

Enum được truyền bằng string literal ('OWNER', 'TODO'); kiểu sinh ra từ Prisma 7 là union của các giá trị đó nên TypeScript vẫn kiểm tra. Session dev chỉ để bạn thử app ở local mà chưa cần làm trang đăng nhập.

Bước 3: PrismaClient singleton sống sót qua hot reload

// src/lib/prisma.ts
import 'server-only'
import { PrismaPg } from '@prisma/adapter-pg'
import { PrismaClient } from '@/generated/prisma/client'

const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }

function createPrismaClient() {
  const adapter = new PrismaPg({
    connectionString: process.env.DATABASE_URL,
    max: process.env.NODE_ENV === 'production' ? 5 : 10,
    connectionTimeoutMillis: 5_000,
  })
  return new PrismaClient({
    adapter,
    log: process.env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'],
  })
}

export const prisma = globalForPrisma.prisma ?? createPrismaClient()

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma

Vì sao cần globalThis: ở dev, mỗi lần bạn sửa file, Next.js nạp lại module. Không có cache, mỗi lần nạp lại tạo một PrismaClient mới với một pool pg mới, và sau vài chục lần save bạn gặp lỗi too many clients already từ PostgreSQL. globalThis không bị nạp lại nên client cũ được tái sử dụng. Ở production module chỉ nạp một lần cho mỗi instance nên không cần.

import 'server-only' biến lỗi "lỡ import Prisma vào Client Component" thành lỗi build thay vì một bundle chứa connection string. Cái giá: module này chỉ import được trong môi trường của Next.js, đó là lý do seed script tự tạo client riêng.

connectionTimeoutMillis: 5_000 quan trọng hơn vẻ ngoài. Với Prisma 7, pool là của driver pg, mặc định chờ lấy connection vô hạn. Pool cạn mà không có timeout thì request treo tới khi function bị nền tảng kill.

Bước 4: getCurrentUser() và kiểm tra quyền

Phần đăng nhập (OAuth, magic link, password) nên giao cho một thư viện như Auth.js, Better Auth, hoặc dịch vụ như Clerk. Thứ mình muốn bạn giữ bất kể chọn gì là ranh giới: cả app chỉ biết đến getCurrentUser(). Đổi nhà cung cấp auth thì chỉ sửa thân hàm này. Bản dưới đọc session từ bảng Session của chính app:

// src/lib/auth.ts
import 'server-only'
import { cache } from 'react'
import { createHash } from 'node:crypto'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { prisma } from '@/lib/prisma'

export type CurrentUser = { id: string; email: string; name: string | null }

const SESSION_COOKIE = 'tracker_session'

// cache(): nhiều component gọi trong cùng một request chỉ tốn một query
export const getCurrentUser = cache(async (): Promise<CurrentUser | null> => {
  const token = (await cookies()).get(SESSION_COOKIE)?.value
  if (!token) return null

  const session = await prisma.session.findUnique({
    where: { tokenHash: createHash('sha256').update(token).digest('hex') },
    select: { expiresAt: true, user: { select: { id: true, email: true, name: true } } },
  })
  if (!session || session.expiresAt <= new Date()) return null
  return session.user
})

export async function requireUser(): Promise<CurrentUser> {
  const user = await getCurrentUser()
  if (!user) redirect('/login') // trang đăng nhập do thư viện auth cung cấp
  return user
}

export const getMembership = cache(async (projectKey: string, userId: string) => {
  return prisma.projectMember.findFirst({
    where: { userId, project: { key: projectKey } },
    select: {
      role: true,
      projectId: true,
      project: { select: { key: true, name: true } },
    },
  })
})

Hai quy tắc về authorization mà mình áp dụng cho mọi file trong bài:

  1. Kiểm tra ở mọi điểm vào. Layout đã kiểm tra không có nghĩa page an toàn, và page đã kiểm tra không có nghĩa Server Action an toàn. Server Action là một endpoint POST công khai: bất kỳ ai có action ID đều gọi được nó với tham số tùy ý, không cần đi qua UI của bạn.
  2. Đưa điều kiện quyền vào WHERE. Thay vì "tìm issue theo id, rồi kiểm tra issue có thuộc project của user không", query luôn là where: { id, projectId: membership.projectId }. Không có bước nào để quên, và lỗi IDOR (đổi id trên URL để xem dữ liệu người khác) biến mất ở tầng query.

Khi user không phải thành viên, trang trả notFound() thay vì 403. Trả 403 nghĩa là xác nhận project đó tồn tại.

Bước 5: Trang danh sách với lọc, tìm kiếm và cursor pagination

Hằng số dùng chung giữa server và client:

// src/lib/issues.ts
import type { IssuePriority, IssueStatus } from '@/generated/prisma/client'

export const ISSUE_STATUSES = ['TODO', 'IN_PROGRESS', 'DONE', 'CANCELED'] as const satisfies readonly IssueStatus[]
export const ISSUE_PRIORITIES = ['LOW', 'MEDIUM', 'HIGH'] as const satisfies readonly IssuePriority[]

export const STATUS_LABEL: Record<IssueStatus, string> = {
  TODO: 'Cần làm',
  IN_PROGRESS: 'Đang làm',
  DONE: 'Hoàn thành',
  CANCELED: 'Đã hủy',
}

import type bị xóa khi compile, nên file này import được vào Client Component mà không kéo code Prisma vào bundle. Record<IssueStatus, string> bắt bạn bổ sung label khi schema thêm status mới.

// src/app/projects/[projectKey]/issues/page.tsx
import Link from 'next/link'
import Form from 'next/form'
import { notFound } from 'next/navigation'
import { z } from 'zod'
import { prisma } from '@/lib/prisma'
import { requireUser, getMembership } from '@/lib/auth'
import { ISSUE_STATUSES, STATUS_LABEL } from '@/lib/issues'
import { createIssue } from './actions'
import { NewIssueForm } from './new-issue-form'
import { IssueStatusSelect } from './issue-status-select'

const PAGE_SIZE = 25

// searchParams là input của user: giá trị sai thì bỏ qua, không throw
const filtersSchema = z.object({
  status: z.enum(ISSUE_STATUSES).optional().catch(undefined),
  q: z.string().trim().min(1).max(100).optional().catch(undefined),
  cursor: z.uuid().optional().catch(undefined),
})

type Props = {
  params: Promise<{ projectKey: string }>
  searchParams: Promise<Record<string, string | string[] | undefined>>
}

function issuesHref(projectKey: string, query: Record<string, string | undefined>) {
  const sp = new URLSearchParams()
  for (const [key, value] of Object.entries(query)) if (value) sp.set(key, value)
  const qs = sp.toString()
  return `/projects/${projectKey}/issues${qs ? `?${qs}` : ''}`
}

export default async function IssuesPage({ params, searchParams }: Props) {
  const [{ projectKey }, rawSearch, user] = await Promise.all([params, searchParams, requireUser()])

  const membership = await getMembership(projectKey, user.id)
  if (!membership) notFound()

  const filters = filtersSchema.parse(rawSearch)

  const rows = await prisma.issue.findMany({
    where: {
      projectId: membership.projectId,
      status: filters.status,
      title: filters.q ? { contains: filters.q, mode: 'insensitive' } : undefined,
      id: filters.cursor ? { lt: filters.cursor } : undefined,
    },
    orderBy: { id: 'desc' },
    take: PAGE_SIZE + 1, // lấy dư 1 dòng để biết còn trang sau không
    select: {
      id: true,
      title: true,
      status: true,
      priority: true,
      assignee: { select: { name: true, email: true } },
    },
  })

  const hasMore = rows.length > PAGE_SIZE
  const issues = hasMore ? rows.slice(0, PAGE_SIZE) : rows
  const nextCursor = hasMore ? issues[issues.length - 1].id : undefined

  return (
    <main className="mx-auto max-w-4xl space-y-6 p-6">
      <h1 className="text-2xl font-semibold">{membership.project.name}</h1>

      <nav className="flex flex-wrap gap-2 text-sm">
        <Link href={issuesHref(projectKey, { q: filters.q })}>Tất cả</Link>
        {ISSUE_STATUSES.map((s) => (
          <Link
            key={s}
            href={issuesHref(projectKey, { status: s, q: filters.q })}
            aria-current={filters.status === s ? 'page' : undefined}
          >
            {STATUS_LABEL[s]}
          </Link>
        ))}
      </nav>

      <Form action={`/projects/${projectKey}/issues`} className="flex gap-2">
        {filters.status && <input type="hidden" name="status" value={filters.status} />}
        <input name="q" defaultValue={filters.q} placeholder="Tìm theo tiêu đề" className="border px-2 py-1" />
        <button type="submit">Tìm</button>
      </Form>

      <NewIssueForm action={createIssue.bind(null, projectKey)} />

      <ul className="divide-y">
        {issues.map((issue) => (
          <li key={issue.id} className="flex items-center justify-between gap-4 py-2">
            <Link href={`/projects/${projectKey}/issues/${issue.id}`}>{issue.title}</Link>
            <span className="text-sm text-gray-500">{issue.assignee?.name ?? 'Chưa giao'}</span>
            <IssueStatusSelect projectKey={projectKey} issueId={issue.id} status={issue.status} />
          </li>
        ))}
      </ul>
      {issues.length === 0 && <p>Không có issue nào khớp bộ lọc.</p>}

      {nextCursor && (
        <Link href={issuesHref(projectKey, { status: filters.status, q: filters.q, cursor: nextCursor })}>
          Trang sau
        </Link>
      )}
    </main>
  )
}

Từ Next.js 15, params và searchParams là Promise, và Next.js 16 xóa hẳn cách truy cập đồng bộ. Promise.all chạy ba việc độc lập song song thay vì tuần tự.

Vì sao id < cursor mà không dùng option cursor của Prisma: option cursor: { id } kèm skip: 1 dựa vào việc đọc lại chính bản ghi làm cursor để xác định vị trí. Nếu issue đó vừa bị xóa giữa hai lần bấm "Trang sau", trang kế tiếp có thể hỏng. So sánh id < cursor chỉ cần giá trị, bản ghi không còn cũng không sao, và nó khớp thẳng với index (projectId, status, id DESC). Điều kiện này chỉ đúng vì id là UUID v7 có thứ tự thời gian; với UUID v4 bạn cần sort theo (createdAt, id) và cursor gồm cả hai giá trị.

Chi phí cần biết: contains với mode: 'insensitive' thành ILIKE '%...%' trong PostgreSQL, không dùng được B-tree index. Với vài nghìn issue mỗi project thì không sao vì projectId đã thu hẹp phạm vi; khi lên hàng trăm nghìn, thêm GIN index pg_trgm bằng migration SQL.

Component next/form render thẻ <form> thường nhưng khi submit với action là chuỗi URL, nó điều hướng phía client và cập nhật searchParams mà không reload trang.

Ba file đi kèm cho trạng thái loading, lỗi và không tìm thấy:

// src/app/projects/[projectKey]/issues/loading.tsx
export default function Loading() {
  return (
    <main className="mx-auto max-w-4xl space-y-2 p-6" aria-busy="true">
      {Array.from({ length: 8 }, (_, i) => (
        <div key={i} className="h-8 animate-pulse rounded bg-gray-200" />
      ))}
    </main>
  )
}
// src/app/projects/[projectKey]/issues/error.tsx
'use client'

import { useEffect } from 'react'

export default function IssuesError({ error, retry }: { error: unknown; retry: () => void }) {
  useEffect(() => {
    console.error(error) // thay bằng Sentry hoặc dịch vụ log của bạn
  }, [error])

  const digest = error instanceof Error && 'digest' in error ? String(error.digest) : undefined
  return (
    <main className="mx-auto max-w-4xl p-6">
      <h2>Không tải được danh sách issue.</h2>
      {digest && <p className="text-sm text-gray-500">Mã lỗi: {digest}</p>}
      <button onClick={() => retry()}>Thử lại</button>
    </main>
  )
}
// src/app/projects/[projectKey]/issues/not-found.tsx
import Link from 'next/link'

export default function IssuesNotFound() {
  return (
    <main className="mx-auto max-w-4xl p-6">
      <h2>Không tìm thấy project hoặc issue.</h2>
      <p>Có thể nó không tồn tại, hoặc bạn không phải thành viên.</p>
      <Link href="/">Về trang chủ</Link>
    </main>
  )
}

error.tsx phải là Client Component. Từ Next.js 16.3, prop retry() (fetch lại dữ liệu và render lại segment) là API ổn định, reset() chỉ xóa trạng thái lỗi mà không fetch lại; các bản 16 cũ hơn và 15 chỉ có reset. Trên production, lỗi từ Server Component đến client chỉ có message chung chung và digest; hiển thị digest để user báo lại, rồi tìm nó trong server log.

Trang chi tiết dùng cùng khuôn kiểm tra quyền và notFound():

// src/app/projects/[projectKey]/issues/[issueId]/page.tsx
import { notFound } from 'next/navigation'
import { z } from 'zod'
import { prisma } from '@/lib/prisma'
import { requireUser, getMembership } from '@/lib/auth'
import { STATUS_LABEL } from '@/lib/issues'

export default async function IssuePage({
  params,
}: {
  params: Promise<{ projectKey: string; issueId: string }>
}) {
  const { projectKey, issueId } = await params
  if (!z.uuid().safeParse(issueId).success) notFound() // tránh lỗi cast uuid từ PostgreSQL

  const user = await requireUser()
  const membership = await getMembership(projectKey, user.id)
  if (!membership) notFound()

  const issue = await prisma.issue.findFirst({
    where: { id: issueId, projectId: membership.projectId },
    select: {
      title: true,
      description: true,
      status: true,
      reporter: { select: { name: true } },
      updatedAt: true,
    },
  })
  if (!issue) notFound()

  return (
    <main className="mx-auto max-w-3xl space-y-4 p-6">
      <h1 className="text-2xl font-semibold">{issue.title}</h1>
      <p>
        {STATUS_LABEL[issue.status]} · tạo bởi {issue.reporter.name ?? 'ẩn danh'}
      </p>
      <p className="whitespace-pre-wrap">{issue.description ?? 'Chưa có mô tả.'}</p>
    </main>
  )
}

Validate issueId trước khi query không phải cho đẹp: chuỗi không phải UUID truyền vào cột @db.Uuid khiến PostgreSQL báo lỗi cast, và trang hiện error boundary thay vì 404.

Bước 6: Server Actions tạo issue với zod và useActionState

// src/app/projects/[projectKey]/issues/actions.ts
'use server'

import { revalidatePath } from 'next/cache'
import { z } from 'zod'
import { prisma } from '@/lib/prisma'
import { getCurrentUser, getMembership } from '@/lib/auth'
import { ISSUE_PRIORITIES, ISSUE_STATUSES } from '@/lib/issues'

const createIssueSchema = z.object({
  title: z.string().trim().min(3, 'Tiêu đề cần ít nhất 3 ký tự').max(200, 'Tiêu đề tối đa 200 ký tự'),
  description: z.string().trim().max(10_000, 'Mô tả quá dài').optional(),
  priority: z.enum(ISSUE_PRIORITIES, { error: 'Mức ưu tiên không hợp lệ' }),
})

export type CreateIssueState = {
  status: 'idle' | 'success' | 'error'
  message?: string
  fieldErrors?: { title?: string[]; description?: string[]; priority?: string[] }
  values?: { title: string; description: string; priority: string }
}

export async function createIssue(
  projectKey: string, // từ .bind(): vẫn là input từ client, không tin
  _prev: CreateIssueState,
  formData: FormData
): Promise<CreateIssueState> {
  const user = await getCurrentUser()
  if (!user) return { status: 'error', message: 'Phiên đăng nhập đã hết hạn.' }

  const membership = await getMembership(projectKey, user.id)
  if (!membership) return { status: 'error', message: 'Bạn không có quyền tạo issue trong project này.' }

  const values = {
    title: String(formData.get('title') ?? ''),
    description: String(formData.get('description') ?? ''),
    priority: String(formData.get('priority') ?? ''),
  }
  const parsed = createIssueSchema.safeParse({
    ...values,
    description: values.description.trim() || undefined,
  })
  if (!parsed.success) {
    return { status: 'error', fieldErrors: z.flattenError(parsed.error).fieldErrors, values }
  }

  await prisma.issue.create({
    data: { ...parsed.data, projectId: membership.projectId, reporterId: user.id },
  })

  revalidatePath(`/projects/${projectKey}/issues`)
  return { status: 'success', message: `Đã tạo issue "${parsed.data.title}".` }
}

const updateStatusSchema = z.object({
  issueId: z.uuid(),
  status: z.enum(ISSUE_STATUSES),
})

export type ActionResult = { ok: true } | { ok: false; message: string }

export async function updateIssueStatus(
  projectKey: string,
  input: { issueId: string; status: string }
): Promise<ActionResult> {
  const user = await getCurrentUser()
  if (!user) return { ok: false, message: 'Phiên đăng nhập đã hết hạn.' }

  const parsed = updateStatusSchema.safeParse(input)
  if (!parsed.success) return { ok: false, message: 'Dữ liệu không hợp lệ.' }

  const membership = await getMembership(projectKey, user.id)
  if (!membership) return { ok: false, message: 'Bạn không có quyền với project này.' }

  // Điều kiện quyền nằm trong WHERE: issue của project khác sẽ không bị chạm tới
  const { count } = await prisma.issue.updateMany({
    where: { id: parsed.data.issueId, projectId: membership.projectId },
    data: { status: parsed.data.status },
  })
  if (count === 0) return { ok: false, message: 'Issue không tồn tại hoặc đã bị xóa.' }

  revalidatePath(`/projects/${projectKey}/issues`)
  return { ok: true }
}

Thứ tự trong mỗi action luôn là: xác thực, validate, phân quyền, ghi, revalidate. Lỗi mà user sửa được (validation, hết phiên) được trả về dưới dạng state. Chỉ lỗi bất ngờ (database sập) mới để throw và rơi vào error.tsx. Lý do: trên production, message của lỗi throw từ server bị Next.js ẩn đi, nên throw để báo "tiêu đề quá ngắn" thì user chỉ thấy một thông báo chung chung.

revalidatePath đánh dấu dữ liệu của route là cũ. Khi action được gọi từ chính trang đó, response của action mang theo RSC payload mới, nên danh sách cập nhật ngay mà không cần router.refresh().

Client Component cho form:

// src/app/projects/[projectKey]/issues/new-issue-form.tsx
'use client'

import { useActionState } from 'react'
import type { CreateIssueState } from './actions'
import { ISSUE_PRIORITIES } from '@/lib/issues'

type Props = {
  action: (prev: CreateIssueState, formData: FormData) => Promise<CreateIssueState>
}

const initialState: CreateIssueState = { status: 'idle' }

export function NewIssueForm({ action }: Props) {
  const [state, formAction, isPending] = useActionState(action, initialState)

  return (
    <form action={formAction} className="space-y-2 rounded border p-4">
      <div>
        <input
          name="title"
          defaultValue={state.values?.title}
          placeholder="Tiêu đề issue"
          aria-invalid={Boolean(state.fieldErrors?.title)}
          aria-describedby="title-error"
          className="w-full border px-2 py-1"
        />
        {state.fieldErrors?.title && (
          <p id="title-error" className="text-sm text-red-600">{state.fieldErrors.title[0]}</p>
        )}
      </div>

      <textarea
        name="description"
        defaultValue={state.values?.description}
        placeholder="Mô tả (không bắt buộc)"
        className="w-full border px-2 py-1"
      />

      <select name="priority" defaultValue={state.values?.priority ?? 'MEDIUM'}>
        {ISSUE_PRIORITIES.map((p) => (
          <option key={p} value={p}>{p}</option>
        ))}
      </select>

      <button type="submit" disabled={isPending}>
        {isPending ? 'Đang tạo...' : 'Tạo issue'}
      </button>

      {state.message && (
        <p role="status" className={state.status === 'error' ? 'text-red-600' : 'text-green-700'}>
          {state.message}
        </p>
      )}
    </form>
  )
}

Vài chi tiết đáng lưu ý:

  • Bind ở Server Component, truyền xuống làm prop. createIssue.bind(null, projectKey) tạo một Server Action mới với tham số đầu cố định. Tham số bind vẫn được gửi từ client lên, nên action phải kiểm tra membership với projectKey đó như mọi input khác.
  • React 19 reset form sau khi action chạy xong. Nếu không trả values về và dùng làm defaultValue, user gõ sai một field là mất cả form. Khi thành công, state không có values nên form trống, đúng hành vi mong muốn.
  • Form vẫn chạy khi JavaScript chưa tải xong. Với <form action={formAction}>, React hỗ trợ progressive enhancement: submit trước hydration vẫn gửi được.

Bước 7: Optimistic UI khi đổi status

Đổi status là thao tác nhỏ, user muốn thấy kết quả ngay chứ không đợi round trip:

// src/app/projects/[projectKey]/issues/issue-status-select.tsx
'use client'

import { useOptimistic, useState, useTransition } from 'react'
import type { IssueStatus } from '@/generated/prisma/client'
import { ISSUE_STATUSES, STATUS_LABEL } from '@/lib/issues'
import { updateIssueStatus } from './actions'

type Props = { projectKey: string; issueId: string; status: IssueStatus }

export function IssueStatusSelect({ projectKey, issueId, status }: Props) {
  const [optimisticStatus, setOptimisticStatus] = useOptimistic(status)
  const [isPending, startTransition] = useTransition()
  const [error, setError] = useState<string | null>(null)

  return (
    <div className="flex items-center gap-2">
      <select
        value={optimisticStatus}
        aria-busy={isPending}
        onChange={(e) => {
          const next = e.target.value as IssueStatus
          setError(null)
          startTransition(async () => {
            setOptimisticStatus(next) // UI đổi ngay
            const result = await updateIssueStatus(projectKey, { issueId, status: next })
            if (!result.ok) setError(result.message) // transition kết thúc, UI quay về status thật
          })
        }}
      >
        {ISSUE_STATUSES.map((s) => (
          <option key={s} value={s}>{STATUS_LABEL[s]}</option>
        ))}
      </select>
      {error && <span className="text-xs text-red-600">{error}</span>}
    </div>
  )
}

useOptimistic(status) trả về giá trị tạm thời chỉ tồn tại trong lúc transition đang chạy. Khi action thành công, revalidatePath khiến Server Component render lại và truyền status mới xuống, giá trị optimistic và giá trị thật trùng nhau. Khi action thất bại, transition kết thúc mà status prop không đổi, nên dropdown tự quay về giá trị cũ; bạn không phải viết code rollback.

Bẫy: setOptimisticStatus phải được gọi bên trong startTransition (hoặc trong một action). Gọi ngoài transition, React cảnh báo và giá trị optimistic không hoạt động đúng.

Khi nào không nên làm optimistic: thao tác không đảo ngược được (xóa project, gửi hóa đơn), thao tác có khả năng bị từ chối cao (vượt quota, xung đột với người khác đang sửa), hoặc thao tác mà kết quả do server quyết định (số thứ tự issue, giá sau khuyến mãi). Với những trường hợp đó, hiển thị trạng thái pending (isPending) và đợi kết quả thật. Optimistic hợp với thao tác nhỏ, gần như luôn thành công và dễ hoàn tác như đổi status, gắn nhãn, đánh dấu đã đọc.

Chạy thử toàn bộ luồng ở local

Trước khi deploy, mình đi qua các trường hợp biên bằng tay một lần, vì đó là chỗ code kiểu này hay sai:

  1. Chạy npm run dev, mở DevTools, tab Application, mục Cookies của localhost:3000, thêm cookie tracker_session với giá trị in ra từ seed. Vào /projects/WEB/issues, bạn thấy 25 issue và link "Trang sau".
  2. Bấm "Trang sau" vài lần đến khi hết. Trang cuối không còn link, và không có issue nào lặp lại giữa các trang.
  3. Sửa URL thành ?status=abc và ?cursor=khong-phai-uuid. Trang vẫn render, bộ lọc sai bị bỏ qua thay vì hiện error page.
  4. Vào /projects/WEB/issues/ kèm một UUID ngẫu nhiên, rồi kèm chuỗi abc. Cả hai hiện not-found.tsx, không cái nào làm sập trang.
  5. Submit form với tiêu đề "ab". Lỗi hiện dưới ô tiêu đề, phần mô tả bạn đã gõ vẫn còn.
  6. Tắt database (docker stop tracker-pg) rồi tải lại trang. error.tsx hiện ra; bật lại database và bấm "Thử lại", danh sách quay lại mà không cần reload trình duyệt.
  7. Tạo thêm một user và một session không thuộc project WEB (bằng Prisma Studio hoặc sửa seed), đổi cookie sang session đó. Trang danh sách phải trả 404, và gọi updateIssueStatus với một issueId của WEB phải trả về lỗi quyền mà issue không đổi.

Bước 7 là bước quan trọng nhất. Nếu chỉ kịp viết một test tự động cho app này, hãy viết test cho đúng kịch bản đó.

Logic nào nằm ở đâu

Loại logic Đặt ở Lý do
Đọc dữ liệu để render trang Server Component Gọi Prisma trực tiếp, không lộ API, không có waterfall client
Mutation từ UI của chính app (form, nút) Server Action Có progressive enhancement, gắn với revalidatePath, không cần tự viết endpoint
Endpoint cho bên ngoài: webhook, mobile app, cron, tải file CSV Route Handler (route.ts) Cần URL ổn định, method và status code rõ ràng, cache header tùy chỉnh
Tương tác tức thời: optimistic, dropdown, phím tắt Client Component Cần state và event handler trên trình duyệt
Redirect sớm, rewrite, header theo request proxy.ts (trước đây là middleware.ts) Chạy trước khi render; không thay được kiểm tra quyền trong page và action
Kiểm tra quyền Mọi page, Server Action, Route Handler, qua helper trong lib/auth.ts Mỗi điểm vào đều có thể bị gọi trực tiếp
Business rule dùng chung Module trong lib/ có server-only Một nơi để test, không bị import nhầm vào client

Từ Next.js 16, file middleware.ts được đổi tên thành proxy.ts và chạy trên Node.js runtime. Dù tên gì, đừng dựa vào nó làm lớp phân quyền duy nhất: matcher sai một ký tự là cả nhánh route không được bảo vệ.

Bước 8: Deploy lên Vercel với Postgres managed

Vercel không còn sản phẩm Postgres riêng; bạn tạo database qua Marketplace (Neon, Supabase, Prisma Postgres và các nhà cung cấp khác) và integration sẽ thêm biến môi trường vào project. Tên biến khác nhau tùy nhà cung cấp, nhưng gần như luôn có hai loại: URL đi qua pooler và URL kết nối trực tiếp. Map chúng vào hai biến app của bạn đang dùng:

Biến Giá trị Ai dùng
DATABASE_URL URL pooled (PgBouncer/Supavisor/endpoint pooled) App lúc runtime qua PrismaPg
DIRECT_URL URL direct Prisma CLI: migrate deploy, và prisma generate lúc build vì prisma.config.ts gọi env('DIRECT_URL')

Cái bẫy thứ nhất: env() của prisma/config ném lỗi nếu biến không tồn tại, và postinstall chạy prisma generate trên Vercel sẽ load file config đó. Thiếu DIRECT_URL trong môi trường build là build fail.

Cái bẫy thứ hai: preview deployment. Mỗi pull request có một preview chạy code mới; nếu biến môi trường Preview trỏ vào database production, một migration thử nghiệm hoặc một nút "xóa" trong preview sẽ chạm dữ liệu thật. Dùng database (hoặc branch database, nếu nhà cung cấp hỗ trợ) riêng cho Preview.

Không chạy prisma migrate deploy trong build command của Vercel. Build chạy cho cả preview, chạy song song khi có nhiều commit, và một migration fail giữa build để lại database ở trạng thái không khớp với deployment nào. Chạy nó trong CI, trước khi deploy production:

# .github/workflows/deploy-production.yml
name: deploy-production
on:
  push:
    branches: [main]

jobs:
  migrate:
    runs-on: ubuntu-latest
    environment: production
    concurrency: production-migrations
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
        env:
          DIRECT_URL: ${{ secrets.DIRECT_URL }}
      - run: npx prisma migrate deploy
        env:
          DIRECT_URL: ${{ secrets.DIRECT_URL }}

  deploy:
    needs: migrate
    runs-on: ubuntu-latest
    environment: production
    env:
      VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
      VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
    steps:
      - uses: actions/checkout@v4
      - run: npm install --global vercel@latest
      - run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
      - run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}
      - run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }}

Với cách này, bạn tắt auto-deploy production từ Git integration của Vercel (giữ preview), để deploy production chỉ xảy ra sau khi migration thành công. Nếu muốn giữ auto-deploy cho đơn giản, quy tắc bắt buộc là mọi migration phải tương thích ngược với code đang chạy: thêm cột nullable trước, xóa cột ở một release sau. Kỹ thuật expand-and-contract, cách tạo index không khóa bảng và cách xử lý migration fail mình đã viết chi tiết trong bài Prisma ORM trong production, ở đây không lặp lại.

Về connection: mỗi instance function có một pool riêng với max: 5 như trong lib/prisma.ts. Nhân với số instance chạy đồng thời lúc cao điểm, con số đó phải nằm dưới giới hạn connection của gói database bạn mua, đó là lý do runtime phải đi qua pooler. Trên Vercel với Fluid compute, bạn có thể truyền một pg.Pool vào PrismaPg và gắn attachDatabasePool() để connection idle được đóng trước khi instance bị suspend; chi tiết ở bài Serverless functions trên Vercel và Netlify.

Bảo mật, hiệu năng và kiểm thử trước khi mở cho user

Bảo mật. Server Actions có sẵn một lớp chống CSRF: Next.js so sánh header Origin với host của request và từ chối khi không khớp. Nhưng nó không biết gì về quyền: kiểm tra quyền trong mọi action là việc của bạn. Không bao giờ dùng giá trị từ client (hidden input, tham số bind, issueId) để quyết định quyền; chỉ dùng chúng để tìm dữ liệu, kèm projectId lấy từ membership phía server. Đừng trả cả object Prisma xuống Client Component: mọi field trong props đều nằm trong HTML và RSC payload; select đúng field cần hiển thị.

Hiệu năng. Mỗi query trên trang danh sách khớp với một index có sẵn. Bật log query của Prisma ở dev và đếm số query mỗi lần load trang: trang danh sách ở trên tốn một query cho session, một cho membership, một cho issues (relation assignee được Prisma lấy bằng một query IN riêng). Nếu con số tăng theo số dòng hiển thị, bạn có N+1.

Kiểm thử. Phần đáng test nhất là quyền: một integration test gọi updateIssueStatus với user không thuộc project và assert issue không đổi. Chạy test đó với PostgreSQL thật (container trong CI), vì mock Prisma sẽ không bắt được lỗi trong WHERE. Chiến lược chi tiết ở bài Chiến lược testing cho web app.

Những lỗi thường gặp

Tạo PrismaClient trong mỗi file hoặc mỗi request.

// Sai: mỗi lần import hoặc mỗi request là một pool mới
export async function getIssues() {
  const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }) })
  return prisma.issue.findMany()
}
// Đúng: import singleton
import { prisma } from '@/lib/prisma'

Kiểm tra quyền ở layout rồi coi như xong. Server Action không đi qua layout. Mọi action tự gọi getCurrentUser() và getMembership().

Tìm theo id rồi mới kiểm tra quyền, hoặc quên kiểm tra.

// Sai: ai biết issueId của project khác cũng sửa được
await prisma.issue.update({ where: { id: issueId }, data: { status } })
// Đúng: quyền nằm trong WHERE
await prisma.issue.updateMany({ where: { id: issueId, projectId: membership.projectId }, data: { status } })

Bọc redirect() trong try/catch. redirect() và notFound() hoạt động bằng cách throw một lỗi đặc biệt. catch nuốt nó thì redirect không xảy ra. Gọi chúng bên ngoài try, hoặc rethrow.

Throw lỗi validation từ Server Action. Trên production, user chỉ thấy thông báo lỗi chung và error boundary thay cho form. Trả lỗi về dưới dạng state.

Tin searchParams. ?status=abc hoặc ?status=TODO&status=DONE (thành mảng) đi thẳng vào Prisma sẽ ném lỗi validation và hiện error page. Parse bằng zod với .catch(undefined).

Import Prisma vào Client Component. Không có server-only, lỗi này có thể chỉ hiện ra lúc build với thông báo khó hiểu về module pg, hoặc tệ hơn là kéo code server vào bundle. Giữ import 'server-only' trong lib/prisma.ts và lib/auth.ts.

migrate deploy trong build command. Preview build cũng chạy migration, hai build song song tranh nhau. Chạy trong CI với concurrency.

Checklist

  • Pin prisma và @prisma/client ở major 7, thư mục src/generated/prisma nằm trong .gitignore, postinstall chạy prisma generate.
  • lib/prisma.ts là nơi duy nhất tạo PrismaClient, có cache globalThis ở dev và import 'server-only'.
  • Mỗi query trên trang danh sách khớp một index (projectId, [status,] id DESC).
  • Mọi page, Server Action và Route Handler gọi getCurrentUser() và kiểm tra membership; điều kiện quyền nằm trong WHERE.
  • searchParams và input của action được parse bằng zod; lỗi user sửa được trả về qua state cho useActionState.
  • Mỗi action ghi dữ liệu gọi revalidatePath cho route bị ảnh hưởng.
  • Segment quan trọng có loading.tsx, error.tsx (dùng retry) và not-found.tsx.
  • Vercel có DATABASE_URL (pooled) và DIRECT_URL (direct) cho cả build lẫn runtime; Preview dùng database riêng.
  • prisma migrate deploy chạy trong CI trước khi deploy production, không nằm trong build command.
  • Có ít nhất một integration test chứng minh user ngoài project không sửa được issue.

Đọc thêm

Last updated: October 19, 2026

Comments