PPR trong Next.js 16: Hướng Dẫn Cache Components và Rendering Kết Hợp

Partial Prerendering (PPR) trong Next.js 16 cho phép kết hợp nội dung tĩnh và động trong cùng một trang ở cấp component. Hướng dẫn chi tiết về Cache Components, use cache, cacheLife, cacheTag, updateTag cùng ví dụ thực tế.

Giới thiệu về Partial Prerendering trong Next.js 16

Nếu bạn đã làm việc với Next.js một thời gian, chắc hẳn bạn từng đau đầu với câu hỏi kinh điển: chọn tốc độ tải trang tĩnh hay tính linh hoạt của nội dung động? Static Site Generation (SSG) cho hiệu suất cực tốt nhưng không hợp với dữ liệu thay đổi liên tục. Server-Side Rendering (SSR) thì nội dung luôn mới nhưng TTFB lại chậm hơn. Còn Incremental Static Regeneration (ISR)? Nó cố dung hòa cả hai nhưng vẫn buộc toàn bộ trang phải theo một chiến lược duy nhất — tĩnh hoặc động, không thể trộn lẫn.

Partial Prerendering (PPR) chính là câu trả lời của Next.js 16 cho bài toán này. Và nói thật, nó khá là ấn tượng.

PPR cho phép bạn kết hợp nội dung tĩnh và nội dung động trong cùng một trang, ở cấp độ component. Phần tĩnh được prerender lúc build và phục vụ ngay từ CDN, trong khi phần động được stream từ server theo thời gian thực. Người dùng nhận được một "static shell" hoàn chỉnh gần như ngay lập tức, rồi các phần động dần được điền vào khi dữ liệu sẵn sàng.

Hãy tưởng tượng một trang sản phẩm thương mại điện tử: header, footer, thông tin sản phẩm, hình ảnh — mấy cái này hầu như không thay đổi, đây là phần tĩnh. Nhưng giỏ hàng, đề xuất cá nhân hóa, đánh giá mới nhất thì cần dữ liệu real-time — đây là phần động. Với PPR, bạn không cần chọn một chiến lược rendering cho cả trang nữa. Mỗi component tự quyết định nó là tĩnh hay động.

Trong Next.js 16, PPR không còn là tính năng thử nghiệm. Nó đã trở thành mô hình caching mặc định thông qua Cache Components và directive "use cache". Bài viết này sẽ đi từ nguyên lý hoạt động, cấu hình, đến xây dựng ứng dụng thực tế — khá dài nhưng mình đảm bảo đáng đọc.

PPR hoạt động như thế nào

Mô hình Static Shell + Dynamic Streaming

PPR chia mỗi trang thành hai phần rõ ràng: static shell (khung tĩnh) và dynamic holes (các vùng động). Quy trình cụ thể như sau:

  1. Tại thời điểm build: Next.js render toàn bộ trang và xác định đâu là tĩnh, đâu là động. Phần tĩnh được lưu thành HTML sẵn sàng phục vụ. Các vùng động thì được đánh dấu bằng placeholder (fallback của Suspense).
  2. Khi người dùng request: Server gửi ngay static shell — bao gồm HTML tĩnh đã prerender cùng các fallback placeholder. Trình duyệt bắt đầu hiển thị nội dung ngay lập tức.
  3. Streaming nội dung động: Song song đó, server thực thi các phần động. Khi mỗi phần sẵn sàng, nó được stream xuống trình duyệt và thay thế placeholder tương ứng — không cần tải lại trang.

Vai trò của Suspense boundaries

React Suspense là cơ chế cốt lõi giúp PPR phân biệt nội dung tĩnh và động. Nguyên tắc đơn giản thôi: mọi thứ bên ngoài Suspense boundary thuộc static shell, mọi thứ bên trong Suspense boundary là phần dynamic và sẽ được stream sau.

// Minh họa cấu trúc PPR cơ bản
// Phần bên ngoài Suspense = Static Shell
// Phần bên trong Suspense = Dynamic Content

import { Suspense } from 'react'
import { Header } from '@/components/Header'
import { Footer } from '@/components/Footer'
import { ProductInfo } from '@/components/ProductInfo'
import { UserCart } from '@/components/UserCart'
import { CartSkeleton } from '@/components/skeletons'

export default async function ProductPage() {
  return (
    <div>
      {/* Static Shell - được prerender và gửi ngay lập tức */}
      <Header />
      <ProductInfo productId="123" />

      {/* Dynamic Hole - được stream sau */}
      <Suspense fallback={<CartSkeleton />}>
        <UserCart />
      </Suspense>

      {/* Static Shell tiếp tục */}
      <Footer />
    </div>
  )
}

Cách trình duyệt nhận nội dung

Khi trình duyệt nhận response từ server, nó trải qua ba giai đoạn. Đầu tiên, HTML tĩnh được parse và render ngay — người dùng thấy layout hoàn chỉnh với các skeleton placeholder. Tiếp theo, các chunk HTML động được stream qua cùng kết nối HTTP, React tự động swap chúng vào đúng vị trí.

Cuối cùng, JavaScript hydration hoàn tất và trang trở nên fully interactive.

Điểm hay nhất? Người dùng không phải chờ toàn bộ trang render xong mới thấy nội dung. TTFB gần bằng thời gian phục vụ file tĩnh, trong khi nội dung động vẫn luôn fresh.

Cấu hình PPR trong Next.js 16

Bật Cache Components trong next.config.ts

Đây là thay đổi lớn trong Next.js 16: tất cả các trang đều dynamic theo mặc định. Bạn chủ động chọn caching cho từng phần bằng directive "use cache". Để bật Cache Components (nền tảng của PPR), cấu hình trong next.config.ts như sau:

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

const nextConfig: NextConfig = {
  // Bật Cache Components — nền tảng cho PPR trong Next.js 16
  cacheComponents: true,
}

export default nextConfig

