BlogBlog · nextjs

Next.js App Router và React Server Components: mental model, data fetching và caching (cập nhật Next.js 15–16)

Ranh giới 'use client' thực sự đánh dấu gì, fetch song song và streaming với Suspense, Server Actions an toàn, và caching thay đổi ra sao từ Next.js 14, 15 đến 16 với Cache Components, 'use cache', cacheLife, cacheTag.

Part of Tech Radar series
April 12, 2026
22 min read
By Leo Pham
nextjsreactserver-componentscachingapp-router
Share this post:

Một team nâng dự án từ Next.js 14 lên 16 và gặp đủ ba loại bug trong một tuần. Trang danh sách sản phẩm bỗng gọi database ở mọi request vì fetch không còn được cache mặc định. Một trang khác lại hiển thị dữ liệu "đông cứng" từ lúc build. Còn 'use client' đặt ở root layout từ thời migrate đầu tiên khiến toàn bộ app thành Client Component, bundle phình to mà không ai để ý.

Nguyên nhân chung: App Router thay đổi mặc định qua từng major version, và mental model "Server Component là gì, chạy ở đâu, được cache khi nào" chưa bao giờ được team thống nhất. Bài này dựng lại mental model đó từ đầu, rồi đi qua data fetching, Server Actions, và đặc biệt là caching qua Next.js 14, 15 và 16, nơi Cache Components với 'use cache' đang thay thế toàn bộ mô hình cache ngầm định cũ.

Mốc phiên bản để bạn đối chiếu: Next.js 14 (26/10/2023), 15 (21/10/2024), 16 (21/10/2025), 16.3 (3/8/2026) và 16.4 (6/10/2026). Bản mới nhất trên npm lúc cập nhật bài là 16.4.4. Mọi chi tiết về flag và API mình dẫn từ release notes và docs chính thức, link ở cuối bài.

TL;DR:

  • 'use client' đánh dấu điểm vào client bundle của một module graph, không phải "component chỉ chạy ở trình duyệt". Server Component truyền vào dưới dạng children vẫn là Server Component.
  • Fetch song song bằng Promise.all, stream phần chậm bằng <Suspense> hoặc loading.tsx. Server Action là POST endpoint công khai: luôn validate, authenticate, authorize.
  • Next.js 15 bỏ cache mặc định cho fetch và GET Route Handler, đồng thời chuyển params, searchParams, cookies(), headers() sang async. Next.js 16 xóa hẳn kiểu truy cập sync.
  • Next.js 16 có Cache Components (cacheComponents: true): không gì được cache trừ khi bạn đánh dấu 'use cache' kèm cacheLife/cacheTag. Từ 16.4, Vercel khuyên dùng cho mọi app, và nó sẽ thành mặc định ở Next.js 17.

Mental model: Server Component, Client Component và ranh giới 'use client'

Trong App Router, mọi page.tsx, layout.tsx và component chúng import đều là Server Component theo mặc định. Chúng chạy trên server (lúc build hoặc lúc request), có thể await database trực tiếp, và output của chúng là RSC Payload: một dạng serialize của cây UI đã render, kèm chỗ trống cho Client Component và tham chiếu tới file JS của chúng. Code của Server Component không bao giờ xuống trình duyệt.

'use client' ở đầu file khai báo ranh giới giữa hai module graph. File đó, mọi thứ nó import và mọi component nó render trực tiếp đều vào client bundle. Client Component vẫn được render ra HTML trên server ở lần tải đầu, rồi hydrate trên trình duyệt. Nên 'use client' không có nghĩa "chỉ render ở client", mà là "cần JavaScript ở client để tương tác".

// app/orders/[id]/order-actions-panel.tsx
'use client'

import { useState, type ReactNode } from 'react'

export function OrderActionsPanel({ title, children }: { title: string; children: ReactNode }) {
  const [open, setOpen] = useState(false)
  return (
    <section>
      <button onClick={() => setOpen((o) => !o)} aria-expanded={open}>{title}</button>
      {open && children /* children là output đã render sẵn trên server */}
    </section>
  )
}
// app/orders/[id]/page.tsx: Server Component
import { OrderActionsPanel } from './order-actions-panel'
import { InvoiceList } from './invoice-list' // Server Component: query DB trực tiếp

