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.
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ục | Hiện trạng |
|---|---|
| Phiên bản | Next.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 |
| Bundler | Turbopack mặc định cho next dev và next build (--webpack để dùng Webpack) |
| Caching | Cache 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 |
| Middleware | middleware.ts đổi tên thành proxy.ts từ Next.js 16 |
| Node.js | Tối thiểu 20.9; params, searchParams là Promise, phải await |
Tạo project mới:
npx create-next-app@latest my-app --yes
cd my-app
npm run devCấ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:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
}
export default nextConfigPhầ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ị:
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| File | Vai trò | Lưu ý |
|---|---|---|
layout.tsx | UI dùng chung, bọc các route con | Giữ state khi điều hướng, không render lại |
page.tsx | UI riêng của route, làm route truy cập được | Nhận params, searchParams dạng Promise |
loading.tsx | Fallback khi segment đang tải | Thực chất là <Suspense> bọc quanh page |
error.tsx | Error boundary của segment | Bắt buộc là Client Component |
not-found.tsx | UI khi gọi notFound() | Đặt ở gốc app/ cho 404 toàn site |
route.ts | API 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)/aboutvẫn là/about._tênlà 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:
// 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:
// 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óm | Ví dụ | Next.js xử lý |
|---|---|---|
| Có thể đoán trước | Import module, tính toán thuần, fs.readFileSync | Tự prerender vào static shell |
| Đã cache | Hàm hoặc component có 'use cache' | Kết quả nằm trong static shell, làm mới theo cacheLife |
| Runtime | cookies(), headers(), searchParams, query không cache | Phả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:
// 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:
// 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ó tronggenerateStaticParamsvẫ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ế chofallback: truecủa Pages Router. generateStaticParamstrả về mảng rỗng sẽ báo lỗi khi bật Cache Components, và exportdynamicParamskhông còn được hỗ trợ (slug không tồn tại thì gọinotFound()). Hệ quả thực tế:next buildphả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êngenerateMetadatavàArticlegọ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:
| Profile | stale | revalidate | expire | Hợp với |
|---|---|---|---|---|
default | 5m | 15m | không hết hạn | Áp dụng khi không gọi cacheLife |
seconds | 30s | 1s | 60s | Gần realtime (không vào static shell) |
minutes | 5m | 1m | 1h | Feed, bảng xếp hạng |
hours | 5m | 1h | 1d | Danh mục sản phẩm |
days | 5m | 1d | 1w | Nội dung ít đổi |
weeks | 5m | 1w | 30d | Nội dung gần như tĩnh |
max | 5m | 30d | 1y | Bà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:
// 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
}// 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'.
Navigation: Link, prefetch và state được giữ lại
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:
// 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:
useSearchParamsluôn cần<Suspense>.usePathname,useParamscũ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 đó.- 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:
// 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:
npx @next/codemod@canary middleware-to-proxy .// 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 Router | App Router hiện hành |
|---|---|
_app.tsx, _document.tsx | app/layout.tsx (root layout). Context provider chuyển vào Client Component |
next/head | metadata hoặc generateMetadata |
getServerSideProps | Server Component async, dữ liệu không cache bọc trong <Suspense> |
getStaticProps + revalidate | Hàm 'use cache' + cacheLife |
getStaticPaths | generateStaticParams (trả về ít nhất 1 phần tử) |
fallback: true | Await 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/router | useRouter, usePathname, useSearchParams, useParams từ next/navigation |
router.query | params (Promise) và useSearchParams |
pages/api/* | app/**/route.ts |
pages/404.tsx, _error.tsx | not-found.tsx, error.tsx, global-error.tsx |
middleware.ts | proxy.ts |
Lỗi hay gặp khi migrate, kèm cách sửa:
| Triệu chứng | Nguyên nhân | Cách sửa |
|---|---|---|
Build lỗi ở export const revalidate, dynamic, fetchCache | Các route segment config này bị thay thế khi bật Cache Components | revalidate → cacheLife; force-dynamic → xoá; force-static → 'use cache' + cacheLife('max') |
runtime = 'edge' không chạy | Cache Components cần Node.js runtime | Bỏ 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 được | await 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
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ó.- Mỗi
'use cache'cócacheLifephù hợp vàcacheTagnếu cần làm mới theo yêu cầu. - Mọi chỗ đọc
cookies(),headers(),searchParamsđều nằm trong<Suspense>. - Mỗi Server Action tự kiểm tra auth và quyền, không trông vào
proxy.ts. - Self-host nhiều instance thì quyết định rõ cache runtime nằm ở đâu (in-memory hay
'use cache: remote'). - Chạy
next buildvà đọ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
- React Server Components: hiểu đúng ranh giới server và client trong Next.js
- Next.js là gì? Giải thích dễ hiểu cho người mới
- React vs Next.js: khác nhau ở đâu và nên học cái nào trước?
- React Query là gì: dùng TanStack Query v5 thay cho fetch trong useEffect
- Tối ưu performance trong React
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.