Khi cacheComponents: true được bật, Next.js sẽ nhận diện directive "use cache" trong code và tự động áp dụng cơ chế caching tương ứng. Các component và hàm được đánh dấu "use cache" sẽ trở thành phần tĩnh của static shell.

Migration từ experimental PPR flag

Nếu bạn đang dùng Next.js 14 hoặc 15 với cờ experimental PPR, tin vui là migration khá đơn giản:

// next.config.ts (Next.js 14/15 - CŨ)
const nextConfig = {
  experimental: {
    ppr: true,            // Cờ experimental cũ
    dynamicIO: true,      // Cờ experimental cũ
  },
}

// next.config.ts (Next.js 16 - MỚI)
const nextConfig: NextConfig = {
  cacheComponents: true,   // Thay thế cả hai cờ trên
}

Nhưng có một điểm khác biệt cực kỳ quan trọng mà bạn cần lưu ý: trong Next.js 14/15, các trang mặc định là tĩnh và bạn opt-out sang dynamic. Trong Next.js 16 thì ngược lại — trang mặc định là dynamic và bạn opt-in vào caching với "use cache". Điều này có nghĩa bạn cần rà soát lại toàn bộ ứng dụng và thêm "use cache" cho những phần muốn cache, thay vì dựa vào hành vi tĩnh mặc định như trước.

Directive "use cache" chi tiết

Directive "use cache" là cách chính để bạn đánh dấu phần nào nên được cache. Nó hoạt động tương tự "use server" hay "use client" — một chuỗi ký tự đặt ở đầu hàm, component, hoặc file. Next.js nhận diện directive này lúc build và tạo cơ chế caching phù hợp.

Cấp độ hàm (Function-level caching)

Đây là cách phổ biến nhất — cache kết quả của một hàm async riêng lẻ. Rất hay dùng để cache dữ liệu từ database hoặc API:

// lib/data.ts
// Cache kết quả hàm fetch dữ liệu sản phẩm
async function getProduct(productId: string) {
  "use cache"

  // Hàm này chỉ được gọi khi cache miss
  // Kết quả sẽ được cache tự động
  const product = await db.product.findUnique({
    where: { id: productId },
    include: { category: true, images: true },
  })

  return product
}

// Cache kết quả hàm gọi API bên ngoài
async function getExchangeRate(currency: string) {
  "use cache"

  const response = await fetch(
    `https://api.exchange.com/rates/${currency}`
  )
  const data = await response.json()

  return data.rate
}

Khi một hàm được đánh dấu "use cache", Next.js serialize tất cả tham số đầu vào (ở đây là productId hoặc currency) làm cache key. Mỗi tổ hợp tham số khác nhau sẽ có entry cache riêng — khá là gọn gàng.

Cấp độ component (Component-level caching)

Bạn cũng có thể cache toàn bộ output của một React Server Component. Đây chính là cách tạo ra các phần tĩnh trong PPR:

// components/ProductInfo.tsx
// Component này sẽ được prerender và cache
// Nó trở thành phần của static shell
async function ProductInfo({ productId }: { productId: string }) {
  "use cache"

  const product = await db.product.findUnique({
    where: { id: productId },
  })

  if (!product) return <div>Không tìm thấy sản phẩm</div>

  return (
    <section className="product-info">
      <h1>{product.name}</h1>
      <p className="price">{product.price.toLocaleString()} VNĐ</p>
      <p className="description">{product.description}</p>
      <div className="specs">
        {product.specs.map((spec) => (
          <div key={spec.label}>
            <strong>{spec.label}:</strong> {spec.value}
          </div>
        ))}
      </div>
    </section>
  )
}

Cấp độ file (File-level caching)

Đặt "use cache" ở đầu file sẽ áp dụng caching cho tất cả các hàm và component được export trong file đó. Cách này tiện lắm khi bạn có nhiều hàm utility cần cache:

// lib/cached-queries.ts
"use cache"

// Tất cả hàm export trong file này đều được cache tự động

export async function getCategories() {
  const categories = await db.category.findMany({
    orderBy: { name: 'asc' },
  })
  return categories
}

export async function getFeaturedProducts() {
  const products = await db.product.findMany({
    where: { featured: true },
    take: 12,
    orderBy: { createdAt: 'desc' },
  })
  return products
}

export async function getStoreSettings() {
  const settings = await db.setting.findFirst()
  return settings
}

Một lưu ý quan trọng: directive "use cache" chỉ hoạt động trong async functionsServer Components. Bạn không dùng được trong Client Components (đánh dấu bằng "use client") hay hàm đồng bộ thông thường. Ngoài ra, tất cả tham số truyền vào phải serializable — vì chúng được dùng làm cache key.

Quản lý vòng đời cache với cacheLife

Mặc định, nội dung cache bởi "use cache" sẽ sử dụng profile 'default'. Nhưng trong thực tế thì mỗi loại dữ liệu có yêu cầu freshness khác nhau, đúng không? Tỷ giá ngoại tệ cần cập nhật mỗi vài phút, danh mục sản phẩm có thể cache hàng giờ, còn chính sách bảo hành thì cache hàng tuần cũng chẳng sao. Hàm cacheLife() cho phép bạn kiểm soát chính xác vòng đời cache.

Các profile có sẵn

Next.js 16 cung cấp 7 profile cache có sẵn, mỗi cái phù hợp với một kịch bản khác nhau:

  • 'default' — Profile mặc định, cân bằng giữa freshness và hiệu suất. Phù hợp cho đa số trường hợp.
  • 'seconds' — Cache cực ngắn, dành cho dữ liệu thay đổi rất nhanh như số liệu real-time.
  • 'minutes' — Cache vài phút, hợp với tỷ giá, giá cổ phiếu.
  • 'hours' — Cache vài giờ, hợp với danh sách sản phẩm, bài viết blog.
  • 'days' — Cache vài ngày, cho nội dung ít thay đổi như trang giới thiệu.
  • 'weeks' — Cache vài tuần, cho nội dung gần như tĩnh.
  • 'max' — Cache lâu nhất có thể, cho nội dung không bao giờ thay đổi như asset metadata.