export default async function OrderPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  return (
    <OrderActionsPanel title="Hóa đơn của đơn hàng">
      <InvoiceList orderId={id} />
    </OrderActionsPanel>
  )
}

InvoiceList không bị kéo vào client bundle dù nằm "bên trong" một Client Component. Nó không được import bởi order-actions-panel.tsx, mà được Server Component cha render trước rồi truyền xuống như dữ liệu. Đây là pattern quan trọng nhất của App Router: đẩy 'use client' xuống lá (nút bấm, form, dropdown) và dùng children/props để lồng UI render từ server vào bên trong.

Props đi qua ranh giới phải serialize được: string, number, boolean, null, plain object, array, Date, Map, Set, BigInt, Promise, JSX đã render, và Server Function (hàm 'use server'). Function thường, instance của class (new Money()), Symbol không có trong registry thì không qua được. Truyền onClick={() => ...} từ Server Component sẽ nhận lỗi "Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with 'use server'".

Context không chạy trong Server Component. Provider phải là Client Component nhận children, và nên đặt càng sâu càng tốt (bọc {children} thay vì cả <html>) để phần còn lại vẫn được tối ưu như Server Component.

Data fetching trong Server Components

Server Component có thể là async. Đọc dữ liệu chỉ là await, không cần useEffect, không cần API route trung gian:

// app/orders/[id]/page.tsx
import { Suspense } from 'react'
import { notFound } from 'next/navigation'
import { getOrder, getCustomer, getInvoices } from '@/lib/orders'
import { OrderTimeline } from './order-timeline'
import { StatusForm } from './status-form'

export default async function OrderPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const order = await getOrder(id)
  if (!order) notFound()

  // ❌ Waterfall: invoices chỉ bắt đầu sau khi customer về
  // const customer = await getCustomer(order.customerId)
  // const invoices = await getInvoices(order.id)

  // ✅ Hai query độc lập chạy song song
  const [customer, invoices] = await Promise.all([getCustomer(order.customerId), getInvoices(order.id)])

  return (
    <main>
      <h1>Đơn {order.code}</h1>
      <p>Khách hàng: {customer.name}</p>
      <p>{invoices.length} hóa đơn</p>
      <StatusForm orderId={order.id} currentStatus={order.status} />

      {/* Phần chậm (join nhiều bảng) stream sau, không chặn phần trên */}
      <Suspense fallback={<p>Đang tải lịch sử đơn…</p>}>
        <OrderTimeline orderId={order.id} />
      </Suspense>
    </main>
  )
}
// lib/orders.ts
import 'server-only'
import { cache } from 'react'
import { db } from '@/lib/db' // Prisma/Drizzle client

// React.cache: gọi bao nhiêu lần trong CÙNG một request cũng chỉ query 1 lần
export const getOrder = cache(async (id: string) => {
  return db.order.findUnique({
    where: { id },
    select: { id: true, code: true, status: true, customerId: true, shopId: true },
  })
})

export async function getCustomer(id: string) {
  return db.customer.findUniqueOrThrow({ where: { id }, select: { id: true, name: true } })
}

export async function getInvoices(orderId: string) {
  return db.invoice.findMany({ where: { orderId }, select: { id: true, total: true, issuedAt: true } })
}

Ba công cụ cần nhớ:

  • Promise.all cho các truy vấn độc lập. Mỗi await tuần tự cộng thêm một vòng round-trip tới database. Ba query 40ms chạy tuần tự thành 120ms, chạy song song còn khoảng 40ms.
  • <Suspense> quanh async component để stream. HTML phần ngoài gửi về trước, phần trong thay fallback khi xong. loading.tsx trong một route segment chính là Suspense boundary bọc page.tsx của segment đó, hiển thị ngay khi điều hướng. Lưu ý: khi stream đã bắt đầu thì status code đã chốt là 200. Có loading.tsx thì notFound() trong page trả về trang 404 với status 200 kèm <meta name="robots" content="noindex">. Cần status 404 thật thì kiểm tra trước khi stream, ví dụ trong proxy.ts.
  • React.cache để layout, page và generateMetadata cùng gọi getOrder(id) mà chỉ tốn một query mỗi request. Phạm vi của nó là một request, không phải cache giữa các request.

