React & Frontend⏱ 10 phút đọc · 7 thg 10, 2026

Next.js App Router thực chiến: cấu trúc file, caching với "use cache" và cách chuyển từ Pages Router

Copy code App Router từ tutorial năm ngoái vào project mới là build báo lỗi. Bài này đi qua Next.js App Router bản 16.4: file conventions, server/client, caching với "use cache", metadata, route handler, proxy.ts và lỗi khi rời Pages Router.

HOLETEX · POST
APP ROUTER
next.js 16.4

Bạn chạy create-next-app để làm một blog nhỏ, mở một tutorial App Router viết năm ngoái và copy theo: export const revalidate = 60 ở đầu page, fetch(url, { next: { revalidate: 60 } }) để cache, thêm middleware.ts để chặn trang admin. Build báo lỗi ngay ở export const revalidate; option next.revalidate của fetch vẫn chạy nhưng không còn là cách được khuyên dùng; còn middleware.ts thì docs đã ghi là deprecated. App Router đã đổi khá nhiều trong 2025 và 2026.

Bài này đi qua App Router đúng như nó đang chạy ở Next.js 16.4 (phát hành 6/10/2026), với code theo API hiện hành trong docs chính thức. Nếu chưa rõ Next.js khác React thuần ở đâu, nên đọc trước bài Next.js là gì và React vs Next.js.

App Router tháng 10/2026: những gì đã khác

Hạng mụcHiện trạng
Phiên bảnNext.js 16.4.0 (6/10/2026), đi kèm React 19.3. 16.x là Active LTS; 15.x là Maintenance LTS và hết hỗ trợ ngày 21/10/2026, project còn ở 15 nên lên kế hoạch nâng cấp
BundlerTurbopack mặc định cho next dev và next build (--webpack để dùng Webpack)
CachingCache Components: không cache mặc định, bạn chủ động cache bằng 'use cache'. Bật sẵn cho project mới từ 16.4, sẽ là mặc định ở Next.js 17
Middlewaremiddleware.ts đổi tên thành proxy.ts từ Next.js 16
Node.jsTối thiểu 20.9; params, searchParams là Promise, phải await

Tạo project mới:

bash
npx create-next-app@latest my-app --yes
cd my-app
npm run dev

Cấu hình khuyến nghị gồm TypeScript, Tailwind CSS, ESLint, App Router, Turbopack, Cache Components và AGENTS.md cho AI coding agent. Nếu bạn từng lưu lựa chọn cũ, --yes sẽ dùng lại lựa chọn đó, nên kiểm tra next.config.ts sau khi tạo. Project có sẵn thì bật như sau:

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

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
}

export default nextConfig

Phần còn lại của bài giả định hai cờ này đã bật (docs khuyên ghi rõ partialPrefetching, để trống sẽ có cảnh báo).

Cấu trúc file: folder là route, file đặc biệt là UI

Mỗi folder trong app/ là một đoạn URL, và chỉ thành trang truy cập được khi có page.tsx. Cấu trúc một blog nhỏ có khu quản trị:

text
app/
├── layout.tsx              # root layout: bắt buộc, chứa <html> và <body>
├── not-found.tsx           # trang 404 chung
├── global-error.tsx        # bắt lỗi ở chính root layout
├── (marketing)/
│   ├── page.tsx            # /
│   └── about/page.tsx      # /about
├── (dashboard)/
│   ├── layout.tsx          # layout riêng cho khu quản trị
│   └── admin/
│       ├── page.tsx        # /admin
│       ├── loading.tsx
│       └── error.tsx
├── blog/
│   ├── page.tsx            # /blog
│   ├── _components/        # private folder, không thành route
│   └── [slug]/
│       ├── page.tsx        # /blog/:slug
│       └── opengraph-image.tsx
├── docs/[...slug]/page.tsx # /docs/a/b/c (catch-all)
└── api/revalidate/route.ts # POST /api/revalidate
proxy.ts                    # nằm cạnh thư mục app
FileVai tròLưu ý
layout.tsxUI dùng chung, bọc các route conGiữ state khi điều hướng, không render lại
page.tsxUI riêng của route, làm route truy cập đượcNhận params, searchParams dạng Promise
loading.tsxFallback khi segment đang tảiThực chất là <Suspense> bọc quanh page
error.tsxError boundary của segmentBắt buộc là Client Component
not-found.tsxUI khi gọi notFound()Đặt ở gốc app/ cho 404 toàn site
route.tsAPI endpoint (Route Handler)Không được nằm cùng segment với page.tsx