import { cacheLife } from 'next/cache'

// Cache tỷ giá trong vài phút
async function getExchangeRate(currency: string) {
  "use cache"
  cacheLife('minutes')

  const res = await fetch(`https://api.rates.com/${currency}`)
  return res.json()
}

// Cache danh mục sản phẩm trong vài giờ
async function getCategories() {
  "use cache"
  cacheLife('hours')

  return db.category.findMany()
}

// Cache chính sách cửa hàng — gần như không đổi
async function getStorePolicy() {
  "use cache"
  cacheLife('weeks')

  return db.policy.findFirst()
}

Thuộc tính stale, revalidate, expire

Mỗi profile cache xoay quanh ba thuộc tính quan trọng:

  • stale — Khoảng thời gian (tính bằng giây) mà dữ liệu cache được coi là "fresh" và phục vụ trực tiếp mà không kiểm tra lại. Trong giai đoạn này, mọi request đều nhận cache ngay.
  • revalidate — Sau khi hết stale, dữ liệu vẫn được phục vụ từ cache nhưng server bắt đầu tái tạo phiên bản mới ở background. Đây là cơ chế Stale-While-Revalidate (SWR) quen thuộc. Client nhận dữ liệu cũ ngay, request tiếp theo sẽ nhận dữ liệu mới.
  • expire — Thời điểm tối đa mà cache entry tồn tại. Sau đó, cache bị xóa hoàn toàn và request tiếp theo phải chờ server tạo dữ liệu mới (không có SWR nữa).

Tùy chỉnh profile cache

Ngoài 7 profile có sẵn, bạn hoàn toàn có thể tạo profile tùy chỉnh. Có hai cách: khai báo trong next.config.ts hoặc dùng trực tiếp inline trong code:

import { cacheLife } from 'next/cache'

// Sử dụng cacheLife với cấu hình tùy chỉnh trực tiếp
async function getProductReviews(productId: string) {
  "use cache"
  // Cache fresh trong 5 phút, revalidate trong 1 giờ, hết hạn sau 24 giờ
  cacheLife({
    stale: 300,       // 5 phút — phục vụ từ cache ngay lập tức
    revalidate: 3600, // 1 giờ — revalidate ở background
    expire: 86400,    // 24 giờ — xóa cache hoàn toàn
  })

  const reviews = await db.review.findMany({
    where: { productId },
    orderBy: { createdAt: 'desc' },
    take: 20,
  })

  return reviews
}

Nếu muốn tái sử dụng profile across toàn bộ ứng dụng, khai báo trong next.config.ts sẽ gọn hơn:

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

const nextConfig: NextConfig = {
  cacheComponents: true,
  cacheLife: {
    // Tạo profile tùy chỉnh tên "product"
    product: {
      stale: 600,       // 10 phút
      revalidate: 3600, // 1 giờ
      expire: 86400,    // 1 ngày
    },
    // Profile cho dữ liệu người dùng — cache ngắn hơn
    userSession: {
      stale: 0,         // Không bao giờ stale — luôn revalidate
      revalidate: 60,   // 1 phút
      expire: 300,      // 5 phút
    },
  },
}

export default nextConfig
// Sau đó sử dụng profile tùy chỉnh trong code
import { cacheLife } from 'next/cache'

async function getProduct(id: string) {
  "use cache"
  cacheLife('product') // Sử dụng profile "product" đã khai báo

  return db.product.findUnique({ where: { id } })
}

Gắn thẻ và làm mới cache với cacheTag và revalidateTag

Trong thực tế, bạn thường cần làm mới cache khi dữ liệu thay đổi — admin cập nhật sản phẩm, người dùng thêm đánh giá, hay hệ thống nhận webhook từ CMS chẳng hạn. Thay vì ngồi chờ cache tự hết hạn, bạn có thể chủ động invalidate cache bằng cơ chế tagging.

Gắn thẻ với cacheTag

Hàm cacheTag() cho phép gắn một hoặc nhiều thẻ cho cache entry. Sau đó bạn invalidate tất cả cache entries có cùng tag — khá giống cách bạn gắn label cho email vậy:

import { cacheTag, cacheLife } from 'next/cache'

// Gắn tag cho cache sản phẩm
async function getProduct(productId: string) {
  "use cache"
  cacheLife('hours')
  // Gắn tag chung cho tất cả sản phẩm + tag riêng cho sản phẩm cụ thể
  cacheTag('products', `product-${productId}`)

  const product = await db.product.findUnique({
    where: { id: productId },
  })
  return product
}

// Gắn tag cho cache danh sách sản phẩm theo danh mục
async function getProductsByCategory(categorySlug: string) {
  "use cache"
  cacheLife('hours')
  cacheTag('products', `category-${categorySlug}`)

  const products = await db.product.findMany({
    where: { category: { slug: categorySlug } },
    orderBy: { createdAt: 'desc' },
  })
  return products
}

// Gắn tag cho đánh giá sản phẩm
async function getReviews(productId: string) {
  "use cache"
  cacheLife('minutes')
  cacheTag('reviews', `reviews-${productId}`)

  return db.review.findMany({
    where: { productId },
    orderBy: { createdAt: 'desc' },
    take: 10,
  })
}

Làm mới cache với revalidateTag

Khi dữ liệu thay đổi, gọi revalidateTag() để invalidate tất cả cache entries có tag tương ứng. Hàm này thường được gọi trong Server Actions, Route Handlers, hoặc Webhooks:

// app/actions/product.ts
'use server'

import { revalidateTag } from 'next/cache'