Truyền promise chưa await xuống Client Component rồi đọc bằng use() cũng là một cách stream. Mình viết chi tiết trong bài React 19 thực chiến.

Server Actions: mutation, validation và authorization

Hàm 'use server' được Next.js biến thành một endpoint nhận POST. Từ Next.js 15, ID của action không đoán được và action không dùng sẽ bị loại khỏi bundle. Dù vậy, bất kỳ ai mở DevTools đều thấy và gọi lại được action đang dùng với payload tùy ý. Hãy viết nó như viết một API route:

// app/orders/[id]/actions.ts
'use server'

import { z } from 'zod'
import { updateTag } from 'next/cache'
import { getSession } from '@/lib/auth'
import { db } from '@/lib/db'

const UpdateStatusSchema = z.object({
  orderId: z.string().min(1).max(64),
  status: z.enum(['confirmed', 'shipped', 'cancelled']),
  note: z.string().trim().max(500).optional(),
})

export type UpdateStatusState = {
  ok: boolean
  message: string
  fieldErrors?: Partial<Record<'orderId' | 'status' | 'note', string[]>>
}

export async function updateOrderStatus(_prev: UpdateStatusState, formData: FormData): Promise<UpdateStatusState> {
  // 1. Authentication
  const session = await getSession()
  if (!session) return { ok: false, message: 'Phiên đăng nhập đã hết hạn' }

  // 2. Validation: FormData là input không tin cậy
  const parsed = UpdateStatusSchema.safeParse({
    orderId: formData.get('orderId'),
    status: formData.get('status'),
    note: formData.get('note') ?? undefined,
  })
  if (!parsed.success) {
    return { ok: false, message: 'Dữ liệu không hợp lệ', fieldErrors: z.flattenError(parsed.error).fieldErrors }
  }
  const { orderId, status, note } = parsed.data

  // 3. Authorization trên CHÍNH bản ghi này (chống IDOR)
  const order = await db.order.findUnique({ where: { id: orderId }, select: { shopId: true } })
  if (!order || !session.user.shopIds.includes(order.shopId)) {
    return { ok: false, message: 'Không tìm thấy đơn hàng' } // không tiết lộ đơn có tồn tại hay không
  }

  await db.order.update({ where: { id: orderId }, data: { status, statusNote: note } })

  // 4. Làm mới dữ liệu đã cache (Next.js 16)
  updateTag(`order-${orderId}`) // người thao tác thấy ngay thay đổi của mình
  updateTag(`shop-${order.shopId}-orders`)
  return { ok: true, message: 'Đã cập nhật trạng thái' }
}

Phía client, StatusForm là Client Component dùng useActionState(updateOrderStatus, { ok: false, message: '' }) với <form action>. Form submit được cả trước khi JavaScript hydrate.

Những điều cần biết về bảo mật và giới hạn:

  • Không có middleware nào tự bảo vệ action. proxy.ts (tên mới của middleware.ts từ Next.js 16) chỉ là lớp lọc thô. Kiểm tra session và quyền trên từng bản ghi phải nằm trong action hoặc trong data access layer mà action gọi.
  • Next.js chống CSRF bằng cách so host trong header Origin với host của app, khác nhau thì từ chối. Đứng sau reverse proxy không forward x-forwarded-host thì khai báo host công khai trong experimental.serverActions.allowedOrigins.
  • Body của action giới hạn mặc định 1MB (experimental.serverActions.bodySizeLimit). Upload file lớn nên dùng presigned URL thẳng lên object storage.
  • redirect() và notFound() hoạt động bằng cách ném một error đặc biệt. Đặt chúng ngoài try/catch, hoặc gọi unstable_rethrow(error) ở đầu khối catch.

Về làm mới cache sau mutation:

API Phiên bản Gọi ở đâu Hành vi
revalidatePath('/orders') 13.4+ Server Action, Route Handler Vô hiệu hóa cache của cả route
revalidateTag('orders') 13.4–15 Server Action, Route Handler Vô hiệu hóa entry gắn tag
revalidateTag('orders', 'max') 16+ Server Action, Route Handler Stale-while-revalidate: trả bản cũ, làm mới ở nền. Dạng một tham số đã deprecated
updateTag('orders') 16+ Chỉ Server Action Hết hạn ngay, request kế tiếp chờ dữ liệu mới (read-your-own-writes)
refresh() 16+ Chỉ Server Action Làm mới phần dữ liệu không cache trên trang, không đụng cache

