FE Day 18 路由:巢狀、動態與載入狀態
執行需求:CPU 可跑。今天是「前端開發實戰:React 與 Next.js 全套」系列的第十八篇。昨天我們把 Next.js 15 App Router 起步了,知道了 Server/Client Component 的邊界、五個約定檔的位置。今天要進入路由設計的精華:「檔案系統即路由」怎麼表達巢狀、動態參數、載入狀態,以及「route group(路由群組)」怎麼在不改變網址的前提下切換 layout。我們會在 booking-fe 上加 /bookings、/bookings/[id]、/bookings/[id]/edit、/bookings/new 四條路由,並示範「側邊欄 layout + 內容區 page」的標準巢狀結構。讀完之後你應該能在 30 秒內決定任何一段新路由的檔案該放哪。
引言
寫後端的工程師對「URL 即資源」非常熟悉——GET /users/42/orders 表示「使用者 42 的所有訂單」。這個觀念跟 App Router 的檔案系統完全對應:app/users/[id]/orders/page.tsx 就是 /users/42/orders。資料夾是 URL 區段、檔案是端點、約定檔決定它扮演什麼角色。比起 Pages Router 的「全部路由寫進 pages/」、React Router 的「全部路由寫進一支設定檔」,App Router 的設計更接近後端的「目錄即資源」直覺。
今天重點放在三件事。第一,巢狀路由(nested routes)——資料夾一層一層對應到 URL 一層一層,layout.tsx 在每一層自動包下層,這是後端「共用 layout」最乾淨的寫法。第二,動態路由(dynamic routes)——[id]、[...slug]、[[...slug]] 三種括號的差別,以及 generateStaticParams 怎麼決定哪些動態值要預先建好。第三,載入與錯誤狀態——loading.tsx、error.tsx、not-found.tsx 放在「對應的目錄下」就只影響那一段路由,不會打擾其他頁面。我們也會順道提 route group(用 (name) 命名的資料夾)這個「不影響 URL 但影響 layout 群組」的特殊語法。
對照後端的經驗,App Router 的資料夾結構就像 FastAPI 的 router include——app/bookings/[id]/page.tsx 像 router.include_router(bookings_router, prefix="/bookings/{id}");巢狀 layout 像 dependency injection 的 scoping;loading.tsx 像 middleware 裡的「資料讀取中」狀態。學會這套語法之後,你會發現自己寫前端時的「找檔案」時間大幅縮短。
巢狀路由:資料夾層級即 URL 層級
巢狀路由是 App Router 最直覺的部分。我們在昨天的專案裡加 app/bookings/ 目錄,底下放 page.tsx 就立刻有 /bookings 這個路由:
src/app/
├── layout.tsx # 根 layout
├── page.tsx # /
├── bookings/ # /bookings
│ ├── page.tsx # /bookings 的內容
│ └── layout.tsx # bookings 段的 layout(選用)
├── bookings/
│ └── [id]/ # /bookings/:id
│ └── page.tsx
└── bookings/
└── [id]/
└── edit/ # /bookings/:id/edit
└── page.tsx
這套結構對應的 URL 是 /bookings、/bookings/42、/bookings/42/edit。資料夾層級跟 URL 層級完全對應,看檔案結構就知道網站長什麼樣。對照到 React Router 的 createBrowserRouter,要寫好幾層巢狀的 children 陣列;對照 Pages Router 要靠檔名扁平化 pages/bookings/[id]/edit.tsx(雖然也能巢狀但 layout 難處理);App Router 用檔案系統就把這件事做完了。
「layout.tsx 自動包下層」是巢狀最關鍵的特性。在 app/bookings/layout.tsx 裡寫的 JSX 會自動包住 app/bookings/page.tsx、app/bookings/[id]/page.tsx 與 app/bookings/[id]/edit/page.tsx。根 app/layout.tsx 會包住整個 App(含 /bookings);app/bookings/layout.tsx 會再包一層。所以整個 render tree 是這樣:
// src/app/bookings/layout.tsx
// bookings 段專屬的 layout:側邊欄 + 內容區
import Link from "next/link";
export default function BookingsLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="grid grid-cols-[200px_1fr] gap-6 px-6 py-8">
<aside className="space-y-2">
<h2 className="text-lg font-semibold">預約管理</h2>
<nav className="flex flex-col gap-1 text-sm">
<Link href="/bookings" className="rounded px-2 py-1 hover:bg-slate-100">
預約清單
</Link>
<Link href="/bookings/new" className="rounded px-2 py-1 hover:bg-slate-100">
新增預約
</Link>
</nav>
</aside>
<section>{children}</section>
</div>
);
}
注意這個 layout 沒宣告 "use client",是純 Server Component——它只是把 children 渲染出來,不需要互動 state。側邊欄的 Link 是 Next.js 提供的 client 元件(內部用了 useRouter),但因為 layout 本身是 server,Link 是「被 server render、client hydrate」的模式,依然能正確運作且不會把整個 layout 變成 client bundle。
另一個重點:「layout.tsx 跨頁面時不會重新 mount」。當使用者在 /bookings 與 /bookings/42 之間切換,根 layout 與 bookings/layout.tsx 維持掛載狀態,只有內層 {children} 換掉。這個特性對「側邊欄的折疊狀態、影片播放、全域 modal」非常有用——這些 UI 不會因為換頁就被 unmount、狀態不會被重置。
動態路由:三種括號的差別
App Router 用三種括號語法處理動態參數,每種對應到不同的 URL 模式:
src/app/bookings/
├── [id]/ # /bookings/42、/bookings/abc 都會命中
│ └── page.tsx # params.id 拿到 "42"
├── [...slug]/ # /bookings/2026/03/29 都會命中(catch-all)
│ └── page.tsx # params.slug = ["2026", "03", "29"]
└── [[...slug]]/ # /bookings 也命中,/bookings/2026/03/29 也命中
└── page.tsx # 沒給 slug 時 params.slug 是 undefined
[id] 是最常見的單一段動態參數,適用於「資源 ID」這類場景。[...slug] 是 catch-all,會把剩下的所有段都收進一個陣列,適用於「任意層級的路徑」(例如麵包屑、目錄樹)。[[...slug]] 是「optional catch-all」,連「不給任何參數」也命中,適用於「同一支頁面同時處理『首頁』與『子頁』」的場景,例如 docs 站的首頁與子章節。
在 page.tsx 裡透過 params prop 讀取動態參數:
// src/app/bookings/[id]/page.tsx
// 動態路由:Server Component 透過 params 拿到 URL 段
import { notFound } from "next/navigation";
import { getBooking } from "@/lib/api";
import { BookingDetail } from "@/features/booking/components/BookingDetail";
type Params = { id: string };
export default async function BookingDetailPage({
params,
}: {
params: Promise<Params>;
}) {
const { id } = await params;
const booking = await getBooking(id);
if (!booking) notFound();
return <BookingDetail booking={booking} />;
}
Next.js 15 把 params 改為 Promise,這是因為 Server Component 在 streaming 模式下需要保留「非同步解析 params」的能力。寫法就是 async function Page({ params }: { params: Promise<...> })、函式內 const { id } = await params。這個改動在升級 Next.js 14 → 15 時很容易漏掉,TypeScript 編譯器會抱怨 Property 'id' does not exist on type 'Promise<...>',記得在所有動態頁面加上 await。
對於「靜態參數需要預先知道」的場景(例如文章 ID 是固定的、想要 SSG),可以用 generateStaticParams:
// src/app/bookings/[id]/page.tsx
// 預先產生靜態頁面:build time 決定有哪些 id
import { listBookingIds } from "@/lib/api";
import { notFound } from "next/navigation";
import { getBooking } from "@/lib/api";
import { BookingDetail } from "@/features/booking/components/BookingDetail";
// 預先生成:把這些 id 在 build time 渲染成 HTML
export async function generateStaticParams(): Promise<{ id: string }[]> {
const ids = await listBookingIds();
return ids.map((id) => ({ id }));
}
type Params = { id: string };
export default async function BookingDetailPage({
params,
}: {
params: Promise<Params>;
}) {
const { id } = await params;
const booking = await getBooking(id);
if (!booking) notFound();
return <BookingDetail booking={booking} />;
}
generateStaticParams 回傳的陣列決定哪些動態值要在 build time 預渲染;其他沒列出的動態值會在 request time 才渲染。對「booking ID 會持續增加」的場景,可以搭配 dynamicParams = true(預設),未列出的 ID 仍可在 runtime 渲染。
載入狀態:loading.tsx 與 Suspense 的協作
loading.tsx 是約定檔,它會被框架自動包進 <Suspense>,在 page.tsx 的 promise 還沒 resolve 時顯示。我們在 app/bookings/loading.tsx 擺一個骨架畫面(skeleton):
// src/app/bookings/loading.tsx
// 約定檔:只影響 /bookings 段的載入狀態
export default function BookingsLoading() {
return (
<ul className="space-y-3" aria-busy="true" aria-live="polite">
{Array.from({ length: 5 }).map((_, i) => (
<li
key={i}
className="h-16 animate-pulse rounded bg-slate-200"
/>
))}
</ul>
);
}
放在 bookings/loading.tsx 而不是 app/loading.tsx 的差別:前者只影響 /bookings 與其子路由,後者影響整個 App。實務上越靠近葉節點的 loading 越精準,避免「切到任何頁面都看到全站 spinner」的粗糙體驗。
進階用法是直接在 JSX 裡用 <Suspense> 包裝「特定元件」,讓骨架更細緻。例如清單頁裡有一個「最近七天熱門時段」區塊,資料抓比較慢,可以這樣寫:
// src/app/bookings/page.tsx
// 內嵌 Suspense:細粒度的載入狀態
import { Suspense } from "react";
import { listBookings } from "@/lib/api";
import { BookingList } from "@/features/booking/components/BookingList";
import { HotSlotsPanel } from "@/features/booking/components/HotSlotsPanel";
export default async function BookingsPage() {
// 清單本身立即讀取
const bookings = await listBookings();
return (
<div className="space-y-6">
<BookingList bookings={bookings} />
{/* 熱門時段:可能慢,用 Suspense 包起來 */}
<Suspense fallback={<div className="h-32 animate-pulse rounded bg-slate-100" />}>
<HotSlotsPanel />
</Suspense>
</div>
);
}
這段寫法的效果是「清單立刻出現、熱門時段的位置先顯示骨架、資料回來後自動換成實際內容」。Streaming SSR 會把 <HotSlotsPanel /> 的部分以 chunked HTML 送出,瀏覽器邊收邊畫,使用者體驗比「全部等齊再出現」好很多。
錯誤狀態:error.tsx 與邊界隔離
error.tsx 的邊界跟 loading.tsx 一樣是「越近越精準」。app/error.tsx 是全站最後防線;app/bookings/error.tsx 只接住 /bookings 段的錯誤,不會波及其他路由。對 booking 系統特別有用:清單頁打 API 失敗時,使用者仍然可以瀏覽首頁與其他頁面,不會整站掛掉。
// src/app/bookings/error.tsx
// 錯誤邊界:只接住 bookings 段的錯誤
"use client";
import { useEffect } from "react";
export default function BookingsError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<div className="rounded border border-red-200 bg-red-50 p-4">
<h2 className="font-semibold text-red-800">預約載入失敗</h2>
<p className="mt-1 text-sm text-red-700">{error.message}</p>
<button
type="button"
onClick={() => reset()}
className="mt-3 rounded bg-red-700 px-3 py-1 text-white"
>
重試一次
</button>
</div>
);
}
注意這個元件是 client(必須 client,因為 reset 需要瀏覽器端的 callback),並用 console.error 把錯誤送到監控系統(Day 30 會接 Sentry 之類的工具)。error.digest 是 Server Component 拋出錯誤時附的 hash,可以送到後端 log 對應真正的 stack trace。
「找不到資源」這個特定錯誤用 notFound() 函式處理。它會自動被 not-found.tsx 接住:
// src/app/bookings/[id]/not-found.tsx
// 約定檔:當這段路由的 notFound() 被呼叫時顯示
import Link from "next/link";
export default function BookingNotFound() {
return (
<div className="py-12 text-center">
<h2 className="text-2xl font-bold">找不到這筆預約</h2>
<p className="mt-2 text-slate-600">可能已被取消或連結過期。</p>
<Link href="/bookings" className="mt-4 inline-block text-blue-700 underline">
回預約清單
</Link>
</div>
);
}
Route Group:用 (name) 切換 layout 不改 URL
Route Group 是 App Router 一個很容易被忽略、但很實用的語法:用「被括號包住的資料夾」(name) 不會出現在 URL,但會影響 layout 群組。常見的應用場景是「同一個 URL 前綴下,用不同 layout 切換不同身份的使用者」:
src/app/
├── (marketing)/ # 路由群組:對外頁面
│ ├── layout.tsx # 含頁尾、導覽列的 layout
│ ├── page.tsx # /(首頁)
│ └── about/page.tsx # /about
└── (admin)/ # 路由群組:後台頁面
├── layout.tsx # 含側邊欄後台 layout
└── bookings/page.tsx # /bookings(後台版)
兩支 page.tsx 的 URL 都是 /bookings 嗎?不對——這個例子裡 (admin)/bookings/page.tsx 的 URL 是 /bookings,但 (marketing)/ 底下沒有 bookings/,所以不會衝突。重點是「同一個 page.tsx 對應到哪個 layout.tsx 由它在哪個群組決定」。
實務上常見的用法還包括「未登入 vs 已登入」的 layout 切換:(public)/login/page.tsx 用簡潔的 layout;(app)/dashboard/page.tsx 用含使用者資訊的 layout。兩者 URL 不衝突,但渲染的 layout 不同。
導覽:Link、redirect 與 useRouter
頁面之間的跳轉有三種寫法。第一種是宣告式的 <Link href="...">,這是首選:框架會自動 prefetch 目標路由的 RSC payload,瀏覽器看到的是一般 anchor 標籤,鍵盤與螢幕閱讀器都能正確解讀。第二種是程式化的 router.push(...) 或 router.replace(...),用在「使用者操作之後跳轉」的情境(例如登入成功後導向 dashboard)。第三種是 Server Component 內的 redirect(...),用在「資料驗證失敗直接跳走」的情境(例如未登入就強制回首頁)。
// src/app/bookings/[id]/edit/page.tsx
// Server Component:用 redirect 做強制跳轉
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth";
import { getBooking } from "@/lib/api";
import { EditBookingForm } from "@/features/booking/components/EditBookingForm";
type Params = { id: string };
export default async function EditBookingPage({
params,
}: {
params: Promise<Params>;
}) {
const session = await getSession();
if (!session) redirect("/login"); // 未登入就跳轉
const { id } = await params;
const booking = await getBooking(id);
if (!booking) redirect("/bookings"); // 找不到就回清單
// 只有管理員能改
if (session.role !== "admin") redirect(`/bookings/${id}`);
return <EditBookingForm booking={booking} />;
}
redirect() 跟 notFound() 一樣都是 Server Component 內建的跳轉機制,差別在「找不到資源」對應 not-found.tsx,「權限不足或強制跳轉」對應 redirect()。呼叫 redirect() 會丟出一個特殊的 Next.js 錯誤讓框架攔截,前端不會看到 stack trace。
Client Component 裡要跳轉時,用 useRouter:
// src/features/auth/components/LoginForm.tsx
// Client Component:用 useRouter 做程式化跳轉
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
export function LoginForm() {
const router = useRouter();
const [pending, setPending] = useState(false);
async function handleSubmit(formData: FormData) {
setPending(true);
const res = await fetch("/api/auth/login", {
method: "POST",
body: formData,
});
if (res.ok) {
router.push("/dashboard"); // 登入成功跳轉
} else {
setPending(false);
}
}
return (
<form action={handleSubmit} className="space-y-3">
{/* 略 */}
</form>
);
}
useRouter 跟 React Router 的 useNavigate 類似,但底層用的是 App Router 的 client-side 路由:跳轉時不會整頁 reload,而是觸發新的 RSC fetch、把對應的 page 換掉。對 SPA 體驗特別重要。
另外有個常被忽略的小工具 usePathname(),能在 Client Component 讀到「目前 URL 路徑」,適合用來標記當前頁面、實作「當前位置高亮」的導覽列:
// src/features/nav/components/SidebarLink.tsx
// Client Component:用 usePathname 判斷當前位置
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
export function SidebarLink({ 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}
className={active ? "font-semibold text-blue-700" : "text-slate-700"}
>
{children}
</Link>
);
}
aria-current="page" 是 ARIA 標準屬性,告訴螢幕閱讀器「這是當前頁面」。鍵盤操作也會自動跳過這個連結(避免重複觸發)。搭配 Tailwind 的 className 切換,使用者能直接看出當前位置。
Query String 與 searchParams
除了 path 段的動態參數,URL 上的 query string(例如 /bookings?status=pending)也是常用的篩選介面。App Router 把 query string 收進 searchParams prop:
// src/app/bookings/page.tsx
// 讀 query string 篩選預約
import { listBookings } from "@/lib/api";
import { BookingList } from "@/features/booking/components/BookingList";
import { FilterBar } from "@/features/booking/components/FilterBar";
type SearchParams = { status?: string; q?: string };
export default async function BookingsPage({
searchParams,
}: {
searchParams: Promise<SearchParams>;
}) {
const { status, q } = await searchParams;
const bookings = await listBookings({ status, q });
return (
<div className="space-y-4">
<FilterBar status={status} query={q} />
<BookingList bookings={bookings} />
</div>
);
}
跟 params 一樣,Next.js 15 的 searchParams 也是 Promise,要在 async 函式內 await 取得值。常見的篩選邏輯直接寫在 page.tsx:使用者點篩選條件、表單 submit 到同一個 URL、框架重新跑 page.tsx、套新的 query string 重新撈資料。不需要寫 useState、不需要 React Query,純 Server Component 就能處理大部分的篩選 UI。
「使用者點篩選條件、表單 submit 到同一個 URL」這條 UI 模式稱為「progressive enhancement」(漸進增強):即使 JavaScript 沒跑(搜尋引擎爬蟲、純文字瀏覽器),使用者依然能用 form submit 改變 URL 與資料;JavaScript 跑起來時,再用 useRouter().replace() 讓體驗更滑順。實作上常見的寫法:
// src/features/booking/components/FilterBar.tsx
// 篩選列:純 form 也能跑,加 client 體驗更佳
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useTransition } from "react";
export function FilterBar({ status, query }: { status?: string; query?: string }) {
const router = useRouter();
const params = useSearchParams();
const [pending, startTransition] = useTransition();
function apply(next: { status?: string; q?: string }) {
const sp = new URLSearchParams(params);
if (next.status) sp.set("status", next.status);
else sp.delete("status");
if (next.q) sp.set("q", next.q);
else sp.delete("q");
startTransition(() => {
router.replace(`/bookings?${sp.toString()}`);
});
}
return (
<div className="flex gap-2" aria-busy={pending}>
<button type="button" onClick={() => apply({ status: "pending" })}>
待處理
</button>
<button type="button" onClick={() => apply({ status: "done" })}>
已完成
</button>
</div>
);
}
useTransition 是 React 19 的新 API,告訴 React「這次更新可以中斷、不影響 UI 響應」。搭配 startTransition 使用時,篩選按鈕的點選不會等資料回來才更新 UI,而是立即更新並標 aria-busy。對 booking 預約系統這種「點按鈕就刷資料」的互動特別有用。
觀察 Layout 行為:DevTools 的實際驗證
想驗證「layout.tsx 跨頁面不重新 mount」這件事,最直接的方式是打開 React DevTools 並觀察元件層級。打開 React DevTools 的 Components 面板,從根 html 一路展開,會看到 RootLayout 與 BookingsLayout 都有獨立的 fiber。在 /bookings 與 /bookings/42 之間切換時,這兩個 fiber 的狀態(如 useState)不會被重置,只有內層的 {children} 換掉。如果在 BookingsLayout 內放一個 useState(0) 的計數器,跨頁切換時計數不會歸零——這就是「layout 不重新 mount」的視覺化證據。
另一個驗證方式是看 Network 面板。在 /bookings 與 /bookings/42 之間切換時,瀏覽器會送出新的 RSC request(payload 是 React tree 的差量描述),不會重新下載整個 HTML 與 JavaScript bundle。Chrome DevTools 的 Network 面板能直接看到「RSC」請求,檢查它的 response body 就能看到 React 19 對應的差量描述。
對照後端的經驗,這套「layout 不重新 mount + 內層換掉」的模型最接近 SPA 的 client-side routing,但因為 RSC payload 是純資料(沒有 JavaScript),整體成本遠比 SPA 低。對 booking 預約系統這種「後台介面偏多、頁面間共享 layout」的應用,這個特性可以讓側邊欄的折疊狀態、表單的未儲存提示、影片播放器等持續運作的 UI 不被換頁打斷。
常見錯誤與踩雷
第一個雷是「[id]/page.tsx 沒升級到 Next.js 15 的 params Promise」。升級到 15 之後 params 是 Promise<{...}>,舊寫法 function Page({ params }: { params: { id: string } }) 會在生產 build 時跳錯(dev 有時不會,因為有 fallback)。修法是把型別改成 params: Promise<{ id: string }>、函式內加 await params。CI 一定要跑 pnpm build 才能抓到這個錯誤。
第二個雷是「在 loading.tsx 用 client 元件(例如 useEffect 寫動畫)」。loading.tsx 是骨架畫面,本質上是 Server Component;硬要加互動動畫會出現代價。如果你需要 skeleton 帶 pulse 動畫,用 Tailwind 的 animate-pulse class 即可,這是純 CSS、不需要 client。
第三個雷是「error.tsx 忘了加 "use client"」。這個錯誤訊息很明確(「error components must be Client Components」),但新手常常看到還是想「為什麼一定要 client」。原因就是 reset 函式必須在瀏覽器端執行。修法照做即可。
第四個雷是「generateStaticParams 在 build time 跑得太慢」。如果動態 ID 有幾萬個,generateStaticParams 在 build time 會呼叫 API 把所有 ID 撈回來,可能讓 CI build 從 1 分鐘變 10 分鐘。這時可以考慮:縮限範圍(只預先生成前 100 筆熱門 ID)、改用 ISR(Incremental Static Regeneration)、或完全不預先生成(讓所有 ID 走 request time)。
第五個雷是「route group 的 layout 互相打架」。如果在 (marketing)/layout.tsx 與 (admin)/layout.tsx 都各自包了 <html> 標籤,build 會跳「Multiple root layouts detected」錯誤。根 app/layout.tsx 是唯一可以有 <html> 與 <body> 的檔案,route group 的 layout 只能包 <div> 之類的區段。
效能與實務提醒
巢狀 layout 的成本要記得算進去。每多一層 layout.tsx,首屏就要多 render 一層 React tree;如果 layout 內有 fetch 動作(例如讀使用者 session),這層的等待時間會被加進 first paint。實務上「RootLayout 保持精簡、不放 fetch」是最常見的最佳化;把需要 fetch 的部分移到 app/<section>/layout.tsx 或用 React Suspense 切段。
Streaming SSR 的效益在巢狀結構特別明顯。當 page.tsx 還在 await 資料時,layout 與 loading.tsx 已經送到瀏覽器並開始繪製,使用者看到骨架後資料跟上。沒有 streaming 的 SSR 時代,整個 page 要等齊才能送 HTML,TTFB(Time To First Byte)與 FCP(First Contentful Paint)都會比較差。實務上把 loading.tsx 放在「最有可能慢的目錄」是最佳化重點。
最後是「Link prefetch」。<Link> 在 viewport 內會自動 prefetch 目標路由的 RSC payload,切換頁面幾乎是即時。但 prefetch 會消耗流量,行動裝置或低速網路要節制。Next.js 15 提供 <Link prefetch={false}> 關閉特定連結的 prefetch,例如「登出」「外部連結」「使用者極少點的連結」。
小結
今天把 App Router 的檔案系統即路由拆成五塊:巢狀 layout(資料夾層級 = URL 層級 + layout.tsx 自動包下層)、動態路由([id]、[...slug]、[[...slug]] 三種括號對應三種模式)、載入狀態(loading.tsx 在對應目錄只影響該段、可搭配 <Suspense> 內嵌細粒度骨架)、錯誤狀態(error.tsx 同樣依目錄邊界隔離、notFound() 觸發 not-found.tsx)、route group(用 (name) 切換 layout 不改 URL)。這五塊合在一起就是「多頁面 + 共享 layout + 動態參數 + 優雅失敗」的標準 Next.js 模式。
在 booking 預約系統的脈絡下,這套結構讓我們能用「資料夾即資源」的直覺組織程式:/bookings 是一個目錄、/bookings/42 是子目錄、每層都有自己的 layout 與錯誤邊界。明天 Day 19 會把 Server Component 的資料取得設計展開:怎麼在元件裡 await fetch、四種快取策略怎麼選、<Suspense> 怎麼跟 loading.tsx 協作把 TTFB 縮到最短。
結語
明天,我們會正式進入 Day 19「Server Components 與資料取得」。我們會在 /bookings 與 /bookings/[id] 兩條路由上把 fetch 包進 src/lib/api.ts,並示範 no-store、force-cache、revalidate 三種快取策略的差別。你會看到 Server Component 如何直接 await 後端、為什麼 bundle 不會包含 API 網址、以及 streaming 模式下資料是怎麼「邊來邊送」的。
延伸資源
- Next.js 官方 Routing 章節:
https://nextjs.org/docs/app/building-your-application/routing - Next.js 官方 Dynamic Routes 章節:
https://nextjs.org/docs/app/building-your-application/routing/dynamic-routes - Next.js 官方 Loading UI 與 Streaming:
https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming - Next.js 官方 Route Groups:
https://nextjs.org/docs/app/building-your-application/routing/route-groups - Next.js 15 generateStaticParams 行為:
https://nextjs.org/docs/app/api-reference/functions/generate-static-params
留言
張貼留言