// Server Action: Cập nhật thông tin sản phẩm
export async function updateProduct(productId: string, data: ProductData) {
  // Cập nhật trong database
  await db.product.update({
    where: { id: productId },
    data,
  })

  // Invalidate cache của sản phẩm cụ thể này
  revalidateTag(`product-${productId}`, 'max')
}

// Server Action: Xóa toàn bộ cache sản phẩm
export async function refreshAllProducts() {
  revalidateTag('products', 'max')
}

// Route Handler cho webhook từ CMS
// app/api/webhook/cms/route.ts
import { revalidateTag } from 'next/cache'

export async function POST(request: Request) {
  const payload = await request.json()

  if (payload.type === 'product.updated') {
    revalidateTag(`product-${payload.productId}`, 'max')
  }

  if (payload.type === 'category.updated') {
    revalidateTag(`category-${payload.categorySlug}`, 'max')
  }

  return Response.json({ revalidated: true })
}

Kết hợp nhiều tag cho revalidation linh hoạt

Chiến lược gắn tag hiệu quả nhất (theo kinh nghiệm của mình) là dùng cả tag chung lẫn tag cụ thể. Tag chung cho phép invalidate hàng loạt, tag cụ thể để invalidate chính xác:

import { cacheTag } from 'next/cache'

async function getProductCard(productId: string) {
  "use cache"
  // Tag phân cấp: từ chung đến cụ thể
  cacheTag(
    'products',                    // Invalidate tất cả sản phẩm
    `product-${productId}`,        // Invalidate sản phẩm cụ thể
    `product-card-${productId}`    // Invalidate riêng phần card
  )

  const product = await db.product.findUnique({
    where: { id: productId },
    select: { id: true, name: true, price: true, thumbnail: true },
  })
  return product
}

Với cách gắn tag này, bạn có thể revalidateTag('products', 'max') để xóa cache toàn bộ sản phẩm, hoặc revalidateTag('product-123', 'max') để chỉ xóa cache liên quan đến sản phẩm 123.

updateTag: API mới trong Next.js 16

Next.js 16 giới thiệu một API hoàn toàn mới: updateTag(). Tên nghe giống revalidateTag() nhưng thực ra hai cái này khác nhau khá nhiều.

Sự khác biệt giữa updateTag và revalidateTag

revalidateTag() dùng cơ chế Stale-While-Revalidate: khi được gọi, cache cũ vẫn được phục vụ cho request tiếp theo trong khi server tái tạo dữ liệu mới ở background. Người dùng không phải chờ, nhưng có thể thấy dữ liệu cũ một lúc.

updateTag() dùng ngữ nghĩa read-your-writes: cache bị xóa ngay lập tức và request tiếp theo từ cùng người dùng sẽ chờ dữ liệu mới. Người dùng vừa thực hiện hành động sẽ thấy kết quả ngay — không bao giờ thấy dữ liệu cũ sau khi vừa cập nhật.

Khi nào sử dụng updateTag

Ví dụ thế này: người dùng sửa tên sản phẩm và nhấn "Lưu". Nếu dùng revalidateTag(), sau khi redirect về trang sản phẩm, họ có thể vẫn thấy tên cũ (vì SWR đang chạy). Với updateTag(), họ luôn thấy tên mới ngay. Trải nghiệm khác nhau rõ rệt.

Lưu ý quan trọng: updateTag() chỉ hoạt động trong Server Actions. Đừng cố dùng nó trong Route Handlers hay Middleware — sẽ không work.

// app/actions/product.ts
'use server'

import { updateTag, revalidateTag } from 'next/cache'
import { redirect } from 'next/navigation'

// Server Action: Chỉnh sửa sản phẩm
// Sử dụng updateTag để người dùng thấy kết quả ngay
export async function editProduct(
  productId: string,
  formData: FormData
) {
  const name = formData.get('name') as string
  const price = parseFloat(formData.get('price') as string)

  await db.product.update({
    where: { id: productId },
    data: { name, price },
  })

  // updateTag: Người dùng hiện tại sẽ thấy dữ liệu mới NGAY LẬP TỨC
  updateTag(`product-${productId}`)

  redirect(`/products/${productId}`)
}

// So sánh: Sử dụng revalidateTag cho webhook
export async function handleCMSWebhook(productId: string) {
  // revalidateTag phù hợp ở đây vì không có "người dùng hiện tại"
  revalidateTag(`product-${productId}`, 'max')
}

Ví dụ so sánh trực quan

Để dễ hình dung hơn, đây là hai kịch bản cụ thể:

'use server'

import { updateTag, revalidateTag } from 'next/cache'

// Kịch bản 1: Người dùng thêm review mới
// → Dùng updateTag vì người dùng muốn thấy review của mình ngay
export async function addReview(productId: string, content: string) {
  await db.review.create({
    data: { productId, content, userId: getCurrentUserId() },
  })

  updateTag(`reviews-${productId}`)
}

// Kịch bản 2: Hệ thống batch update giá sản phẩm
// → Dùng revalidateTag vì không có người dùng cụ thể cần thấy ngay
export async function batchUpdatePrices() {
  await db.product.updateMany({
    data: { price: { multiply: 1.05 } },
  })

  revalidateTag('products', 'max')
}

Xây dựng ứng dụng thực tế với PPR

Okay, lý thuyết đủ rồi. Giờ mình sẽ xây dựng một trang sản phẩm thương mại điện tử hoàn chỉnh để minh họa PPR trong thực tế. Trang bao gồm: header và footer tĩnh, thông tin sản phẩm được cache, cùng giỏ hàng, đề xuất cá nhân hóa, và đánh giá được stream động.

Cấu hình dự án

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

const nextConfig: NextConfig = {
  cacheComponents: true,
  cacheLife: {
    product: {
      stale: 600,
      revalidate: 3600,
      expire: 86400,
    },
    reviews: {
      stale: 60,
      revalidate: 300,
      expire: 3600,
    },
  },
}

export default nextConfig

Layout chính

