FE Day 19 Server Components 與資料取得
執行需求:CPU 可跑。今天是「前端開發實戰:React 與 Next.js 全套」系列的第十九篇,我們把 App Router 最重要的觀念攤開來看——Server Component 怎麼拿資料、為什麼要這樣設計、四種快取策略怎麼選、以及怎麼把真實的 HTTP 呼叫收進一個好維護的資料層。延續前兩天蓋好的 booking-fe 專案,我們會把 fetch 包進 src/lib/api.ts、把「拿單筆預約」「拿預約清單」「拿使用者 session」三個動作變成可組合的函式,並讓 page.tsx 直接 await。整篇範例不依賴真實後端,本機用 mock 資料就能跑,串接 Web 系列的 FastAPI 只要換 base URL。
引言
前端工程師剛接觸 Next.js 的 Server Component 時,最常問的問題是:「這不就是回到 SSR 了嗎?」答案接近「對,但不只是 SSR」。SSR(Server-Side Rendering,伺服器端渲染)在 React 的脈絡裡指的是「在伺服器上把 HTML 產出來,再送到瀏覽器」。Server Component 更進一步:元件本身在伺服器上執行,連 JavaScript bundle 都不送給瀏覽器。這意味著兩件事——伺服器可以直接讀資料庫、打後端 API、讀環境變數、讀檔案系統;瀏覽器只收到渲染好的 HTML 與極少量的互動程式碼。
這個模型對「後端工程師」其實非常友善:你寫 React 元件的方式跟在後端寫函式差不多,可以直接 await、可以直接拿環境變數、不需要寫 Redux 或 React Query。Next.js 在 build time 自動把 Server Component 與 Client Component 拆成兩個 bundle,伺服器 bundle 永遠不會送到瀏覽器,client bundle 也不會被伺服器執行。兩邊的邊界由 "use client" 標記決定,沒寫的就是 Server Component。
今天的目標有四個:第一,把 Server Component 的「為什麼」講清楚,包含它對 bundle size 與資料來源的影響;第二,建立 src/lib/api.ts 這個資料層,把所有 fetch 集中管理;第三,搞懂 Next.js 15 的四種快取策略(default、no-store、force-cache、revalidate)怎麼選;第四,把 <Suspense>、loading.tsx 與錯誤邊界組合成「streaming + 優雅失敗」的標準模式。讀完之後你應該能在 booking-fe 上把任何一個頁面切換成「伺服器拿資料、客戶端拿互動」的標準分工。
值得一提的是,這套 Server Component 模型跟後端的「樣板渲染(template rendering)」或 Django 的 MTV 模式很像:view 函式組裝資料、樣板把資料 render 成 HTML,差別只是 Next.js 把「樣板」換成了「React 元件」。如果你寫過 Django 與 Jinja2,會對這個分工感到熟悉;差別在 React 把渲染拆成多個元件、可以組合,這是 Jinja2 的 {% include %} 與 React 的 <Component /> 差異所在。
為什麼 Server Component 是後端工程師的禮物
寫過後端的人對「資料存取」有一個根深柢固的直覺:直接呼叫、不用經過序列化、不用思考「現在到底在哪個環境」。Server Component 把這個直覺還給你。一個典型的 Server Component 長這樣:
// src/app/bookings/page.tsx
// 純 Server Component:await 後端、把結果 render 成 HTML
import { listBookings } from "@/lib/api";
export default async function BookingsPage() {
const bookings = await listBookings();
return (
<section>
<h1 className="text-2xl font-bold">預約清單</h1>
<ul className="mt-4 divide-y divide-slate-200">
{bookings.map((b) => (
<li key={b.id} className="py-3">
<span className="font-mono text-xs text-slate-500">{b.id}</span>
<span className="ml-3">{b.customerName}</span>
<span className="ml-3 text-slate-500">{b.time}</span>
</li>
))}
</ul>
</section>
);
}
注意這支檔案沒有 "use client",所以它跑在伺服器上。listBookings() 是一個普通的 async 函式,內部用 fetch 打後端 API;因為 Server Component 在 server 端 await,這個 fetch 不會變成前端 bundle 的一部分,瀏覽器也看不到 API 網址。這對「預約系統要打公司內部 API」這種情境特別友善——你不用擔心金鑰外洩、不用做 BFF(Backend-for-Frontend)層、不用為了隱藏 API 網址而多做一層 proxy。
更細的價值是「bundle size」。Client Component 寫得越多,瀏覽器要下載的 JavaScript 越多;Server Component 寫得越多,瀏覽器要下載的 JavaScript 越少(因為大部分邏輯都跑在 server 端)。在後台介面、登入後的頁面、純展示頁面(部落格、說明頁)這些「不需要 client 互動」的場景,能用 Server Component 就用 Server Component,這條原則在 Next.js 社群被反覆驗證過,是最佳化 Core Web Vitals 的第一步。對照到後端的經驗,這就像「template 渲染 vs API 輸出」——前者把 HTML 直接組好送出,後者把資料 JSON 化讓前端再組,Server Component 的策略更接近前者,但保留了元件組合的彈性。
建立資料層:src/lib/api.ts
把 fetch 直接寫在每個 page.tsx 裡會很快失控:base URL 散落各處、錯誤處理不一致、快取策略各寫各的、測試時難以 mock。一個常見的做法是把所有 HTTP 呼叫收進一支 src/lib/api.ts:
// src/lib/api.ts
// 集中所有對後端的呼叫:統一 base URL、快取策略、錯誤處理
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:8000";
export type Booking = {
id: string;
customerName: string;
time: string; // ISO 8601 字串
status: "pending" | "confirmed" | "cancelled";
};
export class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
this.name = "ApiError";
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
...init,
headers: { "Content-Type": "application/json", ...init?.headers },
});
if (!res.ok) {
throw new ApiError(res.status, `${path} 失敗:${res.statusText}`);
}
return (await res.json()) as T;
}
// 清單:靜態內容,預設快取 60 秒
export function listBookings() {
return request<Booking[]>("/api/bookings", {
next: { revalidate: 60, tags: ["bookings"] },
});
}
// 單筆:動態內容,不要快取(每次都拿最新)
export function getBooking(id: string) {
return request<Booking>(`/api/bookings/${id}`, { cache: "no-store" });
}
// 建立預約:POST,永遠不要快取
export function createBooking(payload: Omit<Booking, "id">) {
return request<Booking>("/api/bookings", {
method: "POST",
body: JSON.stringify(payload),
cache: "no-store",
});
}
幾個重點說明:
API_BASE讀環境變數NEXT_PUBLIC_API_BASE,這是 Next.js 內建給瀏覽器可讀變數的命名空間(NEXT_PUBLIC_開頭)。本機開發時若沒設,fallback 到http://localhost:8000,剛好對應 Web 系列的 FastAPI 預設 port。request<T>是一個泛型函式,用 TypeScript 的泛型把回傳型別推回去,省去每個函式都要手寫 cast。listBookings用next.revalidate設 60 秒快取,並用tags: ["bookings"]標記——之後可以用revalidateTag("bookings")在 Server Action 裡主動失效(Day 21 會用到)。getBooking與createBooking明確設cache: "no-store",因為它們處理的是「單筆最新狀態」與「建立動作」,不應該被快取。
資料層還可以加一些延伸設計。例如把 request() 包成支援「自動重試」「分散追蹤(trace ID 注入 header)」「結構化 logging」的版本;或者把「後端錯誤」與「網路錯誤」分成兩個 Error class(ApiError 與 NetworkError),讓上層能針對不同錯誤決定要不要 fallback 到預設資料。對應到後端的「service 層」設計,這個 api.ts 就是 service 層的簡化版,之後 Day 36 串接 FastAPI 時可以再擴充。
Next.js 15 的四種快取策略
Next.js 對 fetch() 的快取處理經過幾次大改,到 15 版大致穩定成四種策略。光看 cache 與 next.revalidate 兩個欄位的組合,就能涵蓋大部分情境:
| 場景 | 寫法 | 行為 |
|---|---|---|
| 靜態內容(部落格、說明頁) | 不帶 cache 與 next,或 cache: "force-cache" |
Build time 取一次,之後每個使用者都拿到同一份 |
| 需要定期刷新(最新消息、商品清單) | next: { revalidate: 60 } |
第一次請求時 fetch,60 秒內的後續請求走快取 |
| 每次都要最新(使用者個人資料、購物車) | cache: "no-store" |
每次請求都打 API,不快取 |
| Server Action 觸發的失效 | next: { tags: ["bookings"] } + revalidateTag("bookings") |
平常照 revalidate 規則;動作發生時主動清掉標籤對應的快取 |
實務上的選擇口訣:「公開資料用快取,使用者資料不用快取」。listBookings 對所有登入使用者顯示的預約清單是公開資料,60 秒內重新整理就走快取;getBooking 是「我自己的訂單詳情」,每個人看到的內容不同(後端依 cookie 過濾),必須 no-store。這條口訣對應到後端快取設計就是「不要把使用者相關的回應放進共用快取」,是基本的存取控制常識。
另一個常被忽略的細節:「快取是發生在 build time 還是 request time」取決於這個頁面是「靜態」還是「動態」。一個完全沒讀 cookies()、headers()、searchParams 的 Server Component 會被 Next.js 在 build time 預先渲染;一旦你在 async 函式裡讀了任何 runtime API,那一頁就自動變成 dynamic rendering,revalidate 也跟著失效。Day 17 提過這個機制,這裡要記得:force-dynamic 雖然能用,但已經不是推薦寫法;正確做法是「讓函式讀 runtime API,讓 Next.js 自己推導」。
tags-based invalidation 是 15 版後的新重點。一旦 fetch 帶了 tags: ["bookings"],之後任何地方呼叫 revalidateTag("bookings")(包括另一個 fetch、另一個 Server Action、另一個 Route Handler)都會主動把這個標籤對應的快取清掉。這對「建立預約後希望清單立刻刷新」的場景非常有用:建立動作裡 revalidateTag("bookings"),下一次任何頁面拿清單都會走新鮮資料,不必等 60 秒到。這條對應到後端的「事件驅動快取失效」,是 Redis 或 memcached 的標準技法。
Streaming 與 Suspense:分區載入的標準模式
Day 18 講過 loading.tsx 是 Suspense fallback,但實際專案會希望「頁面有多個區塊,各自獨立載入」。例如預約清單頁有「今日預約」「本週預約」「本月預約」三個區塊,這三個區塊互相不依賴,但都打後端——如果整頁 await 完才一次顯示,TTFB 會被最慢的那個 fetch 拖住。React 18 之後的 streaming 把這個問題解掉了:每個區塊各自是 async 元件,框架會「準備好一個就送一個」。
// src/app/bookings/_components/BookingSection.tsx
// 可獨立 streaming 的區塊:每個區塊都有自己的 loading 邊界
import { listBookings } from "@/lib/api";
import { Suspense } from "react";
import { BookingsSkeleton } from "./BookingsSkeleton";
async function TodayBookings() {
const bookings = await listBookings(); // 實務上會帶 ?range=today
return (
<section>
<h2 className="text-lg font-semibold">今日預約</h2>
<ul>{bookings.map((b) => <li key={b.id}>{b.customerName}</li>)}</ul>
</section>
);
}
export function BookingSection() {
return (
<Suspense fallback={<BookingsSkeleton />}>
<TodayBookings />
</Suspense>
);
}
// src/app/bookings/_components/BookingsSkeleton.tsx
// 共用的骨架畫面:對應「東西還在跑」的 UI
export function BookingsSkeleton() {
return (
<div className="space-y-2" aria-busy="true" aria-live="polite">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-8 animate-pulse rounded bg-slate-100" />
))}
<span className="sr-only">預約載入中</span>
</div>
);
}
幾個關鍵點:
TodayBookings是 async 函式,內部 await——React 會把它視為「可以暫停」的元件,自動套進 Suspense 邊界。- 外層
<Suspense fallback={...}>提供 fallback UI;當TodayBookings還在 await 時,使用者先看到BookingsSkeleton,完成後自動替換。 aria-busy="true"與aria-live="polite"告訴輔助技術「這個區塊正在載入」。Day 28 會展開無障礙。- 檔名
_components/開頭底線是 App Router 的約定:「這個資料夾是私有輔助區,不 對應 URL」。你可以放任何不想被當成頁面的檔案在這裡。
這套「每個區塊獨立 streaming」的設計對真實專案特別有感。想像預約後台的首頁有四個區塊:今日預約、本週統計、未確認訂單、最近活動——四個區塊都打不同的後端 API。沒有 streaming 時,整頁的 TTFB 是「四個 fetch 全部完成」的時間;有了 streaming 之後,瀏覽器一收到任何一個區塊的 HTML 就立刻渲染,使用者看到第一塊內容的時間從「最慢那塊」壓到「最快那塊」。對使用者體驗的差距通常是數百毫秒到數秒,在 Lighthouse 分數上會直接反映在 LCP 與 TBT 兩個指標。
錯誤邊界:把失敗變成「優雅的降級」
Server Component 拿資料失敗時,預設會把整頁炸成 500。實務上你會希望「某個區塊壞了不要拖累整頁」。error.tsx 就是為這個設計的——它是 Error Boundary 的 fallback UI,捕獲同層與子層的 throw:
// src/app/bookings/error.tsx
// 區塊級錯誤邊界:listBookings 失敗時顯示「稍後重試」
"use client";
export default function BookingsError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="rounded border border-amber-200 bg-amber-50 p-4">
<h2 className="font-semibold text-amber-700">預約資料載入失敗</h2>
<p className="mt-1 text-sm text-amber-600">{error.message}</p>
<button
onClick={reset}
className="mt-3 rounded bg-amber-600 px-3 py-1 text-sm font-medium text-white"
>
重試一次
</button>
</div>
);
}
注意 error.tsx 必須是 Client Component(檔案頂端 "use client"),因為它要接 reset 函式重新觸發 render。瀏覽器打開 /bookings,故意把 api.ts 的 API_BASE 改成不存在的網址,會看到這個 amber 區塊與「重試一次」按鈕,而 root layout 與 root error 都還在運作。
更進一步,如果後端整個掛掉、不希望使用者看到一堆區塊錯誤,可以把 api.ts 的 request() 加上 timeout 與統一 fallback:
// src/lib/api.ts(加上 timeout 的版本)
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(`${API_BASE}${path}`, {
...init,
signal: controller.signal,
headers: { "Content-Type": "application/json", ...init?.headers },
});
if (!res.ok) {
throw new ApiError(res.status, `${path} 失敗:${res.statusText}`);
}
return (await res.json()) as T;
} finally {
clearTimeout(timeout);
}
}
AbortController + signal 是標準的取消機制,setTimeout 5 秒後自動 abort,避免後端卡死時 fetch 永遠 pending。這個寫法在 Server Component 與 Route Handler 都通用,是 Day 37「錯誤處理與使用者回饋」會再展開的基礎。
常見錯誤與踩雷
第一個雷是「在 Server Component 裡呼叫瀏覽器 API」。例如在 page.tsx 直接寫 localStorage.getItem("token"),編譯時不會錯(因為 TypeScript 認得),但 server 端沒有 localStorage,runtime 會炸。對應原則:「瀏覽器 API 只在 Client Component 用,伺服器 API(環境變數、檔案系統、process)只在 Server Component 用」。真的要從 client 端拿東西送到 server,要用 cookie 或 HTTP header。
第二個雷是「async 元件忘記 await」。function Page() { return fetch(...); } 沒有 await,fetch 會直接被丟掉,等於「假裝有拿資料」。TypeScript 沒辦法抓到這個錯誤(fetch 回傳 Promise 不是 Promise 的值),只有 runtime 才會發現頁面空空的。對應修法:永遠寫 async function Page() { const data = await fetch(...); return ... }。
第三個雷是「fetch 預設快取造成的『為什麼我改了後端卻看不到』」。這在 14 版之後特別容易踩,因為預設行為從「永遠 no-store」變成「永遠 cache」。如果你在 dev 環境改了後端 API,畫面卻沒更新,第一件事是檢查 request() 有沒有設 cache: "no-store"。對應工具:next build 之後的 .next/cache/fetch-cache/ 資料夾可以看到快取檔。
第四個雷是「revalidate 設了但 searchParams 在 URL 裡」。revalidate 只對「同樣的請求」有效,如果 URL 後面帶 ?status=pending,Next.js 會把每次不同的 searchParams 視為不同的 cache key,所以快取命中率低。看起來像「revalidate 沒生效」,其實是 cache key 不一樣。對應修法:把動態參數放進 fetch URL 的 query string,或在 Server Component 裡讀 searchParams 自行組裝快取 key。
效能與實務提醒
資料層的另一個設計重點是「不要在元件裡組裝 URL」。如果你發現 page.tsx 裡直接寫 fetch(\`/api/bookings/\${id}\`),請把它搬進 api.ts。原因是:URL 組裝的邏輯(query string、encode、base URL)很容易散落各處,到時候改 API 版本號會滿地撈蝦子;集中之後可以用 TypeScript 的型別系統保證傳入參數正確、可以集中加 retry/timeout、可以集中打 log。
Server Component 的另一個效能紅利是「data colocation(資料與元件同位置)」。傳統的 React Query / SWR 模式需要「資料層 → hook → 元件」三層;Server Component 直接 await 拿資料,省掉 hook 層,bundle 也更小。代價是「client 端的樂觀更新、輪詢、infinite scroll」這類需要 client 互動的場景必須用 SWR 或 React Query。我們在 Day 14 的時候已經演練過純 client 端的資料取得;Day 31 之後的專案篇會把這兩套混搭起來。
最後提醒一件事:Server Component 的 async 函式預設會被 Next.js 自動記憶化(memoize),同一個 render pass 裡呼叫兩次 listBookings() 只會打一次後端。如果你想強制每次都打,加 cache: "no-store";如果想更精細控制快取粒度,用 unstable_cache 或 React 的 cache() 函式包一層。後者是 React 19 的標準 API,等正式說明齊備後我們會在 Day 39 效能篇展開。
另一個值得提的是「資料來源的環境變數處理」。在 api.ts 裡讀 process.env.NEXT_PUBLIC_API_BASE 時要注意:NEXT_PUBLIC_ 開頭的變數會被打包進 client bundle,等於把這個值公開。如果你想保密(內部 API 的 domain 不希望被爬),把所有對外的資料呼叫都放在 Server Component,client 只接收最終的 HTML,不要把 base URL 傳到 client。這條觀念跟後端「不要把內部服務網址暴露給前端」完全一致,是 App Router 鼓勵的設計模式。
Day 19 把 Server Component 的「拿資料」主軸打通之後,明天 Day 20 我們會進入對應的另一面:Client Component 的「互動」。useState、useEffect、瀏覽器 API、事件處理、React 19 的新 hook(useActionState、useOptimistic)都會在 Client Component 上跑,兩邊怎麼分工、怎麼傳資料、怎麼避免污染整棵樹,是建立「高效能前端」最關鍵的設計依據。
小結
今天我們把 Server Component 的資料取得攤開來看了。Server Component 直接 await、沒有 hook 層、bundle 不送到瀏覽器,是 Next.js 對後端工程師最友善的設計。我們建立了 src/lib/api.ts 集中所有 HTTP 呼叫,把 base URL、timeout、快取策略、型別、錯誤處理收在一處;搞懂了 force-cache、no-store、revalidate、tags 四種快取組合怎麼選;用 Suspense、loading.tsx、error.tsx 三件式組出 streaming + 優雅失敗的標準模式。明天我們會進入 Day 20,把焦點轉到 Client Component——什麼時候該用、什麼時候不該用、怎麼跟 Server Component 搭配。
結語
今天我們把資料取得的主軸打通:Server Component 拿資料、api.ts 管細節、Suspense + loading + error 處理使用者體驗。明天,我們會把鏡頭轉到 Client Component:在「需要 useState、useEffect、瀏覽器 API、事件處理」的場景下,怎麼宣告 "use client"、怎麼把資料從 Server Component 傳到 Client Component、怎麼避免常見的「把整棵樹污染成 client」反模式。Day 20 結束時,你應該能在同一個頁面裡精準地切割 server 與 client 的邊界。
延伸資源
- Next.js 官方 Data Fetching(15.x,2026 年 3 月):
https://nextjs.org/docs/app/getting-started/data-fetching - Next.js 官方 Caching(15.x):
https://nextjs.org/docs/app/deep-dive/caching - React 官方 Server Components 完整介紹:
https://react.dev/reference/rsc/server-components - Next.js 官方 Streaming 與 Suspense:
https://nextjs.org/docs/app/getting-started/loading-ui-and-streaming - MDN AbortController 與 fetch signal:
https://developer.mozilla.org/en-US/docs/Web/API/AbortController
留言
張貼留言