目錄
- 什麼是 Next.js 渲染模型?
- Server / Client Components 邊界規則
- Streaming 與 Suspense
- Static / Dynamic / PPR 三種 Rendering
- Next.js 四層快取總覽
- Request Memoization 與 Data Cache
- Full Route Cache 與 Router Cache
- revalidateTag / revalidatePath / unstable_cache
- 實戰範例
- 常見問題
- 總結
- 參考資源
什麼是 Next.js 渲染模型?
Next.js 渲染模型指的是 App Router 下,元件「在哪裡執行」「何時執行」「結果怎麼快取」的完整機制。
為什麼需要新模型?
傳統 SPA 問題:
- 首屏空白 → 等 JS 載入 → React render → 才看到內容
- SEO 不友善(爬蟲拿到的是空殼 HTML)
- 所有元件都打包到 bundle,bundle 越來越大
Pages Router 的限制:
getServerSideProps/getStaticProps只能用在 page 層級- 元件層級沒有「在伺服器跑」的概念
- 快取邏輯散落各處
App Router 的解法:
- 預設 Server Component:元件層級就能在伺服器執行
- 內建 4 層快取:fetch、Route、Router、Memo 各司其職
- Streaming:邊渲染邊送 HTML,TTFB 與 LCP 都優化
核心心智模型
請求進來
↓
路由匹配 → 找 page.tsx + 所有 layout.tsx
↓
Server 端執行 Server Components(含 await fetch)
↓
產生 RSC payload + 初始 HTML
↓
Streaming 送到瀏覽器
↓
Client Components hydrate(綁定事件、啟用 state)
Server / Client Components 邊界規則
兩種元件並存
| 類型 | 標記方式 | 執行位置 | 可用功能 |
|---|---|---|---|
| Server Component | 預設 | 伺服器 | async/await、直接讀資料庫、讀環境變數、不送 JS 到 client |
| Client Component | "use client" |
伺服器初次渲染 + 瀏覽器 hydrate | useState / useEffect / 事件處理 / 瀏覽器 API |
"use client" 是「邊界宣告」
"use client" 寫在檔案最上方,這個檔案及其 import 的子元件都進入 Client 邊界:
// app/components/Tabs.tsx
"use client"; // 從這裡開始進入 Client 邊界
import { useState } from "react";
export default function Tabs({ items }: { items: string[] }) {
const [active, setActive] = useState(0);
return (
<div>
{items.map((item, i) => (
<button key={i} onClick={() => setActive(i)}>
{item}
</button>
))}
</div>
);
}
"use server" 是「伺服器函式宣告」
"use server" 用法不同,標記單一函式或檔案是 Server Action:
// app/actions.ts
"use server";
export async function createPost(formData: FormData) {
// 這個函式只會在伺服器執行,可以從 client 直接呼叫
}
Server Actions 完整介紹見 server-actions.md
邊界穿越規則
Rule 1:Client Component 不能直接 import 並執行 Server Component
// ❌ 編譯失敗
"use client";
import ServerOnly from "./ServerOnly"; // 不行
export default function Wrong() {
return <ServerOnly />;
}
Rule 2:但 Client Component 可以接受 Server Component 作為 children prop
// app/page.tsx (Server)
import ClientShell from "./ClientShell";
import ServerContent from "./ServerContent";
export default function Page() {
return (
<ClientShell>
<ServerContent /> {/* ✅ 透過 children 傳入 OK */}
</ClientShell>
);
}
// ClientShell.tsx
"use client";
export default function ClientShell({
children,
}: {
children: React.ReactNode;
}) {
// children 已經是渲染好的 RSC payload,client 只負責放進 DOM
return <div className="shell">{children}</div>;
}
Rule 3:Server Component 可以自由 import Client Component
// app/page.tsx (Server)
import ClientCounter from "./ClientCounter"; // ✅ OK
export default function Page() {
return <ClientCounter />;
}
判斷依據速查
| 元件需要... | 用 |
|---|---|
| 讀資料庫 / 環境變數 / 大型 lib(不想送到 client) | Server |
useState / useEffect / useReducer |
Client |
onClick / onChange / 表單互動 |
Client |
window / localStorage / IntersectionObserver |
Client |
| 純展示資料,不需互動 | Server |
| SEO 重要(內容要直接在初始 HTML) | Server |
Streaming 與 Suspense
Streaming 是什麼?
伺服器不等所有資料準備好才送 HTML,而是邊渲染邊送。慢的部分用 fallback 占位,等 ready 才補上實際內容。
如何啟用 Streaming
方法 1:loading.tsx
最簡單,每個路由都可以有:
// app/dashboard/loading.tsx
export default function Loading() {
return <div className="skeleton">載入儀表板中...</div>;
}
Next.js 自動把整個 page.tsx 包進 <Suspense fallback={<Loading />}>。
方法 2:手動 <Suspense> 切割
更細粒度,把慢的子元件單獨包:
// app/dashboard/page.tsx
import { Suspense } from "react";
export default function Dashboard() {
return (
<div>
<Header /> {/* 快速 */}
<Suspense fallback={<ChartSkeleton />}>
<SlowChart /> {/* 慢的可以單獨等 */}
</Suspense>
<Suspense fallback={<ListSkeleton />}>
<SlowList /> {/* 也可以單獨等 */}
</Suspense>
</div>
);
}
Streaming 帶來的好處
- TTFB(Time To First Byte)變快:不用等所有資料
- LCP 改善:用戶更早看到首屏主要內容
- 獨立等待:慢的元件不會拖累快的部分
Suspense 邊界選擇原則
✅ 適合切 Suspense
├── 獨立的資料來源(會慢的 API)
├── 大型互動元件(評論區、推薦清單)
└── 第三方嵌入(廣告、影片播放器)
❌ 不適合切
├── 標題、Logo 等首屏核心內容(流式進來會閃)
└── 已經很快的元件(多包一層反而浪費)
Static / Dynamic / PPR 三種 Rendering
Static Rendering(預設)
何時渲染:build 時或第一次請求後快取
特徵:
- HTML 在 build 時產生
- 全球 CDN 邊緣節點都可以快取
- 適合不常變動的內容
// app/blog/[slug]/page.tsx — 預設 static
export default async function Post({ params }: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await fetch(`https://cms.example.com/posts/${slug}`).then(r => r.json());
return <article>{post.title}</article>;
}
// build 時預先生成所有路由
export async function generateStaticParams() {
const posts = await fetch("https://cms.example.com/posts").then(r => r.json());
return posts.map((p: { slug: string }) => ({ slug: p.slug }));
}
Dynamic Rendering(按需)
何時渲染:每次請求
何時自動切換到 Dynamic:使用了以下任一 API
cookies()headers()searchParamsfetch(..., { cache: "no-store" })fetch(..., { next: { revalidate: 0 } })
// app/dashboard/page.tsx
import { cookies } from "next/headers";
export default async function Dashboard() {
const cookieStore = await cookies();
const userId = cookieStore.get("uid")?.value;
// 一旦用了 cookies(),整頁變 dynamic
const data = await fetch(`/api/user/${userId}`).then(r => r.json());
return <div>{data.name}</div>;
}
強制動態 / 強制靜態
// 強制動態
export const dynamic = "force-dynamic";
// 強制靜態(用了 cookies 等會報錯)
export const dynamic = "force-static";
// 預設(智能判斷)
export const dynamic = "auto";
Partial Prerendering(PPR)
Next.js 15+ 提供,仍在實驗。同一頁混合 static + dynamic:靜態框架預渲染,動態部分透過 streaming 補上。
// next.config.ts
export default {
experimental: {
ppr: "incremental",
},
};
// app/product/[id]/page.tsx
export const experimental_ppr = true;
import { Suspense } from "react";
export default function Product({ params }: { params: Promise<{ id: string }> }) {
return (
<div>
<ProductInfo params={params} /> {/* 靜態:預渲染 */}
<Suspense fallback={<RecommendSkeleton />}>
<PersonalizedRecommendations /> {/* 動態:runtime 補上 */}
</Suspense>
</div>
);
}
PPR 的價值:頁面首屏即時可見(從靜態 CDN 拿到),動態部分透過 streaming 補完。
Next.js 四層快取總覽
| 層 | 名稱 | 範圍 | 期限 | 目的 |
|---|---|---|---|---|
| 1 | Request Memoization | 單次 request | request 結束 | 同次請求內 dedupe 重複 fetch |
| 2 | Data Cache | 跨 request、跨 build | 直到 revalidate | 快取 fetch 結果 |
| 3 | Full Route Cache | 跨 user、跨 request | 直到 revalidate | 快取完整路由 HTML + RSC payload |
| 4 | Router Cache | 單個 client(browser) | session 期間 | client 端的 prefetch / navigation 快取 |
┌─────────────────────────────────────────────────────────────┐
│ Browser │
│ Router Cache (client-side, per session) │
│ │
└──────────────────────────┬──────────────────────────────────┘
│ navigate / fetch
↓
┌─────────────────────────────────────────────────────────────┐
│ Next.js Server │
│ Full Route Cache (HTML + RSC payload, 跨 user) │
│ ↓ miss │
│ Render page │
│ ↓ │
│ Data Cache (fetch 結果,跨 build) │
│ ↓ miss │
│ fetch() → 上游 API │
│ ↑ │
│ Request Memoization (單次 request 內 dedupe) │
└─────────────────────────────────────────────────────────────┘
Request Memoization 與 Data Cache
Request Memoization
目的:同一次 request 內,多個 Server Component 都呼叫 fetch(url) 時自動 dedupe,只打一次上游。
// app/page.tsx
import Header from "./Header";
import Sidebar from "./Sidebar";
export default async function Page() {
// Header 和 Sidebar 都會呼叫 fetch("/api/user")
// 但實際只打一次(Request Memoization)
return (
<>
<Header />
<Sidebar />
</>
);
}
// 兩個元件分別寫
async function getUser() {
return fetch("https://api.example.com/user").then(r => r.json());
}
特性:
- 自動運作,無需設定
- 只對 GET fetch 生效
- 一次 request 結束就清空
Data Cache
目的:跨 request 快取 fetch 結果,預設永久快取(直到 revalidate)。
// 預設行為(強制快取)
fetch("https://api.example.com/posts");
// 等同於:
fetch("https://api.example.com/posts", { cache: "force-cache" });
// 不快取
fetch("https://api.example.com/posts", { cache: "no-store" });
// 60 秒重新驗證
fetch("https://api.example.com/posts", {
next: { revalidate: 60 },
});
// 加 tag 後可手動 revalidate
fetch("https://api.example.com/posts", {
next: { tags: ["posts"] },
});
Data Cache 與渲染模式的關係
| fetch 配置 | Data Cache | Route 行為 |
|---|---|---|
| 預設(無設定) | ✅ 永久 | Static |
cache: "force-cache" |
✅ 永久 | Static |
cache: "no-store" |
❌ 不快取 | Dynamic |
next: { revalidate: 60 } |
✅ 60 秒 | ISR |
next: { tags: [...] } |
✅ 直到 tag revalidate | Static / ISR |
Full Route Cache 與 Router Cache
Full Route Cache
儲存什麼:整個路由的 HTML + RSC payload
何時建立:build 時(static route)或第一次 dynamic 渲染後
何時失效:
- 該路由的 Data Cache 被 revalidate
- 部署新版本(build 重新生成)
- 用了
cookies()/headers()/searchParams→ 該路由直接不進 Full Route Cache
Router Cache(Client-side)
儲存什麼:navigation 過的路由 RSC payload(在瀏覽器 memory)
特性:
- 用
<Link>點擊跳轉時,Next.js 會背景 prefetch - 已 prefetch 的路由切換幾乎零延遲
- 預設 layout 共用 30 秒、page 30 秒(Next.js 15+ 變得更短)
// 主動控制 Router Cache
import { useRouter } from "next/navigation";
const router = useRouter();
router.refresh(); // 重新拉 RSC payload,但不重 mount client state
router.prefetch("/dashboard"); // 手動 prefetch
<Link> 的 prefetch 行為
// 預設:在 viewport 內 prefetch
<Link href="/about">About</Link>
// 不 prefetch(節省流量)
<Link href="/about" prefetch={false}>About</Link>
// 強制 prefetch(即使不在 viewport)
<Link href="/about" prefetch={true}>About</Link>
revalidateTag / revalidatePath / unstable_cache
revalidatePath
用途:失效特定路徑的 Data Cache + Full Route Cache
// app/actions.ts
"use server";
import { revalidatePath } from "next/cache";
export async function createPost(formData: FormData) {
// ... 寫入資料庫
revalidatePath("/posts"); // 失效 /posts 頁面
revalidatePath("/posts/[slug]", "page"); // 失效所有動態文章頁
}
revalidateTag
用途:失效特定 tag 標記的快取,可跨多個路由
// 多個地方 fetch 同一份資料時,貼同一個 tag
fetch("https://api.example.com/posts", { next: { tags: ["posts"] } });
fetch("https://api.example.com/posts/featured", { next: { tags: ["posts"] } });
// 一次失效
import { revalidateTag } from "next/cache";
export async function refreshPosts() {
"use server";
revalidateTag("posts");
}
unstable_cache
用途:把非 fetch 的資料(例如資料庫查詢)也納入 Data Cache
import { unstable_cache } from "next/cache";
const getPostsByUser = unstable_cache(
async (userId: string) => {
return await db.post.findMany({ where: { userId } });
},
["posts-by-user"], // cache key prefix
{
revalidate: 60,
tags: ["posts"],
}
);
// 使用
const posts = await getPostsByUser("user-123");
Next.js 16 對
unstable_cache仍標示為 unstable,未來 API 可能改變。生產環境建議搭配revalidateTag監控。
實戰範例
範例 1:部落格列表 + 詳情頁(ISR)
// app/blog/page.tsx — 列表頁,每 60 秒重新驗證
export default async function BlogList() {
const posts = await fetch("https://cms.example.com/posts", {
next: { revalidate: 60, tags: ["posts"] },
}).then((r) => r.json());
return (
<ul>
{posts.map((p: { slug: string; title: string }) => (
<li key={p.slug}>
<Link href={`/blog/${p.slug}`}>{p.title}</Link>
</li>
))}
</ul>
);
}
// app/blog/[slug]/page.tsx — 詳情頁,static + ISR
export async function generateStaticParams() {
const posts = await fetch("https://cms.example.com/posts").then((r) => r.json());
return posts.map((p: { slug: string }) => ({ slug: p.slug }));
}
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await fetch(`https://cms.example.com/posts/${slug}`, {
next: { tags: [`post-${slug}`, "posts"] },
}).then((r) => r.json());
return <article>{post.content}</article>;
}
// app/api/revalidate/route.ts — webhook 收到 CMS 更新後失效
import { revalidateTag } from "next/cache";
import { NextResponse } from "next/server";
export async function POST(req: Request) {
const { slug } = await req.json();
if (slug) {
revalidateTag(`post-${slug}`);
} else {
revalidateTag("posts");
}
return NextResponse.json({ revalidated: true });
}
範例 2:個人化儀表板 + Streaming
// app/dashboard/page.tsx
import { Suspense } from "react";
import { cookies } from "next/headers";
import StatsCards from "./StatsCards";
import RecentActivity from "./RecentActivity";
import SlowChart from "./SlowChart";
export default async function Dashboard() {
const cookieStore = await cookies();
const userId = cookieStore.get("uid")?.value;
return (
<div>
{/* 快資料:直接 await */}
<StatsCards userId={userId} />
{/* 慢資料:包 Suspense */}
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity userId={userId} />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<SlowChart userId={userId} />
</Suspense>
</div>
);
}
常見問題
問題 1:明明加了 revalidate,為何沒更新?
原因:可能正在 build 期間 / 路由還沒被存取 / 上游 API 沒變
排查:
- 確認
next: { revalidate: N }寫對位置(在 fetch options 內,不是頂層 export) - ISR 需要該路徑被存取至少一次才會背景重生
- 開發環境可能行為不同,務必到
npm run build && npm start測
問題 2:cookies() 用了之後整頁變 dynamic 怎麼辦?
症狀:原本 static 的頁面用了 cookies() 就強制 dynamic,build 時警告
解決方案 1:用 PPR 拆分
// 把使用 cookies() 的部分隔開
import { Suspense } from "react";
export const experimental_ppr = true;
export default function Page() {
return (
<div>
<StaticContent /> {/* 預渲染 */}
<Suspense fallback={<Loading />}>
<UserSpecificContent /> {/* dynamic */}
</Suspense>
</div>
);
}
解決方案 2:把個人化邏輯搬到 Client Component
問題 3:開發環境沒看到快取作用
原因:npm run dev 模式下 Next.js 為了開發體驗會繞過某些快取
解決方案:用 production build 測試
npm run build
npm start
問題 4:fetch 不快取(怎麼設定都不行)
原因:可能是 fetch 在 Route Handler / Server Action 內,這些情境預設不快取
解決方案:明確指定 cache: "force-cache" 或在 Server Component 內 fetch
問題 5:跨頁切換時資料變舊(Router Cache)
症狀:寫入資料後切回列表頁,看到舊資料
原因:Client Router Cache 還沒過期
解決方案:在 mutation 後呼叫 router.refresh()
"use client";
import { useRouter } from "next/navigation";
const router = useRouter();
await createPost(data);
router.refresh(); // 重新拉 RSC,繞過 Router Cache
總結
核心要點
渲染模式 = Static / Dynamic / PPR
快取層 = Request Memo + Data Cache + Full Route Cache + Router Cache
設計原則:
- ✅ 預設 Server Component,需要互動才加
"use client" - ✅ 預設快取,需要即時才
cache: "no-store" - ✅ 慢的子樹用
<Suspense>包,啟用 Streaming - ✅ Mutation 後用
revalidateTag/revalidatePath清快取
渲染模式決策表
| 場景 | 用哪種 |
|---|---|
| 部落格、文件、行銷頁 | Static |
| 個人化儀表板 | Dynamic |
| 商品頁(半個人化) | PPR / ISR |
| 即時聊天訊息列表 | Dynamic + revalidate: 0 |
| CMS 驅動內容 | Static + tag revalidate |
快取設定速查
// 永久快取(預設)
fetch(url);
// 不快取(每次重打)
fetch(url, { cache: "no-store" });
// 60 秒重新驗證
fetch(url, { next: { revalidate: 60 } });
// 加 tag,可手動失效
fetch(url, { next: { tags: ["posts"] } });
// 手動失效
revalidateTag("posts");
revalidatePath("/posts");
相關閱讀
- Next.js 入門指南 — 先打底
- Server Actions 與表單 — Mutation 後如何失效快取
- 進階路由 — Parallel Routes / Middleware
- 資產與效能 — 配合 next/dynamic 進一步 code split
- 部署與 Runtime — Edge 環境下的快取行為
參考資源
- 官方渲染文件:https://nextjs.org/docs/app/building-your-application/rendering
- 官方快取文件:https://nextjs.org/docs/app/building-your-application/caching
- PPR 介紹:https://nextjs.org/docs/app/api-reference/next-config-js/ppr
- React Server Components 規格:https://github.com/reactjs/rfcs/blob/main/text/0188-server-components.md
建立日期:2026-05-16 最後更新:2026-05-16