// app/layout.tsx
import { Suspense } from 'react'
import { Header } from '@/components/Header'
import { Footer } from '@/components/Footer'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="vi">
      <body>
        <Header />
        <main>{children}</main>
        <Footer />
      </body>
    </html>
  )
}

Component Header và Footer (Static Shell)

// components/Header.tsx
import { cacheLife, cacheTag } from 'next/cache'

export async function Header() {
  "use cache"
  cacheLife('days')
  cacheTag('layout', 'header')

  const navigation = await db.navigation.findMany({
    where: { location: 'header' },
    orderBy: { order: 'asc' },
  })

  return (
    <header className="border-b bg-white">
      <div className="container mx-auto flex items-center justify-between py-4">
        <a href="/" className="text-xl font-bold">ShopVN</a>
        <nav className="flex gap-6">
          {navigation.map((item) => (
            <a key={item.id} href={item.href} className="hover:text-blue-600">
              {item.label}
            </a>
          ))}
        </nav>
      </div>
    </header>
  )
}

// components/Footer.tsx
import { cacheLife, cacheTag } from 'next/cache'

export async function Footer() {
  "use cache"
  cacheLife('weeks')
  cacheTag('layout', 'footer')

  const year = new Date().getFullYear()

  return (
    <footer className="border-t bg-gray-50 py-8">
      <div className="container mx-auto text-center text-gray-500">
        <p>&copy; {year} ShopVN. Mọi quyền được bảo lưu.</p>
      </div>
    </footer>
  )
}

Trang sản phẩm (Kết hợp Static + Dynamic)

Đây là phần thú vị nhất — nơi PPR thực sự tỏa sáng:

// app/products/[id]/page.tsx
import { Suspense } from 'react'
import { ProductInfo } from '@/components/ProductInfo'
import { ProductImages } from '@/components/ProductImages'
import { UserCart } from '@/components/UserCart'
import { Recommendations } from '@/components/Recommendations'
import { Reviews } from '@/components/Reviews'
import {
  CartSkeleton,
  RecommendationsSkeleton,
  ReviewsSkeleton,
} from '@/components/skeletons'

interface ProductPageProps {
  params: Promise<{ id: string }>
}

export default async function ProductPage({ params }: ProductPageProps) {
  const { id } = await params

  return (
    <div className="container mx-auto py-8">
      {/* === STATIC SHELL === */}
      <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
        <ProductImages productId={id} />
        <ProductInfo productId={id} />
      </div>

      {/* === DYNAMIC: Giỏ hàng === */}
      <section className="mt-12">
        <h2 className="text-2xl font-bold mb-4">Giỏ hàng của bạn</h2>
        <Suspense fallback={<CartSkeleton />}>
          <UserCart />
        </Suspense>
      </section>

      {/* === DYNAMIC: Đề xuất === */}
      <section className="mt-12">
        <h2 className="text-2xl font-bold mb-4">Dành riêng cho bạn</h2>
        <Suspense fallback={<RecommendationsSkeleton />}>
          <Recommendations productId={id} />
        </Suspense>
      </section>

      {/* === DYNAMIC: Đánh giá === */}
      <section className="mt-12">
        <h2 className="text-2xl font-bold mb-4">Đánh giá từ khách hàng</h2>
        <Suspense fallback={<ReviewsSkeleton />}>
          <Reviews productId={id} />
        </Suspense>
      </section>
    </div>
  )
}

Component tĩnh: ProductInfo và ProductImages

// components/ProductInfo.tsx
import { cacheLife, cacheTag } from 'next/cache'
import { AddToCartButton } from './AddToCartButton'

export async function ProductInfo({ productId }: { productId: string }) {
  "use cache"
  cacheLife('product')
  cacheTag('products', `product-${productId}`)

  const product = await db.product.findUnique({
    where: { id: productId },
    include: { category: true },
  })

  if (!product) return <div>Không tìm thấy sản phẩm</div>

  return (
    <div className="space-y-4">
      <span className="text-sm text-gray-500">{product.category.name}</span>
      <h1 className="text-3xl font-bold">{product.name}</h1>
      <p className="text-2xl text-red-600 font-semibold">
        {product.price.toLocaleString('vi-VN')} VNĐ
      </p>
      <p className="text-gray-700 leading-relaxed">{product.description}</p>
      <div className="flex items-center gap-2">
        <span className={product.inStock ? 'text-green-600' : 'text-red-600'}>
          {product.inStock ? 'Còn hàng' : 'Hết hàng'}
        </span>
      </div>
      <AddToCartButton productId={product.id} disabled={!product.inStock} />
    </div>
  )
}

Component động: UserCart và Reviews

// components/UserCart.tsx
// KHÔNG dùng "use cache" — component động
import { getCurrentUser } from '@/lib/auth'

export async function UserCart() {
  const user = await getCurrentUser()

  if (!user) {
    return (
      <div className="rounded-lg border p-4 text-center text-gray-500">
        <p>Đăng nhập để xem giỏ hàng</p>
      </div>
    )
  }

  const cartItems = await db.cartItem.findMany({
    where: { userId: user.id },
    include: { product: true },
  })

  const total = cartItems.reduce(
    (sum, item) => sum + item.product.price * item.quantity,
    0
  )

  return (
    <div className="rounded-lg border p-6">
      {cartItems.length === 0 ? (
        <p className="text-gray-500">Giỏ hàng trống</p>
      ) : (
        <>
          {cartItems.map((item) => (
            <div key={item.id} className="flex justify-between py-2 border-b">
              <span>{item.product.name} x{item.quantity}</span>
              <span>{(item.product.price * item.quantity).toLocaleString('vi-VN')} VNĐ</span>
            </div>
          ))}
          <div className="mt-4 text-right font-bold text-lg">
            Tổng: {total.toLocaleString('vi-VN')} VNĐ
          </div>
        </>
      )}
    </div>
  )
}

