FE Day 17 Next.js 起步:App Router 與檔案結構
執行需求:CPU 可跑。今天正式進入 Next.js 篇章。前面十六篇你用純 React 加 Vite 學會了元件、狀態、Hook、樣式、測試、專案結構,這些觀念在 Next.js 完全沿用;多出來的是「框架接管了路由、打包、開發伺服器、部署介面」。從今天起,往後四篇會把焦點放在 App Router:怎麼用檔案系統定義路由、怎麼區分 Server Component 與 Client Component、怎麼用四個約定檔(layout.tsx、page.tsx、loading.tsx、error.tsx)把畫面包起來。我們一步步把昨天的純 React 專案搬進 Next.js 15,跑得起來再談細節。
引言
很多後端工程師第一次碰到 Next.js 會被兩個東西嚇到:第一個是「App Router 跟舊的 Pages Router 差很多」,第二個是「Server Component 跟我熟悉的 React 不一樣」。Pages Router 是 2016 年發布的版本(pages/ 底下每支 .tsx 就是一個路由,元件預設是 Client Component,用 getServerSideProps 或 getStaticProps 處理 SSR),App Router 是 2023 年發布的版本(app/ 底下用 page.tsx、layout.tsx 等約定檔,元件預設是 Server Component,用 "use client" 切到 Client)。這個系列從 Day 17 起「只談 App Router」,舊的 Pages Router 不再展開。為什麼?因為官方在 Next.js 13 之後就把 App Router 設為預設,Pages Router 雖然還能跑,但新教學、新範例、新工具鏈都圍繞 App Router 設計。
本篇會做七件事:第一,說明 App Router 的「檔案系統即路由」觀念;第二,解釋 Server Component 與 Client Component 的邊界;第三,示範 create-next-app 建立專案;第四,把根目錄的 layout.tsx、page.tsx、loading.tsx、error.tsx 寫成完整範本;第五,解釋 "use client" 怎麼放、放哪裡;第六,把昨天的 features/ 結構搬進來;第七,示範怎麼把 React 19 的 useState 元件接上 App Router。整篇閱讀時間約 25 分鐘,動手做大約 30 分鐘(包含建立專案、等裝套件)。
為什麼選 App Router 而不是 Pages Router
Pages Router 把「每個路由一支檔案」這件事做得直覺:pages/about.tsx 就是 /about。但它在三個地方遇到瓶頸:第一,共用佈局很囉嗦,要用 _app.tsx、getLayout 或 HOC(Higher-Order Component)一層一層套;第二,資料取得分兩種(getServerSideProps SSR、getStaticProps SSG),中間還有 ISR(Incremental Static Regeneration)這種半生不熟的選項,新手難以抉擇;第三,所有元件都打包到 client bundle,無論它實際上只在伺服器跑,結果首屏 JavaScript 太大、互動時間拉長。
App Router 解決這三個問題的方式是「把 React Server Components 變成預設」。Server Component 在伺服器端渲染、不會送到瀏覽器,因此可以放心地 import 大型套件(例如 date-fns、chart.js)而不用擔心 bundle 變胖。資料取得改成「在元件裡 await fetch(...)」這種直觀寫法;快取、重新驗證、串流都由 React 19 的延伸 runtime 自動處理。佈局改成「layout.tsx 自動包下層」的巢狀結構,不再需要 HOC。
除了這三個技術問題,App Router 還順手解決了「SEO」與「首屏時間」。Pages Router 的 SSR 雖然能產生 HTML,但每個請求都要打後端、抓資料、組裝;SSG 雖然能預先生成,但不能處理「個人化內容」(例如「嗨,{使用者名稱}」這種要看 cookie 才能決定的 UI)。App Router 的 Server Component 把這兩件事合併:靜態部分 build time 預渲染、動態部分 request time 處理,框架自動分流。這對應到 FastAPI 的樣本渲染加動態資料混合,但全部用 React 慣用語法寫。
對後端工程師來說,App Router 比較接近你熟悉的 SSR 思維:每個請求進來,伺服器把元件渲染成 HTML 回傳;Client Component 負責「掛上去之後的互動」。這跟 FastAPI 的 Jinja2 範本很像,只是範本是 React 寫的,而且同一支檔案可以選擇「只在伺服器跑」或「也送到瀏覽器跑」。
Server Component 與 Client Component 的邊界
App Router 的元件分成兩種:Server Component 與 Client Component,兩者的差別不是「哪個跑得快」,而是「跑在哪裡」。
Server Component 在伺服器上執行,渲染完成後只把 HTML 與少量的 RSC payload(React Server Components 的序列化資料)送到瀏覽器。它不能使用 React 的互動 API:沒有 useState、沒有 useEffect、沒有 onClick,但可以直接 await fetch(...)、可以直接讀後端 API、直接讀資料庫。預設所有元件都是 Server Component。
Client Component 在瀏覽器上執行,能使用 React 所有互動 API。它會被打包成 JavaScript、下載到瀏覽器、執行 hydrate(把 Server 渲染的 HTML 接手變成可互動)。要讓一個元件變成 Client Component,只在檔案最上面加 "use client":
// src/components/counter/Counter.tsx
// Client Component:檔案頂端宣告 "use client" 即可
"use client";
import { useState } from "react";
export function Counter({ initial = 0 }: { initial?: number }) {
const [count, setCount] = useState(initial);
return (
<button
type="button"
onClick={() => setCount((c) => c + 1)}
className="rounded bg-slate-900 px-3 py-1 text-white"
>
已按下 {count} 次
</button>
);
}
宣告 "use client" 之後,這支檔案「與它遞迴 import 的所有模組」都會被打包進 client bundle。所以 "use client" 要放得精準:放在「需要 client 能力的最小範圍」。把宣告放在 app/page.tsx 會讓整個首頁變成 client;放在 Counter.tsx 只讓這顆按鈕變成 client。原則是「葉節點才標 use client,根元件維持 Server」。
實務上常見的分界線是「互動」:表單、計數器、開合選單、視窗、對話框這種「使用者會動到的」要 Client;純展示、純清單、純文字內容保持 Server。一個 booking 預約系統為例,「預約清單頁」可以整頁 Server,「按下『取消預約』的小按鈕」要 Client。
完整實作:用 create-next-app 起步
實際動手最快。我們用官方 create-next-app 從零建一個專案,所有選項都用「App Router + TypeScript + Tailwind + ESLint + src 目錄」這套主流預設:
# 官方腳手架:用 App Router 預設建立
pnpm create next-app@latest booking-fe --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
# 進入專案、安裝其餘依賴
cd booking-fe
pnpm add clsx
建立完的目錄結構是這樣(節錄):
booking-fe/
├── src/
│ ├── app/
│ │ ├── layout.tsx # 根 layout:包所有頁面
│ │ ├── page.tsx # 首頁 / 路由
│ │ ├── globals.css # Tailwind 入口
│ │ └── favicon.ico
│ ├── components/ #(自己新增)UI 元件
│ ├── features/ #(自己新增)業務功能
│ ├── hooks/ #(自己新增)共用 Hook
│ └── lib/ #(自己新增)純函式工具
├── public/
├── eslint.config.mjs
├── next.config.ts
├── package.json
├── postcss.config.mjs
├── tailwind.config.ts
└── tsconfig.json
腳手架產生的 src/app/ 只放了 layout.tsx、page.tsx、globals.css 三支。我們把昨天的 features/ 結構搬進來,並把約定檔補齊。先看預設的 layout.tsx 長什麼樣:
// src/app/layout.tsx
// 根 layout:所有頁面都會被這層包起來
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "預約管理系統",
description: "內部使用的預約管理前端",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-Hant">
<body className="min-h-screen bg-slate-50 text-slate-900 antialiased">
{children}
</body>
</html>
);
}
RootLayout 是約定名稱,框架會自動抓這支作為整個 App 的根容器。它必須回傳包含 <html> 與 <body> 的 JSX,並用 {children} 保留子層的渲染空間。metadata 物件會被框架自動讀取,生成 <title> 與 <meta> 標籤,這個寫法比 Pages Router 的 <Head> 集中很多(Day 23 會展開 metadata)。
接著寫首頁 page.tsx。它直接是 Server Component,可以 await 資料。這裡我們先寫一個「從 mock 抓預約清單」的範例:
// src/app/page.tsx
// Server Component:可以直接 await 後端或 mock 資料
import { listBookings } from "@/lib/api";
import { Counter } from "@/components/counter/Counter";
export default async function HomePage() {
const bookings = await listBookings();
return (
<main className="mx-auto max-w-3xl px-6 py-12">
<h1 className="text-3xl font-bold">預約管理</h1>
<p className="mt-2 text-slate-600">今天有 {bookings.length} 筆預約。</p>
<section className="mt-8">
<h2 className="text-xl font-semibold">互動測試</h2>
<Counter />
</section>
</main>
);
}
這個範例把兩件事放在同一個檔案:「Server Component 拿資料」與「嵌入 Client Component」。注意 Counter 是 "use client" 元件,HomePage 本身沒宣告 "use client",所以它是 Server;Server 可以直接 render Client,這是 Next.js 14/15 預設就支援的混合模型。
接下來看 loading.tsx 與 error.tsx 怎麼寫。loading.tsx 在 page.tsx 等待資料時自動顯示:
// src/app/loading.tsx
// 約定檔:資料讀取期間顯示,自動被 Suspense 包起來
export default function Loading() {
return (
<div className="mx-auto max-w-3xl px-6 py-12">
<p className="animate-pulse text-slate-500">載入中,請稍候…</p>
</div>
);
}
error.tsx 在 page.tsx 拋出錯誤時自動顯示——但它「必須」是 Client Component,因為它需要拿到 reset 函式:
// src/app/error.tsx
// 約定檔:錯誤邊界;必須是 Client Component
"use client";
import { useEffect } from "react";
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<div className="mx-auto max-w-3xl px-6 py-12">
<h2 className="text-2xl font-bold text-red-700">發生錯誤</h2>
<p className="mt-2 text-slate-700">{error.message}</p>
<button
type="button"
onClick={() => reset()}
className="mt-4 rounded bg-slate-900 px-3 py-1 text-white"
>
重試
</button>
</div>
);
}
注意 error.tsx 必須包在 Client Component,因為它接收的 reset 函式需要在瀏覽器上執行。如果你把 error.tsx 寫成 Server Component,編譯時就會跳出「error components must be Client Components」的錯誤。
第四個約定檔 not-found.tsx 在「找不到路由」時顯示:
// src/app/not-found.tsx
// 約定檔:當使用者存取不存在的路由時顯示
import Link from "next/link";
export default function NotFound() {
return (
<main className="mx-auto max-w-3xl px-6 py-12">
<h2 className="text-3xl font-bold">找不到頁面</h2>
<p className="mt-2 text-slate-600">這個連結已經失效或被移除。</p>
<Link href="/" className="mt-4 inline-block text-blue-700 underline">
回首頁
</Link>
</main>
);
}
四個約定檔到位之後,整個根目錄就有了完整的「載入中/錯誤/找不到/正常」四種狀態。當使用者點開首頁時,框架會根據 page.tsx 的 promise 自動決定要顯示哪一個——不用寫一行 try/catch,也不用 isLoading state。
把昨天的 features/ 結構搬進來
昨天在純 React 加 Vite 蓋的分層結構可以直接搬到 Next.js,只要區分清楚「src/app/ 放路由」「src/features/ 放業務」「src/components/ 放通用 UI」:
src/
├── app/ # 路由層:每個資料夾 = 一段路徑
│ ├── layout.tsx # 根 layout
│ ├── page.tsx # /
│ ├── loading.tsx
│ ├── error.tsx
│ ├── not-found.tsx
│ └── bookings/
│ ├── page.tsx # /bookings
│ └── [id]/page.tsx # /bookings/:id(Day 18)
├── components/ # 通用 UI 元件(純展示)
├── features/ # 業務功能
│ └── booking/
│ ├── components/
│ ├── hooks/
│ ├── api.ts
│ └── types.ts
├── hooks/ # 跨功能共用 Hook
├── lib/ # 純函式工具
└── styles/
└── globals.css
這套結構的關鍵是「app/ 只放路由檔」:layout.tsx、page.tsx、loading.tsx、error.tsx、not-found.tsx,以及未來的 route.ts(API 路由,Day 24 會講)。任何複雜的元件、Hook、工具函式都放進 features/booking/ 或 components/。app/bookings/page.tsx 通常只有十幾行——讀資料、組裝元件,不放商業邏輯。
"use client" 的放置策略
新手最常犯的錯是把 "use client" 放在 app/page.tsx 開頭。一旦這樣做,整個首頁的 subtree 都會被打包進 client bundle,等於「自動放棄 Server Component 的好處」。正確的策略是「沿著資料流往下找,最靠近 useState / onClick / useEffect 的那支檔案才標」。
一個實用的判斷法:打開 features/booking/components/BookingForm.tsx,如果它用了 useState 控制輸入框,那就在它頂端加 "use client";如果同一個目錄下的 BookingSummary.tsx 只是顯示訂單摘要、沒任何 hook 或事件,那就不要加。框架會把 BookingSummary 留在 Server,只把 BookingForm 與它的依賴送到瀏覽器。
另外,「"use client" 不能跨越 Server Component 邊界 import」這條規則要記住。如果你有一支 Server Component(沒 "use client")想要 import 一支 Client Component(標了 "use client"),這完全合法;反過來,Client Component 不能 import Server Component 進來——這是因為 Server Component 用了 server-only 的能力(例如讀檔案系統),瀏覽器端根本沒辦法執行。編譯時 Next.js 會直接擋下這種 import。
還有一個常見的誤用是「為了用 onClick 把整個表單元件都標 client」。實際上更細緻的做法是「表單容器保持 Server、把『按鈕』抽成 client 小元件」。例如一個預約表單 BookingForm.tsx 內含 10 個欄位,只有「選擇日期」的日期選擇器需要 useState 控制開合——那就把日期選擇器抽成 DatePicker.tsx 並標 "use client",表單本體留在 Server。這個分割的好處是「client bundle 只包含真的需要互動的程式碼」。
順道提一下「"use server"」這個反向標記。"use client" 把檔案標為 client,"use server" 把函式標為「只能在伺服器執行」。當你寫一個表單送出後要觸發的函式(Day 21 會展開),在函式本體頂端加 "use server",框架就會保證這個函式永遠不會被打包進 client bundle,前端只能呼叫、不會看到內容。這對「寫資料庫」「打內部 API」特別有用。實務上兩種標記常常配對出現:表單元件標 client(需要 useState)、送的 action 標 server(執行寫入動作)。
啟動 dev server 與觀察行為
裝好之後跑 pnpm dev,瀏覽 http://localhost:3000,應該會看到首頁標題與計數器。這個過程中觀察三件事能幫你快速理解 App Router 的運行方式。
第一件事是「終端機的編譯訊息」。第一次存取首頁時,console 會出現「Compiling /…」字樣,這是 Next.js 15 的 RSC 編譯器在把 Server Component 編譯成可執行的 runtime。同一個路由再存取就不會出現,因為結果已經快取。build 模式(pnpm build && pnpm start)則在 build time 就把所有路由編譯好,第一次存取不再有等待。
第二件事是「瀏覽器的原始 HTML」。在首頁按右鍵「檢視網頁原始碼」,會看到完整的 HTML 結構,包括 <h1>預約管理</h1> 與 {bookings.length} 筆預約 的實際數字。這證明 Server Component 確實在伺服器端跑完、把結果吐成 HTML——跟 Pages Router 的 SSR 很像,但更乾淨。對比 React 純 CSR(client-side rendering)的應用,原始 HTML 只會看到空殼、所有內容由 JavaScript 注入。
第三件事是「client bundle 的內容」。打開 DevTools 的 Network 面板,過濾 .js 檔,會看到一支支被打包的 chunk。注意:標了 "use client" 的 Counter.tsx 與它遞迴 import 的 React 模組會被打包,但「booking 清單資料」「listBookings 函式」「API base URL」不會出現在任何 client bundle。這就是「Server Component 不送 JavaScript」的視覺化驗證,也是對後端工程師最關鍵的安全保證——API 網址、金鑰、環境變數都不會外洩。
常見錯誤與踩雷
第一個雷是「在 layout.tsx 裡寫 "use client"」。RootLayout 是整個應用的根,一旦標成 client,所有頁面都變 client bundle,Server Component 的優勢直接消失。修法是維持 Server,並把互動元件抽到 "use client" 的子元件。
第二個雷是「error.tsx 沒寫 "use client"」。框架的錯誤邊界機制要求錯誤元件是 client,因為需要接收瀏覽器端的 reset callback。忘記加 "use client" 會得到「Error: error components must be Client Components」的編譯錯誤。順道一提,global-error.tsx 才是真的包整個 App 的錯誤邊界,它必須包含自己的 <html> 與 <body>,因為根 layout 在它出錯時可能也壞了。
第三個雷是「把 fetch 寫在 useEffect 裡」。在 App Router 裡,Server Component 直接 await fetch(...) 比 useEffect 內呼叫好太多——前者不用送到 client bundle、不用處理 isLoading、SSR 直接拿到結果。只有「使用者操作之後才需要新資料」(例如點按鈕刷新)才在 Client Component 用 useEffect 或 useSWR。
第四個雷是「忘記 import 對應的 CSS」。Tailwind 4 的設定跟 Tailwind 3 不一樣:CSS 入口在 globals.css,裡面要 @import "tailwindcss";。沒 import 的話整個 Tailwind 不會生效,按鈕、間距都沒有樣式。檢查方式:開瀏覽器 DevTools 看 <button class="bg-slate-900"> 有沒有真的套到背景色。
第五個雷是「next.config.ts 的 experimental 寫錯」。Next.js 15 已經把 Server Actions、typedRoutes、serverActions 等旗標從 experimental 移到正式設定;用舊版範例直接複製貼上會跳警告。建議從頭跑一次 create-next-app 看官方預設,再依需求微調。
效能與實務提醒
Server Component 對 bundle size 的影響是直接且可量化的。一個 booking 清單頁如果整頁寫成 Client Component,bundle 可能含 200KB 以上的 JavaScript;如果切成「清單本身 Server、單筆卡片 Client」,bundle 可能降到 30KB。對 Core Web Vitals 的 LCP(Largest Contentful Paint)與 TTI(Time to Interactive)都是大幅改善。實務上量測可以跑 next build 看 .next/analyze 的輸出,或裝 @next/bundle-analyzer。
另一個常被忽略的細節是「"use client" 的 transitive effect」。如果你在 components/Counter.tsx 標了 "use client",那麼這支檔案 import 的所有東西(包括 clsx、date-fns)都會被打包進 client bundle。所以「client 元件依賴的函式庫要小」,不要在 client 端 import 整包 moment、lodash——這類工具盡量放在 lib/ 裡當 Server-only 工具,或選用 tree-shake 友善的 ESM 版本。
最後是 dev server 的行為差異。pnpm dev 之後 Next.js 會在第一次請求某個路由時編譯它,第一次載入會比較慢(看 console 會看到「Compiling /bookings…」)。這是 Fast Refresh 與 RSC 的必要成本,正式環境(pnpm build && pnpm start)就會全部預編譯。如果你想縮短 dev 啟動時間,可以把不需要的路由先寫成簡單的 placeholder,之後再展開。React 19 的 useFormState、useFormStatus(Day 21 會講)這些 API 也都是 App Router 的 Server Actions 配套,最好一起裝好。
小結
今天我們把 Next.js 15 App Router 的起步工作做完,重點有三個層次。第一個層次是「為什麼」:App Router 把 React Server Components 變成預設,解決 Pages Router 的三個瓶頸(共用佈局囉嗦、資料取得選項太多、所有元件都進 client bundle);後端工程師對這個模型的接受度通常很高,因為它接近「在伺服器上 render 樣板」的傳統思維。第二個層次是「邊界」:預設元件是 Server Component,能用 await 讀資料;需要 useState、onClick 才在檔案頂端加 "use client",葉節點優先。第三個層次是「約定檔」:layout.tsx、page.tsx、loading.tsx、error.tsx、not-found.tsx 五個檔名是框架認得的特殊檔,放對位置框架就會自動處理對應的狀態,不用自己寫 Suspense 或錯誤邊界。
這套模型的副作用是「結構決定 bundle」:app/ 越瘦、features/ 越肥、"use client" 越集中在葉節點,最後的 bundle 就越小。把昨天的分層結構直接搬進來,搭配四個約定檔,今天就能跑出一個「能加新頁面、能擴充業務」的最小 Next.js 專案。Day 18 我們會把這個骨架長出實際的巢狀路由、動態路由與載入狀態,展現 App Router 在「多頁面 + 共享 layout + 動態參數」上的威力。
結語
明天,我們會正式進入 Day 18「路由:巢狀、動態與載入狀態」。我們會在昨天蓋好的 booking-fe 上加 /bookings、/bookings/[id]、/bookings/new 三段路由,並用巢狀 layout 把「側邊欄 + 內容區」的結構重現。你會看到 loading.tsx 在哪一層放就只影響哪一層、error.tsx 的錯誤邊界怎麼隔離、route group(用 (name) 命名的資料夾)怎麼在「不改網址」的前提下切換 layout。Day 18 結束時,你會有一個能承載多頁面的完整骨架。
延伸資源
- Next.js 官方 App Router 基礎:
https://nextjs.org/docs/app/getting-started - React Server Components 官方 RFC:
https://github.com/reactjs/rfcs/blob/main/text/0188-server-components.md - Next.js 15 release notes(Server Actions 穩定、
typedRoutes正式):https://nextjs.org/blog/next-15 - create-next-app 官方選項說明:
https://nextjs.org/docs/app/api-reference/create-next-app - Tailwind CSS 4.x 官方起手式:
https://tailwindcss.com/docs/installation/framework-guides/nextjs
留言
張貼留言