Quy tắc chọn: người dùng vừa sửa và phải thấy ngay thay đổi của mình thì dùng updateTag. Webhook từ CMS hay job nền, nơi chậm vài giây không sao, thì dùng revalidateTag(tag, 'max'). Ưu tiên tag hơn path để không vô hiệu hóa thừa.

Caching qua các phiên bản: Next.js 15 tắt mặc định, Next.js 16 có Cache Components

Next.js 14: cache ngầm định ở mọi nơi

fetch trong Server Component được cache vô thời hạn trừ khi bạn dặn khác (cache: 'no-store', next: { revalidate }). GET Route Handler mặc định static. Client Router Cache giữ trang dynamic 30 giây. Hệ quả là rất nhiều bug "sao dữ liệu không đổi", và một nhóm config (dynamic, revalidate, fetchCache) để chữa cháy.

Next.js 15: đảo mặc định, request API thành async

// Next.js 15+: params, searchParams, cookies(), headers(), draftMode() đều là Promise
import { cookies } from 'next/headers'

export default async function ReportsPage({
  params,
  searchParams,
}: {
  params: Promise<{ shopId: string }>
  searchParams: Promise<{ from?: string; to?: string }>
}) {
  const [{ shopId }, { from, to }, cookieStore] = await Promise.all([params, searchParams, cookies()])
  const currency = cookieStore.get('currency')?.value ?? 'VND'
  // ...
  return <h1>Báo cáo shop {shopId} từ {from ?? 'đầu tháng'} đến {to ?? 'hôm nay'} ({currency})</h1>
}

Theo release notes của Next.js 15: fetch, GET Route Handler và điều hướng phía client không còn được cache mặc định. Client Router Cache có staleTime bằng 0 cho page segment, layout dùng chung vẫn không bị fetch lại. Các request API chuyển sang async, kèm codemod npx @next/codemod@canary next-async-request-api .. Ở bản 15, truy cập sync vẫn chạy được nhưng có warning.

Một bẫy của mô hình này: "fetch không cache" không có nghĩa là "trang dynamic". Trang không dùng request API nào vẫn có thể được prerender lúc build, và kết quả fetch bị đóng băng trong HTML. Muốn chắc chắn render lúc request, gọi await connection() (từ next/server) hoặc đọc một request API.

Next.js 16: Cache Components và 'use cache'

Next.js 16 xóa hẳn truy cập sync cho params/cookies()/headers(), đổi middleware.ts thành proxy.ts, dùng Turbopack làm bundler mặc định, và giới thiệu Cache Components: một mô hình mà mọi thứ chạy lúc request, trừ khi bạn đánh dấu cache rõ ràng.

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true, // 16.3+; docs khuyên đặt tường minh, app mới từ 16.4 đã bật sẵn
}

export default nextConfig
// lib/catalog.ts
import 'server-only'
import { cacheLife, cacheTag } from 'next/cache'
import { db } from '@/lib/db'

export async function getProduct(slug: string) {
  'use cache'
  cacheLife('hours') // stale 5 phút, revalidate 1 giờ, expire 1 ngày
  cacheTag(`product-${slug}`)
  return db.product.findUnique({ where: { slug }, select: { id: true, name: true, price: true, slug: true } })
}
// app/products/[slug]/page.tsx
import { Suspense } from 'react'
import { cookies } from 'next/headers'
import { notFound } from 'next/navigation'
import { getProduct } from '@/lib/catalog'
import { getCartCount } from '@/lib/cart'

export async function generateStaticParams() {
  return [{ slug: 'ao-thun-basic' }] // với Cache Components phải trả về ít nhất 1 param
}

// Page không async: params được await bên trong Suspense để shell vẫn prerender được
export default function ProductPage({ params }: { params: Promise<{ slug: string }> }) {
  return (
    <main>
      <Suspense fallback={<p>Đang tải sản phẩm…</p>}>
        <ProductDetails params={params} />
      </Suspense>
      <Suspense fallback={<span>Giỏ hàng: …</span>}>
        <CartBadge />
      </Suspense>
    </main>
  )
}