// components/Reviews.tsx
import { cacheLife, cacheTag } from 'next/cache'

export async function Reviews({ productId }: { productId: string }) {
  "use cache"
  cacheLife('reviews')
  cacheTag('reviews', `reviews-${productId}`)

  const reviews = await db.review.findMany({
    where: { productId },
    include: { user: { select: { name: true, avatar: true } } },
    orderBy: { createdAt: 'desc' },
    take: 10,
  })

  const avgRating = reviews.length
    ? reviews.reduce((sum, r) => sum + r.rating, 0) / reviews.length
    : 0

  return (
    <div className="space-y-6">
      <div className="flex items-center gap-4">
        <span className="text-4xl font-bold">{avgRating.toFixed(1)}</span>
        <span className="text-gray-500">({reviews.length} đánh giá)</span>
      </div>
      {reviews.map((review) => (
        <div key={review.id} className="border-b pb-4">
          <div className="flex items-center gap-2 mb-2">
            <img src={review.user.avatar} alt={review.user.name} className="w-8 h-8 rounded-full" />
            <strong>{review.user.name}</strong>
            <span className="text-yellow-500">
              {'★'.repeat(review.rating)}{'☆'.repeat(5 - review.rating)}
            </span>
          </div>
          <p className="text-gray-700">{review.content}</p>
        </div>
      ))}
    </div>
  )
}

Server Actions cho trang sản phẩm

// app/actions/reviews.ts
'use server'

import { updateTag } from 'next/cache'
import { getCurrentUser } from '@/lib/auth'

export async function submitReview(productId: string, formData: FormData) {
  const user = await getCurrentUser()
  if (!user) throw new Error('Vui lòng đăng nhập')

  const rating = parseInt(formData.get('rating') as string)
  const content = formData.get('content') as string

  await db.review.create({
    data: { productId, userId: user.id, rating, content },
  })

  updateTag(`reviews-${productId}`)
}

// app/actions/cart.ts
'use server'

import { updateTag } from 'next/cache'
import { getCurrentUser } from '@/lib/auth'

export async function addToCart(productId: string, quantity: number = 1) {
  const user = await getCurrentUser()
  if (!user) throw new Error('Vui lòng đăng nhập')

  await db.cartItem.upsert({
    where: { userId_productId: { userId: user.id, productId } },
    update: { quantity: { increment: quantity } },
    create: { userId: user.id, productId, quantity },
  })

  updateTag(`cart-${user.id}`)
}

Skeleton components cho trải nghiệm loading mượt mà

Skeleton components là phần không thể thiếu — chúng hiển thị trong static shell khi chờ nội dung động được stream:

// components/skeletons.tsx
export function CartSkeleton() {
  return (
    <div className="rounded-lg border p-6 animate-pulse">
      <div className="space-y-3">
        <div className="h-4 bg-gray-200 rounded w-3/4" />
        <div className="h-4 bg-gray-200 rounded w-1/2" />
        <div className="h-4 bg-gray-200 rounded w-2/3" />
      </div>
      <div className="mt-4 h-6 bg-gray-200 rounded w-1/3 ml-auto" />
    </div>
  )
}

export function RecommendationsSkeleton() {
  return (
    <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
      {Array.from({ length: 4 }).map((_, i) => (
        <div key={i} className="rounded-lg border p-4 animate-pulse">
          <div className="aspect-square bg-gray-200 rounded mb-2" />
          <div className="h-4 bg-gray-200 rounded w-3/4 mb-1" />
          <div className="h-4 bg-gray-200 rounded w-1/2" />
        </div>
      ))}
    </div>
  )
}

export function ReviewsSkeleton() {
  return (
    <div className="space-y-6 animate-pulse">
      <div className="flex items-center gap-4">
        <div className="h-10 w-10 bg-gray-200 rounded" />
        <div className="h-4 bg-gray-200 rounded w-24" />
      </div>
      {Array.from({ length: 3 }).map((_, i) => (
        <div key={i} className="border-b pb-4">
          <div className="flex items-center gap-2 mb-2">
            <div className="w-8 h-8 bg-gray-200 rounded-full" />
            <div className="h-4 bg-gray-200 rounded w-20" />
          </div>
          <div className="h-4 bg-gray-200 rounded w-full mb-1" />
          <div className="h-4 bg-gray-200 rounded w-2/3" />
        </div>
      ))}
    </div>
  )
}

Tối ưu hiệu suất với PPR

PPR mang lại hiệu suất vượt trội so với rendering truyền thống, nhưng để khai thác tối đa, có vài kỹ thuật bạn nên biết.

Đặt Suspense boundaries sát nội dung động

Đây là lỗi mình thấy nhiều người mắc: bọc Suspense quanh một vùng lớn chứa cả nội dung tĩnh lẫn động. Hậu quả là phần tĩnh bên trong cũng bị coi là dynamic, mất đi lợi ích prerendering:

// SAI: Suspense bọc quá nhiều nội dung
<Suspense fallback={<Loading />}>
  <h2>Giỏ hàng của bạn</h2>  {/* Đây là nội dung tĩnh! */}
  <p>Xem các sản phẩm trong giỏ</p>  {/* Đây cũng là tĩnh! */}
  <UserCart />  {/* Chỉ component này mới là dynamic */}
</Suspense>

// ĐÚNG: Suspense chỉ bọc phần dynamic
<h2>Giỏ hàng của bạn</h2>
<p>Xem các sản phẩm trong giỏ</p>
<Suspense fallback={<CartSkeleton />}>
  <UserCart />
</Suspense>

Tối đa hóa static shell

Luôn tự hỏi: phần nào có thể trở thành static shell? Nguyên tắc đơn giản: nếu nội dung không phụ thuộc vào người dùng cụ thể và không thay đổi theo thời gian thực, hãy cache nó. Các ứng viên tốt gồm: navigation, footer, sidebar, breadcrumb, thông tin sản phẩm, mô tả danh mục, hình ảnh, metadata SEO.