Ba quy ước đặt tên folder:

  • [slug] là dynamic segment, [...slug] bắt mọi đoạn phía sau, [[...slug]] bắt cả trường hợp không có đoạn nào.
  • (tên) là route group: nhóm route, gắn layout riêng, không thêm vào URL. (marketing)/about vẫn là /about.
  • _tên là private folder: để component, util cạnh route mà không thành URL.

Trong một segment, layout bọc error, error bọc loading, loading bọc page. Vì vậy error.tsx không bắt lỗi của layout.tsx cùng cấp; lỗi đó đi lên segment cha, còn lỗi ở root layout cần global-error.tsx.

Root layout với metadata chung và title template:

tsx
// app/layout.tsx
import type { Metadata } from 'next'
import './globals.css'

export const metadata: Metadata = {
  title: { template: '%s | HoleTex Blog', default: 'HoleTex Blog' },
  description: 'Blog lập trình thực chiến cho dev Việt',
}

export default function RootLayout({ children }: LayoutProps<'/'>) {
  return (
    <html lang="vi">
      <body>{children}</body>
    </html>
  )
}

LayoutProps, PageProps là type global sinh ra khi chạy next dev, next build hoặc next typegen, không cần import, và suy ra kiểu params từ đúng đường dẫn route.

Error boundary của segment:

tsx
// app/(dashboard)/admin/error.tsx
'use client'

import { useEffect } from 'react'

export default function Error({
  error,
  retry,
}: {
  error: Error & { digest?: string }
  retry: () => void
}) {
  useEffect(() => {
    console.error(error) // gửi lên Sentry hoặc dịch vụ log của bạn
  }, [error])

  return (
    <div>
      <h2>Không tải được dữ liệu quản trị</h2>
      <button onClick={() => retry()}>Thử lại</button>
    </div>
  )
}

Tutorial cũ dùng reset(). Từ Next.js 16.3, retry() đã stable và nên dùng: nó fetch lại và render lại phần con của boundary, kể cả Server Component, còn reset() chỉ render lại mà không fetch. Ở production, lỗi từ Server Component chỉ hiện thông báo chung kèm error.digest để dò log server.

Ranh giới server và client: nhắc nhanh

Mọi layout và page mặc định là Server Component: chạy trên server, được async/await, đọc database trực tiếp, code không gửi xuống browser. Chỉ phần cần state, event handler hay API browser mới tách ra file 'use client', đặt càng gần "lá" của cây càng tốt.

Chi tiết (props serializable, pattern children, Server Functions, bảo mật) có trong bài React Server Components.

Data fetching và caching: dynamic mặc định, cache khi bạn chọn

Đây là phần thay đổi nhiều nhất. Mô hình cũ cache ngầm theo nhiều tầng, khó đoán khi nào trang "bị static". Cache Components lật ngược lại: không có gì được cache trừ khi bạn đánh dấu. Khi build, mỗi component rơi vào một trong ba nhóm:

NhómVí dụNext.js xử lý
Có thể đoán trướcImport module, tính toán thuần, fs.readFileSyncTự prerender vào static shell
Đã cacheHàm hoặc component có 'use cache'Kết quả nằm trong static shell, làm mới theo cacheLife
Runtimecookies(), headers(), searchParams, query không cachePhải nằm trong <Suspense>, stream vào lúc request

