Next.js 渲染與快取

Next.js App Router 渲染模型與四層快取完整解析 — Server/Client Components、Streaming、PPR、revalidate


目錄


什麼是 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()
  • searchParams
  • fetch(..., { 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
// 預設:在 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 沒變

排查

  1. 確認 next: { revalidate: N } 寫對位置(在 fetch options 內,不是頂層 export)
  2. ISR 需要該路徑被存取至少一次才會背景重生
  3. 開發環境可能行為不同,務必到 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");

相關閱讀


參考資源


建立日期:2026-05-16 最後更新:2026-05-16

🔗相關文章