Cache theo mức độ chi tiết phù hợp

Đừng cache quá thô (toàn bộ trang) hay quá mịn (từng dòng text). Cache ở mức component hoặc hàm data-fetching thường là vừa phải nhất. Quan trọng là mỗi cache entry nên có cùng tần suất thay đổi:

// TỐT: Cache riêng theo tần suất thay đổi
async function ProductInfo({ id }: { id: string }) {
  "use cache"
  cacheLife('hours')
  // ...
}

async function ProductPrice({ id }: { id: string }) {
  "use cache"
  cacheLife('minutes')
  // ...
}

// KHÔNG TỐT: Cache chung component lớn chứa cả dữ liệu ít đổi và đổi thường
async function ProductSection({ id }: { id: string }) {
  "use cache"
  cacheLife('minutes') // Phải dùng thời gian ngắn nhất → lãng phí cache
}

Theo dõi hiệu suất với Next.js DevTools

Next.js 16 có DevTools tích hợp giúp theo dõi hiệu suất PPR. Trong dev mode, bạn thấy rõ đâu là static shell, đâu là dynamic hole, thời gian stream từng phần, và cache hit/miss rate. Tab "Cache" giúp kiểm tra component có được cache đúng không, tab "Timing" đo thời gian stream từng Suspense boundary. Rất tiện để debug.

So sánh PPR với SSR, SSG, ISR truyền thống

Để thấy rõ giá trị của PPR, hãy đặt nó cạnh các chiến lược rendering mà Next.js đã hỗ trợ từ trước. Điểm khác biệt cốt lõi: các chiến lược cũ áp dụng cho toàn bộ trang, PPR hoạt động ở cấp độ component.

Static Site Generation (SSG)

SSG render toàn bộ trang lúc build. Trang được phục vụ dưới dạng HTML tĩnh từ CDN — hiệu suất cực tốt. Nhưng toàn bộ nội dung đều "đông cứng", không có gì động. Thay đổi gì cũng phải rebuild.

Phù hợp cho blog, trang tài liệu, landing page — nơi nội dung hiếm khi thay đổi.

Server-Side Rendering (SSR)

SSR render toàn bộ trang trên server cho mỗi request. Nội dung luôn fresh nhưng TTFB chậm hơn nhiều vì server phải thực thi logic, query database, và render HTML cho mỗi lần truy cập. Không tận dụng được CDN cho HTML. Đây là lựa chọn cho dashboard, trang cá nhân hóa — nơi mọi thứ cần real-time.

Incremental Static Regeneration (ISR)

ISR kết hợp SSG và SSR bằng cách cho phép trang tĩnh được tái tạo theo chu kỳ (ví dụ mỗi 60 giây). Request đầu tiên sau khi hết hạn vẫn nhận trang cũ (SWR), trang mới được tạo ở background. ISR cải thiện đáng kể nhưng vẫn buộc toàn bộ trang theo cùng chiến lược — không thể có phần tĩnh và phần động trong cùng trang.

Partial Prerendering (PPR)

PPR giải quyết hạn chế lớn nhất của tất cả chiến lược trên: phân chia tĩnh/động ở cấp độ component. Trang PPR có TTFB nhanh như SSG (nhờ static shell), nội dung fresh như SSR (phần động được stream realtime), và caching thông minh như ISR (nhờ cacheLifecacheTag).

So sánh nhanh:

  • TTFB: SSG (cực nhanh) > ISR (nhanh) ≈ PPR (nhanh nhờ static shell) > SSR (chậm).
  • Dữ liệu fresh: SSR (luôn fresh) ≈ PPR (phần dynamic luôn fresh) > ISR (fresh theo chu kỳ) > SSG (cũ cho đến rebuild).
  • Cá nhân hóa: SSR (có), PPR (có — trong Suspense), ISR (không), SSG (không).
  • Độ chi tiết: PPR (component-level), còn lại đều page-level.
  • CDN cacheable: SSG (toàn bộ), ISR (toàn bộ), PPR (static shell), SSR (không).

Trên thực tế, hầu hết trang web đều có cả phần tĩnh lẫn phần động. Trang thương mại điện tử có header tĩnh nhưng giỏ hàng động. Trang tin tức có bài viết tĩnh nhưng bình luận động. PPR là chiến lược rendering đầu tiên phản ánh đúng bản chất này — bạn không cần chọn một chiến lược cho cả trang nữa.

Các lỗi thường gặp và cách khắc phục

Qua quá trình sử dụng PPR, mình tổng hợp lại 6 lỗi phổ biến nhất mà developer hay mắc phải.

Lỗi 1: Quên bọc Suspense quanh component động

Nếu component động (không có "use cache") mà không bọc Suspense, toàn bộ trang sẽ thành dynamic — mất hoàn toàn lợi ích PPR. Next.js 16 không tự tạo Suspense boundary cho bạn đâu.

// SAI: Component động không có Suspense
export default async function Page() {
  return (
    <div>
      <ProductInfo productId="123" />
      <UserCart />  {/* Dynamic — KHÔNG có Suspense! */}
      <Footer />
    </div>
  )
}

// ĐÚNG: Bọc Suspense quanh phần dynamic
export default async function Page() {
  return (
    <div>
      <ProductInfo productId="123" />
      <Suspense fallback={<CartSkeleton />}>
        <UserCart />
      </Suspense>
      <Footer />
    </div>
  )
}

Lỗi 2: Cache dữ liệu cá nhân hóa theo người dùng

Cái này nguy hiểm thật sự. Nếu dùng "use cache" cho component hiển thị dữ liệu cá nhân, kết quả của user A có thể hiển thị cho user B — lỗi bảo mật nghiêm trọng:

// NGUY HIỂM: Cache dữ liệu cá nhân!
async function UserCart() {
  "use cache"  // KHÔNG LÀM ĐIỀU NÀY!
  const user = await getCurrentUser()
  const cart = await db.cart.findUnique({ where: { userId: user.id } })
  return <div>{/* render giỏ hàng */}</div>
}

// ĐÚNG: Không cache component cá nhân hóa
async function UserCart() {
  const user = await getCurrentUser()
  const cart = await db.cart.findUnique({ where: { userId: user.id } })
  return <div>{/* render giỏ hàng */}</div>
}

Lỗi 3: Sử dụng cache profile không phù hợp

Cache profile quá dài cho dữ liệu thay đổi nhanh thì người dùng thấy thông tin cũ. Quá ngắn cho dữ liệu ít đổi thì lãng phí tài nguyên server. Cần cân nhắc cho từng trường hợp:

// KHÔNG HIỆU QUẢ: Giá thay đổi mỗi ngày nhưng cache hàng tuần
async function getPrice(productId: string) {
  "use cache"
  cacheLife('weeks')  // Quá lâu cho dữ liệu giá!
  return db.product.findUnique({ where: { id: productId }, select: { price: true } })
}

// TỐT HƠN: Profile phù hợp với tần suất thay đổi
async function getPrice(productId: string) {
  "use cache"
  cacheLife('hours')
  cacheTag(`price-${productId}`)  // Có thể invalidate ngay khi cần
  return db.product.findUnique({ where: { id: productId }, select: { price: true } })
}

Lỗi 4: Không gắn tag cho cache

Dùng "use cache" mà không gắn cacheTag? Bạn sẽ không có cách nào invalidate cache đó theo yêu cầu. Cache chỉ hết hạn theo thời gian — không thể chủ động làm mới khi dữ liệu thay đổi. Đây là lỗi dễ quên nhất.

// THIẾU: Không thể invalidate cache này
async function getProduct(id: string) {
  "use cache"
  cacheLife('hours')
  return db.product.findUnique({ where: { id } })
}

// ĐẦY ĐỦ: Luôn gắn tag cho dữ liệu có thể thay đổi
async function getProduct(id: string) {
  "use cache"
  cacheLife('hours')
  cacheTag('products', `product-${id}`)
  return db.product.findUnique({ where: { id } })
}

Lỗi 5: Sử dụng updateTag ngoài Server Actions

Như đã nói ở trên, updateTag() chỉ chạy trong Server Actions. Cố gọi trong Route Handler hoặc Middleware thì sẽ throw error hoặc không có tác dụng gì:

// SAI: updateTag trong Route Handler
import { updateTag } from 'next/cache'

export async function PUT(request: Request) {
  const data = await request.json()
  await db.product.update({ where: { id: data.id }, data })
  updateTag(`product-${data.id}`)  // Không hoạt động ở đây!
  return Response.json({ success: true })
}

// ĐÚNG: Dùng revalidateTag trong Route Handler
import { revalidateTag } from 'next/cache'

export async function PUT(request: Request) {
  const data = await request.json()
  await db.product.update({ where: { id: data.id }, data })
  revalidateTag(`product-${data.id}`, 'max')
  return Response.json({ success: true })
}

Lỗi 6: Truyền tham số không serializable vào hàm cached

Tất cả tham số truyền vào hàm hoặc props có "use cache" phải serializable (chuyển được thành JSON). Functions, class instances, circular references đều không được:

// SAI: Truyền function vào component cached
async function ProductList({
  products,
  onSelect,  // Function — KHÔNG serializable!
}: {
  products: Product[]
  onSelect: (id: string) => void
}) {
  "use cache"  // Lỗi!
}

// ĐÚNG: Tách phần cached và phần interactive
async function ProductList({ categoryId }: { categoryId: string }) {
  "use cache"
  cacheTag(`category-${categoryId}`)

  const products = await db.product.findMany({ where: { categoryId } })

  return (
    <div>
      {products.map((p) => (
        <ProductCard key={p.id} product={p} />
      ))}
    </div>
  )
}

Kết luận

Partial Prerendering trong Next.js 16 thực sự đánh dấu một bước ngoặt. Thay vì ép toàn bộ trang vào một chiến lược rendering duy nhất, PPR để mỗi component tự quyết định cách render — tĩnh hay động, cache lâu hay ngắn, chung cho mọi người hay cá nhân hóa.

Tóm tắt những điểm then chốt:

  • Mọi trang đều dynamic theo mặc định trong Next.js 16. Opt-in vào caching bằng "use cache".
  • Static shell + Dynamic streaming là mô hình cốt lõi. Suspense boundaries phân chia rõ ràng phần tĩnh và động.
  • cacheLife() kiểm soát thời gian cache với stale, revalidate, expire.
  • cacheTag()revalidateTag() cho phép gắn thẻ và invalidate cache khi cần.
  • updateTag() đảm bảo read-your-writes trong Server Actions — người dùng thấy kết quả ngay.
  • Tối ưu hiệu suất bằng cách đặt Suspense boundaries sát nội dung động, tối đa hóa static shell, cache ở mức chi tiết hợp lý.

Cá nhân mình thấy điều tuyệt vời nhất của PPR không chỉ là cải thiện các chỉ số kỹ thuật (TTFB, LCP, FCP) mà còn là trải nghiệm phát triển. Bạn không cần quyết định "trang này nên SSG hay SSR" ngay từ đầu nữa. Cứ xây dựng component bình thường, thêm "use cache" cho phần nào nên cache, bọc Suspense quanh phần nào nên dynamic — rồi Next.js lo phần còn lại.

Với PPR, ranh giới giữa "trang tĩnh" và "trang động" đã mờ đi. Mỗi trang giờ là sự kết hợp tối ưu của cả hai — nhanh như tĩnh, fresh như động. Đây là tương lai của web rendering, và nó đã sẵn sàng trong Next.js 16 rồi.

Về Tác Giả Editorial Team

Our team of expert writers and editors.