Mỗi trang vì vậy có một static shell (HTML gửi đi ngay, phục vụ được từ CDN), phần động stream vào sau. Docs gọi đây là Partial Prerendering. Đọc dữ liệu runtime hoặc không cache mà thiếu <Suspense> thì dev overlay báo route bị chặn (blocking route) kèm gợi ý: cache lại hoặc bọc Suspense.

Ví dụ hoàn chỉnh: trang bài viết

Tầng dữ liệu, gom vào một file chỉ chạy trên server:

ts
// lib/posts.ts
import 'server-only'
import { cacheLife, cacheTag } from 'next/cache'
import { db } from '@/lib/db' // Prisma, Drizzle... tuỳ bạn

export async function getPost(slug: string) {
  'use cache'
  cacheLife('max')
  cacheTag('posts', `post-${slug}`)

  return db.post.findUnique({
    where: { slug },
    select: { id: true, title: true, excerpt: true, content: true },
  })
}

export async function getPopularSlugs() {
  'use cache'
  cacheLife('max')
  cacheTag('posts')

  const posts = await db.post.findMany({
    select: { slug: true },
    orderBy: { views: 'desc' },
    take: 20,
  })
  return posts.map((p) => p.slug)
}

Tham số của hàm (slug) tự động thành một phần của cache key, nên mỗi bài có entry riêng. cacheTag nhận nhiều tag trong một lần gọi, sau này bạn có thể làm mới một bài hoặc cả danh sách.

Trang bài viết:

tsx
// app/blog/[slug]/page.tsx
import { Suspense } from 'react'
import { cookies } from 'next/headers'
import { notFound } from 'next/navigation'
import { getPost, getPopularSlugs } from '@/lib/posts'

export async function generateStaticParams() {
  const slugs = await getPopularSlugs()
  return slugs.map((slug) => ({ slug })) // phải có ít nhất 1 phần tử
}

export async function generateMetadata({ params }: PageProps<'/blog/[slug]'>) {
  const { slug } = await params
  const post = await getPost(slug)
  return post ? { title: post.title, description: post.excerpt } : {}
}

async function Article({ params }: Pick<PageProps<'/blog/[slug]'>, 'params'>) {
  const { slug } = await params
  const post = await getPost(slug)
  if (!post) notFound()

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  )
}

async function ReadingPreference() {
  const fontSize = (await cookies()).get('font-size')?.value ?? 'md'
  return <p>Cỡ chữ đang dùng: {fontSize}</p>
}

export default function PostPage({ params }: PageProps<'/blog/[slug]'>) {
  return (
    <main>
      <Suspense fallback={<p>Đang tải bài viết...</p>}>
        <Article params={params} />
      </Suspense>
      <Suspense fallback={null}>
        <ReadingPreference />
      </Suspense>
    </main>
  )
}

Vài điểm đáng chú ý:

  • Page không await params ở trên cùng mà truyền Promise xuống component con trong <Suspense>. Nhờ vậy slug không có trong generateStaticParams vẫn được trả shell ngay ở lần truy cập đầu, nội dung stream vào, rồi Next.js lưu bản hoàn chỉnh cho người sau. Đây là bản thay thế cho fallback: true của Pages Router.
  • generateStaticParams trả về mảng rỗng sẽ báo lỗi khi bật Cache Components, và export dynamicParams không còn được hỗ trợ (slug không tồn tại thì gọi notFound()). Hệ quả thực tế: next build phải kết nối được nguồn dữ liệu và có ít nhất một bài. Build trong Docker không có DB thì cần cấp DB lúc build hoặc trả về một slug cố định.
  • notFound() gọi sau khi đã bắt đầu stream thì response giữ status 200, Next.js tự thêm thẻ noindex.
  • getPost đã có 'use cache', nên generateMetadata và Article gọi cùng slug không tạo thêm query thừa.
  • ReadingPreference đọc cookie nên nằm trong Suspense riêng; chỉ phần đó stream lúc request, không kéo cả route thành dynamic như mô hình cũ.

cacheLife: cache bao lâu