async function ProductDetails({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const product = await getProduct(slug) // đã cache → slug biết trước nằm luôn trong static shell
  if (!product) notFound()
  return (
    <>
      <h1>{product.name}</h1>
      <p>{product.price.toLocaleString('vi-VN')} đ</p>
    </>
  )
}

async function CartBadge() {
  const sessionId = (await cookies()).get('session')?.value // runtime data → stream lúc request
  const count = sessionId ? await getCartCount(sessionId) : 0
  return <span>Giỏ hàng: {count}</span>
}

Cách Next.js xử lý trang này khi bật Cache Components:

  • Phần không phụ thuộc request cùng kết quả 'use cache' được prerender thành static shell (HTML + RSC payload), phục vụ được từ CDN. Với slug có trong generateStaticParams, thông tin sản phẩm nằm luôn trong shell. Slug khác nhận shell có fallback ngay, rồi được render và cache cho người truy cập sau (ISR). Fallback của CartBadge cũng nằm trong shell, nội dung thật stream vào lúc request. Đây là Partial Prerendering, giờ là hành vi mặc định của mô hình này.
  • Dữ liệu không cache hoặc request API (cookies(), headers(), searchParams) đặt ngoài <Suspense> sẽ bị dev overlay báo "blocking route". Bạn phải chọn: cache nó, bọc Suspense, hoặc chấp nhận route chặn.
  • Math.random(), Date.now(), crypto.randomUUID() trong lúc prerender gây lỗi build. Muốn giá trị riêng mỗi request thì gọi await connection() trước và bọc Suspense, muốn dùng chung thì đặt trong 'use cache'.
  • Tham số và giá trị closure của hàm 'use cache' tự thành cache key, nên chúng phải serialize được. Không đọc cookies()/headers() bên trong 'use cache': đọc ở ngoài rồi truyền giá trị vào, hoặc dùng 'use cache: private' (kết quả chỉ nằm trong cache của trình duyệt, được đưa vào prefetch).
  • Mặc định entry của 'use cache' nằm trong bộ nhớ của từng instance, mất khi serverless instance bị hủy, và mọi cache gắn với một deployment. Cần cache bền và dùng chung giữa các instance thì dùng 'use cache: remote' với cache handler. Dữ liệu cache của fetch và unstable_cache thời trước thì tồn tại qua nhiều deployment, đây là khác biệt cần tính khi migrate.
  • Route segment config dynamic, revalidate, fetchCache sẽ báo lỗi khi bật cacheComponents. dynamicParams và runtime cũng không được hỗ trợ, vì Cache Components yêu cầu Node.js runtime. export const revalidate = 3600 thành 'use cache' + cacheLife('hours').
  • Next.js giữ state của route bằng <Activity mode="hidden"> khi điều hướng đi rồi quay lại. Dropdown đang mở hay kết quả của useActionState vẫn còn nguyên, và đôi khi bạn phải reset chủ động.

Next.js 16.3 thêm Partial Prefetching (prefetch shell của bất kỳ route nào, <Link prefetch> lấy thêm phần cache theo URL), root params, catchError cho error boundary có retry(). Bản 16.4 thêm export const ensureStatic = 'navigation' | 'prefetch' | 'shell' để build thất bại nếu nội dung dynamic lọt vào trang lẽ ra phải tĩnh, cùng await navigation()/await prefetch() để chọn phần nào nằm ngoài prefetch.

Bảng: rendering và caching qua Next.js 14, 15, 16

Hành vi Next.js 14 Next.js 15 Next.js 16 (mô hình cũ) Next.js 16 + Cache Components
fetch trong Server Component Cache mặc định Không cache mặc định Không cache mặc định Không cache, trừ khi nằm trong 'use cache'
GET Route Handler Static mặc định Không cache Không cache Prerender như page nếu không đọc dữ liệu runtime/không cache
Client Router Cache cho page 30 giây (dynamic) staleTime 0 staleTime 0 Theo stale của cacheLife (tối thiểu 30 giây)
params, searchParams, cookies(), headers() Sync Async, sync vẫn chạy kèm warning Chỉ async Chỉ async; đọc ngoài Suspense bị báo blocking
Partial Prerendering Thử nghiệm Thử nghiệm (experimental_ppr theo route) Flag cũ bị xóa Mặc định
API cache chính fetch options, unstable_cache, revalidateTag/Path Như 14 Thêm updateTag, refresh; revalidateTag(tag, profile) 'use cache', cacheLife, cacheTag (stable từ 16.0)
dynamic, revalidate, fetchCache Dùng được Dùng được Dùng được Báo lỗi, thay bằng 'use cache'
Chặn request middleware.ts (Edge) middleware.ts, Node runtime stable từ 15.5 proxy.ts (Node), middleware.ts deprecated Như cột trước
Bundler mặc định webpack webpack (Turbopack cho dev stable) Turbopack Turbopack
React 18 (App Router dùng bản canary) 19 19.2; 16.4 dùng 19.3 Như cột trước

Giữ code server ở server: server-only, NEXT_PUBLIC_ và secrets

// lib/payments.ts
import 'server-only' // Client Component nào import file này → lỗi lúc BUILD, không phải lúc runtime

const PAYMENT_API = 'https://api.payment-gateway.example/v1'

export async function createRefund(orderId: string, amount: number) {
  const res = await fetch(`${PAYMENT_API}/refunds`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.PAYMENT_SECRET_KEY}`, // không có NEXT_PUBLIC_ → chỉ server đọc
      'Content-Type': 'application/json',
      'Idempotency-Key': `refund-${orderId}`,
    },
    body: JSON.stringify({ orderId, amount }),
  })
  if (!res.ok) throw new Error(`Payment API trả về ${res.status}`)
  return (await res.json()) as { refundId: string; status: 'pending' | 'succeeded' }
}
# .env.local
DATABASE_URL="<connection string>"           # chỉ server đọc được
PAYMENT_SECRET_KEY="<lấy từ secret manager>"  # chỉ server đọc được, không commit
NEXT_PUBLIC_SENTRY_DSN="<dsn công khai>"      # bị inline vào bundle JS gửi xuống trình duyệt

Ba điều cần khắc vào đầu:

  • Biến môi trường có tiền tố NEXT_PUBLIC_ được thay thẳng giá trị vào JavaScript gửi xuống trình duyệt lúc build. Ai cũng đọc được bằng View Source. Biến không có tiền tố thì trong client bundle bị thay bằng chuỗi rỗng. Vì vậy code server lỡ bị import vào client sẽ không lộ secret, nhưng sẽ chạy sai một cách âm thầm. import 'server-only' biến lỗi âm thầm đó thành lỗi build. Với Next.js, cài package server-only là tùy chọn (Next tự xử lý import này), nhưng cài vào giúp ESLint không báo "extraneous dependency".
  • Đừng truyền nguyên object từ database xuống Client Component. <ProfileCard user={user} /> với user có passwordHash, resetToken thì những field đó nằm trong RSC payload. Hãy tạo DTO chỉ gồm field cần hiển thị, đặt trong data access layer.
  • Ngày 3/12/2025, React và Next.js công bố lỗ hổng RCE trong giao thức RSC (CVE-2025-55182, ở phía Next.js là CVE-2025-66478), ảnh hưởng cả app không tự viết Server Function nào. Sau đó có thêm các CVE về DoS và lộ source code. Từ 7/2026, Next.js có lịch security release định kỳ. Hãy bật Dependabot/Renovate cho next và react*, và cập nhật bản patch trong vòng vài ngày.

Chi tiết về access control, SSRF và quản lý secret trong Next.js có ở bài Bảo mật web app Next.js theo OWASP Top 10.

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

import { useState } from 'react'
import { redirect } from 'next/navigation'

// ❌ 1. Hook trong Server Component → lỗi "useState only works in Client Components"
export default async function OrdersPage() {
  const [tab, setTab] = useState('all')
  // ...
}
// ✅ Tách phần tương tác thành Client Component nhỏ, page vẫn là Server Component
// app/orders/status-tabs.tsx: 'use client' + useState; page.tsx render <StatusTabs />

// ❌ 2. 'use client' ở root layout: cả app thành client, mất toàn bộ lợi ích của RSC
// app/layout.tsx
// 'use client'
// ✅ Chỉ provider là client, layout vẫn là server
import { Providers } from './providers' // file này có 'use client'
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="vi">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}

// ❌ 3. redirect() bị try/catch nuốt mất
async function createOrderBad(formData: FormData) {
  'use server'
  try {
    const order = await saveOrder(formData)
    redirect(`/orders/${order.id}`) // ném error đặc biệt → rơi vào catch
  } catch {
    return { ok: false, message: 'Không tạo được đơn' }
  }
}
// ✅ redirect nằm ngoài try
async function createOrder(formData: FormData) {
  'use server'
  let orderId: string
  try {
    orderId = (await saveOrder(formData)).id
  } catch {
    return { ok: false, message: 'Không tạo được đơn' }
  }
  redirect(`/orders/${orderId}`)
}

Thêm bốn lỗi khác hay gặp khi review:

  • Import code server vào Client Component. Một file utils.ts vừa có hàm format tiền vừa import Prisma, và Client Component import nó để lấy hàm format. Kết quả là lỗi build khó hiểu về module fs/net, hoặc tệ hơn là bundle phình to. Tách file theo môi trường và đánh dấu server-only.
  • Vô tình biến cả app thành dynamic (mô hình cũ): đọc cookies() hoặc headers() trong root layout hay header dùng chung, khiến mọi route render lúc request. Với Cache Components, đây là các cảnh báo "blocking route". Đẩy phần đọc cookie xuống một component nhỏ bọc trong Suspense.
  • Vô tình đóng băng dữ liệu (Next.js 15/16 mô hình cũ): trang chỉ có fetch không cache và không dùng request API vẫn được prerender lúc build. Xem cột "Route" trong output của next build (static hay dynamic) sau mỗi thay đổi lớn.
  • Truyền function hoặc class instance qua ranh giới: chuyển logic vào Client Component, hoặc truyền dữ liệu thô rồi format ở phía client.

Ai nên áp dụng ngay, ai nên chờ

Dự án mới: dùng thẳng Next.js 16.4 với Cache Components và Partial Prefetching (mặc định của create-next-app từ 16.4). Không có gì để migrate, và mô hình explicit dễ suy luận hơn hẳn mô hình cache ngầm định cũ.

Đang ở Next.js 15: nâng lên 16 trước (npx @next/codemod@canary upgrade latest), giữ mô hình cache cũ. Việc phải làm là xóa truy cập sync, đổi middleware.ts thành proxy.ts, thêm profile cho revalidateTag, kiểm tra các thay đổi mặc định của next/image. Sau đó mới bật cacheComponents và dùng export const instant = false để tạm cho các route chưa sẵn sàng đi qua validation, rồi chuyển từng route.

Nên chờ hoặc lên kế hoạch kỹ: app phụ thuộc Edge runtime cho page (Cache Components yêu cầu Node.js runtime). App self-host nhiều instance đang dựa vào việc fetch cache tồn tại qua deploy (cần cấu hình cache handler cho 'use cache: remote' trước). Hoặc team chưa có thời gian xử lý chuyện state được giữ qua điều hướng. Cache Components sẽ là mặc định ở Next.js 17, nên "chờ" nghĩa là chuẩn bị chứ không phải bỏ qua.

Checklist

  • Tìm mọi 'use client' ở layout/page và đẩy xuống component lá. Server Component lồng vào bằng children.
  • Mọi file chạm database, secret hoặc SDK server có import 'server-only'. Không có secret nào mang tiền tố NEXT_PUBLIC_.
  • Các truy vấn độc lập trong một page chạy bằng Promise.all. Phần chậm nằm trong <Suspense> hoặc có loading.tsx.
  • Mỗi Server Action có đủ bốn bước: authenticate, validate bằng schema, authorize trên bản ghi cụ thể, revalidate bằng tag.
  • redirect()/notFound() không nằm trong try/catch.
  • Đã chạy next build và đối chiếu route nào static, route nào dynamic với kỳ vọng.
  • Trên Next.js 16: mọi revalidateTag có tham số profile, mutation của người dùng dùng updateTag.
  • Lên kế hoạch bật cacheComponents: liệt kê route dùng dynamic/revalidate/fetchCache/runtime, và nơi đang dựa vào fetch cache tồn tại qua deploy.
  • next, react, react-dom được theo dõi tự động và cập nhật bản vá bảo mật trong tuần.

Đọc thêm

Last updated: April 12, 2026

Comments