Docs khuyên luôn đi kèm cacheLife với mỗi 'use cache', nếu không sẽ dùng profile default. Các profile có sẵn:

ProfilestalerevalidateexpireHợp với
default5m15mkhông hết hạnÁp dụng khi không gọi cacheLife
seconds30s1s60sGần realtime (không vào static shell)
minutes5m1m1hFeed, bảng xếp hạng
hours5m1h1dDanh mục sản phẩm
days5m1d1wNội dung ít đổi
weeks5m1w30dNội dung gần như tĩnh
max5m30d1yBài CMS, làm mới bằng tag

stale: browser được dùng lại bao lâu; revalidate: khi nào server làm mới ở background; expire: sau mốc này request phải chờ dữ liệu mới. Cache quá ngắn (seconds, revalidate: 0, expire dưới 5 phút) bị loại khỏi prerender và trở thành phần động (stream lúc request).

Làm mới cache theo yêu cầu

updateTag (chỉ trong Server Action) cho hết hạn ngay, hợp khi người dùng vừa sửa và cần thấy ngay. revalidateTag(tag, profile) (Server Action và Route Handler) theo kiểu stale-while-revalidate, hợp với webhook từ CMS:

ts
// app/(dashboard)/admin/actions.ts
'use server'

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

export async function updatePostTitle(slug: string, title: string) {
  const session = await auth()
  if (!session?.user?.isAdmin) throw new Error('Không có quyền')
  // Tham số đến từ client: validate trước khi dùng (hoặc dùng Zod)
  if (typeof title !== 'string' || title.length === 0 || title.length > 200) {
    throw new Error('Tiêu đề không hợp lệ')
  }

  await db.post.update({ where: { slug }, data: { title } })
  updateTag(`post-${slug}`) // người sửa thấy thay đổi ngay ở request kế tiếp
}
ts
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
import type { NextRequest } from 'next/server'

export async function POST(request: NextRequest) {
  if (request.headers.get('x-revalidate-secret') !== process.env.REVALIDATE_SECRET) {
    return Response.json({ ok: false }, { status: 401 })
  }

  // Webhook có thể gửi body rỗng: không để request.json() ném lỗi 500
  const { slug } = (await request.json().catch(() => ({}))) as { slug?: string }
  revalidateTag(slug ? `post-${slug}` : 'posts', 'max')
  return Response.json({ ok: true })
}

revalidateTag giờ bắt buộc tham số thứ hai: code cũ revalidateTag('posts') sửa thành revalidateTag('posts', 'max'). revalidatePath vẫn dùng được, nhưng docs khuyên ưu tiên tag.

Cache nằm ở đâu khi self-host

Khi deploy bằng Docker hay nhiều instance: kết quả 'use cache' lúc runtime mặc định nằm trong bộ nhớ của từng instance, mất khi restart, và gắn với một lần deploy. Cần cache bền dùng chung thì docs hướng dẫn 'use cache: remote' kèm cache handler, chỉ đáng khi tỉ lệ cache hit cao; kể cả remote cũng tính lại sau mỗi lần deploy vì cache key chứa build id. Ngược lại, fetch Data Cache và unstable_cache của mô hình cũ giữ dữ liệu qua các lần deploy, nên khi migrate hãy tính tới khác biệt này.

Muốn chắc chắn không có phần động lọt vào, dùng export const ensureStatic = 'navigation' (mới ở 16.4): build fail nếu route có nội dung render lúc request. Trang bài viết ở trên sẽ không qua kiểm tra này vì ReadingPreference đọc cookie; muốn ép tĩnh thì bỏ phần đó hoặc dùng mức nhẹ hơn như 'shell'.

Với Partial Prefetching, <Link> mặc định chỉ prefetch App Shell của trang đích. Muốn prefetch cả nội dung đã cache của đúng URL đó, thêm prefetch={true}, đổi lại mỗi link tốn thêm một lần gọi server khi prefetch. Hook phía client import từ next/navigation, không phải next/router:

tsx
// app/_components/nav-link.tsx
'use client'

import Link from 'next/link'
import { usePathname } from 'next/navigation'

export function NavLink({ href, children }: { href: string; children: React.ReactNode }) {
  const pathname = usePathname()
  const active = pathname === href || pathname.startsWith(`${href}/`)

  return (
    <Link href={href} aria-current={active ? 'page' : undefined}>
      {children}
    </Link>
  )
}

Hai hành vi mới khi bật Cache Components:

  1. useSearchParams luôn cần <Suspense>. usePathname, useParams cũng cần khi route bên dưới có dynamic param chưa biết lúc build (ví dụ nav trong layout dùng chung với /blog/[slug]). Bọc đúng component nhỏ đọc hook đó.
  2. State được giữ khi quay lại trang. Next.js dùng <Activity> để ẩn route cũ thay vì unmount: useState, ô input, dropdown đang mở còn nguyên khi bấm back, effect vẫn được dọn và chạy lại. Logic dựa vào unmount để reset form cần reset chủ động.

Metadata API thay cho next/head

Thay cho next/head, dùng object metadata cho nội dung tĩnh và generateMetadata khi cần dữ liệu (như hai ví dụ ở trên), cả hai chỉ trong Server Component; metadata gộp từ root xuống theo kiểu shallow merge, nên page khai báo openGraph sẽ thay toàn bộ openGraph của layout cha. Ảnh chia sẻ có thể sinh bằng opengraph-image.tsx với ImageResponse từ next/og, và dưới Cache Components generateMetadata theo cùng quy tắc cache như component.

Route Handlers: khi nào cần route.ts

Route Handler dùng Web Request/Response chuẩn; kiểu params dùng helper global RouteContext:

ts
// app/api/posts/[slug]/route.ts
import type { NextRequest } from 'next/server'
import { notFound } from 'next/navigation'
import { getPost } from '@/lib/posts'

export async function GET(_req: NextRequest, ctx: RouteContext<'/api/posts/[slug]'>) {
  const { slug } = await ctx.params
  const post = await getPost(slug)
  if (!post) notFound()
  return Response.json(post)
}

Quy tắc chọn: UI đọc dữ liệu thì lấy thẳng trong Server Component, mutation từ UI dùng Server Action, còn Route Handler dành cho bên ngoài gọi vào (webhook, mobile app, endpoint public). 'use cache' không đặt trực tiếp trong handler được, phải tách hàm như getPost.

proxy.ts thay cho middleware.ts

Từ Next.js 16, middleware.ts đổi tên thành proxy.ts, hàm export thành proxy, vì chữ "middleware" dễ bị nhầm với Express. Codemod:

bash
npx @next/codemod@canary middleware-to-proxy .
ts
// proxy.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function proxy(request: NextRequest) {
  const hasSession = request.cookies.has('session')
  if (!hasSession) {
    return NextResponse.redirect(new URL('/login', request.url))
  }
}

export const config = {
  matcher: ['/admin/:path*'],
}

Proxy chạy Node.js runtime mặc định, đặt runtime trong file này sẽ báo lỗi. Quan trọng hơn: Server Function được gọi bằng POST tới chính route chứa nó, nên đổi matcher hay chuyển action sang route khác có thể âm thầm làm mất lớp kiểm tra ở proxy. Docs khuyên luôn kiểm tra auth bên trong từng Server Function, như updatePostTitle ở trên.

Chuyển từ Pages Router: bảng đối chiếu và lỗi hay gặp

pages/ và app/ chạy song song được, nên có thể chuyển từng trang. Điều hướng giữa hai router là hard navigation và next/link không prefetch chéo, nên chuyển theo cụm trang liên quan.

Pages RouterApp Router hiện hành
_app.tsx, _document.tsxapp/layout.tsx (root layout). Context provider chuyển vào Client Component
next/headmetadata hoặc generateMetadata
getServerSidePropsServer Component async, dữ liệu không cache bọc trong <Suspense>
getStaticProps + revalidateHàm 'use cache' + cacheLife
getStaticPathsgenerateStaticParams (trả về ít nhất 1 phần tử)
fallback: trueAwait params bên trong <Suspense> với Partial Prefetching
fallback: 'blocking'Await params ngoài <Suspense> (khi không bật Partial Prefetching)
useRouter từ next/routeruseRouter, usePathname, useSearchParams, useParams từ next/navigation
router.queryparams (Promise) và useSearchParams
pages/api/*app/**/route.ts
pages/404.tsx, _error.tsxnot-found.tsx, error.tsx, global-error.tsx
middleware.tsproxy.ts

Lỗi hay gặp khi migrate, kèm cách sửa:

Triệu chứngNguyên nhânCách sửa
Build lỗi ở export const revalidate, dynamic, fetchCacheCác route segment config này bị thay thế khi bật Cache Componentsrevalidate → cacheLife; force-dynamic → xoá; force-static → 'use cache' + cacheLife('max')
runtime = 'edge' không chạyCache Components cần Node.js runtimeBỏ export này; logic cần chạy ở edge chuyển sang proxy.ts
Lỗi build với new Date(), Math.random()Giá trị khác nhau mỗi lần render, không prerender đượcawait connection() trong component bọc Suspense, hoặc cache kết quả

Project lớn không cần sửa một lần. Docs gợi ý bật Cache Components, chạy npx @next/codemod@canary cache-components-instant-false ./app (project có src/ thì dùng ./src/app) để tạm cho mọi route bỏ qua bước kiểm tra "instant", để app build được trước, rồi chuyển từng route. Riêng lỗi new Date(), Math.random() lúc prerender thì không hoãn được. Nếu dùng AI agent, npx next@canary upgrade --agent=latest (16.4) chuẩn bị sẵn hướng dẫn, codemod và bước kiểm tra cho agent; vẫn nên review diff kỹ.

Checklist trước khi deploy

  1. npm ls next react để biết đang ở bản nào. Bản vá gần nhất là 16.3.8 và 15.5.27 (30/9/2026), trong đó có hai lỗi liên quan 'use cache' (sai cache key với root param, lộ nội dung Draft Mode), nên app bật Cache Components càng cần cập nhật. Next.js cũng thông báo bản vá cho một lỗi critical và một lỗi high bị hoãn do phụ thuộc upstream; theo dõi blog của Next.js và lên bản mới ngay khi có.
  2. Mỗi 'use cache' có cacheLife phù hợp và cacheTag nếu cần làm mới theo yêu cầu.
  3. Mọi chỗ đọc cookies(), headers(), searchParams đều nằm trong <Suspense>.
  4. Mỗi Server Action tự kiểm tra auth và quyền, không trông vào proxy.ts.
  5. Self-host nhiều instance thì quyết định rõ cache runtime nằm ở đâu (in-memory hay 'use cache: remote').
  6. Chạy next build và đọc output: route nào static, route nào có phần động.

App Router năm 2026 dễ đoán hơn bản đầu: không có gì cache ngầm, mọi chỗ cache đều nằm trong code. Đổi lại, bạn cần hiểu chắc Suspense, Server Component và luồng dữ liệu của React. Nếu muốn củng cố phần nền đó, khóa React PRO của HoleTex đi từ hook, state tới kiến trúc ứng dụng theo hướng thực chiến.

Bài liên quan

Nguồn tham khảo: Next.js 16.4 (nextjs.org), September 2026 Security Release (nextjs.org), Installation (nextjs.org), Caching (nextjs.org), Revalidating (nextjs.org), Migrating to Cache Components (nextjs.org), ISR with Cache Components (nextjs.org), notFound (nextjs.org), generateMetadata (nextjs.org), proxy.js (nextjs.org), Migrating from Pages to App Router (nextjs.org), Support policy (nextjs.org). Cập nhật 2026-10-08.

Thấy hay? Chia sẻ