【Next.js 基礎教學】#03 頁面路由與導覽

測驗:Next.js 頁面路由與導覽

共 5 題,點選答案後會立即顯示結果

1. 在 Next.js App Router 中,什麼決定了網址的結構?

  • A. 設定檔 next.config.js 中的路由設定
  • B. app 目錄下的資料夾結構
  • C. package.json 中的 routes 欄位
  • D. 每個元件中定義的 path 屬性

2. 關於 layout.tsx 的敘述,何者正確?

  • A. 每個頁面切換時 layout 都會完全重新渲染
  • B. layout.tsx 是選用的,根目錄可以不用有
  • C. layout 在頁面切換時不會重新渲染,狀態會被保留
  • D. layout.tsx 只能放在根目錄,子目錄不能有

3. 下列哪個資料夾結構可以讓 /docs/docs/api/hooks 都能被訪問?

  • A. app/docs/[slug]/page.tsx
  • B. app/docs/[...slug]/page.tsx
  • C. app/docs/page.tsx 加上 app/docs/[slug]/page.tsx
  • D. app/docs/[[...slug]]/page.tsx

4. 使用 useRouter hook 時,需要注意什麼?

  • A. 只能在 Server Component 中使用
  • B. 必須在 Client Component 中使用,需要加上 ‘use client’
  • C. 必須先 import layout.tsx 才能使用
  • D. 只能用於靜態路由,動態路由要用 Link

5. 在動態路由中,當資料不存在時如何顯示 404 頁面?

  • A. 使用 throw new Error('404')
  • B. 使用 router.push('/404')
  • C. 從 next/navigation 匯入並呼叫 notFound()
  • D. 回傳 return null

前言

在前兩篇文章中,我們學會了建立 Next.js 專案並認識了元件的基本結構。現在,是時候讓我們的應用程式「動起來」了——不是指動畫效果,而是讓使用者能在多個頁面之間流暢地切換。

當你用 AI 輔助閱讀 Next.js 專案時,理解路由系統是關鍵的第一步。因為路由決定了整個專案的檔案結構,也決定了使用者能訪問哪些頁面。

App Router 的檔案式路由原理

Next.js 的 App Router 採用「檔案式路由」(File-based Routing),這是一個非常直覺的設計:資料夾結構就是網址結構

最小範例

app/
├── page.tsx          → 對應網址 /
├── about/
│   └── page.tsx      → 對應網址 /about
└── blog/
    └── page.tsx      → 對應網址 /blog

看到這個結構,你馬上就能知道這個網站有三個頁面:首頁、關於頁、部落格頁。

關鍵規則

  1. 資料夾 = 路由段落:每個資料夾名稱對應網址的一個部分
  2. page.tsx = 頁面入口:只有資料夾內有 page.tsx 才能被訪問
  3. 沒有 page.tsx = 不可訪問:資料夾可以只用來組織結構,不一定要有頁面

這代表當你看到一個陌生的 Next.js 專案時,只要打開 app/ 目錄,就能快速掌握整個網站的頁面架構。

page.tsx 與 layout.tsx 的作用

page.tsx:頁面內容

page.tsx 定義了該路由的實際內容:

// app/about/page.tsx
export default function AboutPage() {
  return (
    <div>
      <h1>關於我們</h1>
      <p>這是關於頁面的內容</p>
    </div>
  )
}
Code language: JavaScript (javascript)

當你讀到一個 page.tsx 檔案時,問自己:「這個頁面要顯示什麼?」

layout.tsx:共用外框

layout.tsx 定義了該路由及其子路由共用的外框:

// app/layout.tsx(根 layout,必須存在)
export default function RootLayout({
  children
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="zh-TW">
      <body>
        <nav>網站導覽列</nav>
        <main>{children}</main>
        <footer>網站頁尾</footer>
      </body>
    </html>
  )
}
Code language: JavaScript (javascript)

重要特性

  • children 會被替換成當前頁面的內容
  • Layout 在頁面切換時不會重新渲染,狀態會被保留
  • 根目錄的 layout.tsx 是必要的,且必須包含 <html><body> 標籤

讀 code 時的思考方式

當你看到一個 layout.tsx,問自己:

  • 「這個 layout 包住了哪些頁面?」
  • 「哪些 UI 元素是這些頁面共用的?」

巢狀路由結構

App Router 的強大之處在於 layout 可以層層巢狀:

app/
├── layout.tsx           ← 根 layout(導覽列、頁尾)
├── page.tsx             ← 首頁
└── dashboard/
    ├── layout.tsx       ← Dashboard layout(側邊欄)
    ├── page.tsx         ← /dashboard
    ├── settings/
    │   └── page.tsx     ← /dashboard/settings
    └── profile/
        └── page.tsx     ← /dashboard/profile
// app/dashboard/layout.tsx
export default function DashboardLayout({
  children
}: {
  children: React.ReactNode
}) {
  return (
    <div className="dashboard">
      <aside>Dashboard 側邊欄</aside>
      <section>{children}</section>
    </div>
  )
}
Code language: JavaScript (javascript)

當使用者訪問 /dashboard/settings 時,渲染結果會是:

RootLayout
  └── DashboardLayout
        └── SettingsPage

三層結構自動組合在一起。讀懂這個機制後,你就能理解為什麼某些 UI 元素會「神奇地」出現在每個頁面上。

動態路由:[slug] 與 […slug]

實際應用中,我們常需要根據資料動態生成頁面,例如部落格文章、商品詳情頁。

基本動態路由 [slug]

用方括號包住資料夾名稱,就建立了動態路由:

app/
└── blog/
    └── [slug]/
        └── page.tsx    ← 對應 /blog/任意值
// app/blog/[slug]/page.tsx
export default async function BlogPost({
  params
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params

  return <h1>文章:{slug}</h1>
}
Code language: JavaScript (javascript)

對應關係

網址 params.slug
/blog/hello-world "hello-world"
/blog/nextjs-tutorial "nextjs-tutorial"

Catch-all 路由 […slug]

[...slug] 可以匹配多層路徑:

app/
└── docs/
    └── [...slug]/
        └── page.tsx    ← 對應 /docs/任意/多層/路徑
// app/docs/[...slug]/page.tsx
export default async function DocsPage({
  params
}: {
  params: Promise<{ slug: string[] }>
}) {
  const { slug } = await params

  // slug 是陣列
  return <p>路徑:{slug.join(' / ')}</p>
}
Code language: JavaScript (javascript)

對應關係

網址 params.slug
/docs/getting-started ["getting-started"]
/docs/api/hooks/useRouter ["api", "hooks", "useRouter"]

Optional Catch-all [[…slug]]

雙層方括號讓路由變成可選的,連根路徑也能匹配:

app/
└── shop/
    └── [[...slug]]/
        └── page.tsx    ← 對應 /shop 以及 /shop/任意/路徑
網址 params.slug
/shop undefined
/shop/clothes ["clothes"]
/shop/clothes/shirts ["clothes", "shirts"]

讀 code 時的判斷技巧

看到動態路由時,快速判斷:

  • [id]:單一動態值,params 是字串
  • [...slug]:多層路徑,params 是陣列,不匹配根路徑
  • [[...slug]]:多層路徑,params 是可選陣列,也匹配根路徑

Link 元件與 useRouter Hook

Link 元件:宣告式導覽

<Link> 是 Next.js 提供的導覽元件,用於取代 HTML 的 <a> 標籤:

import Link from 'next/link'

export default function Navigation() {
  return (
    <nav>
      <Link href="/">首頁</Link>
      <Link href="/about">關於</Link>
      <Link href="/blog/my-first-post">第一篇文章</Link>
    </nav>
  )
}
Code language: JavaScript (javascript)

Link 的優勢

  • 自動預先載入(Prefetch):當連結進入視野,Next.js 會預先載入目標頁面
  • 客戶端導覽:不會整頁重新載入,只更新變動的部分
  • 保留狀態:共用的 layout 不會重新渲染

useRouter Hook:程式化導覽

當你需要根據某些條件(如表單送出後)進行導覽時,使用 useRouter

'use client'  // 必須是 Client Component

import { useRouter } from 'next/navigation'

export default function LoginForm() {
  const router = useRouter()

  const handleSubmit = async () => {
    // 登入邏輯...

    // 登入成功後導向 dashboard
    router.push('/dashboard')
  }

  return (
    <button onClick={handleSubmit}>
      登入
    </button>
  )
}
Code language: JavaScript (javascript)

useRouter 常用方法

const router = useRouter()

// 導向新頁面(加入瀏覽記錄)
router.push('/dashboard')

// 取代當前頁面(不加入瀏覽記錄)
router.replace('/login')

// 重新整理當前頁面(重新取得伺服器資料)
router.refresh()

// 上一頁
router.back()

// 下一頁
router.forward()
Code language: JavaScript (javascript)

讀 code 時的選擇判斷

  • 看到 <Link>:這是使用者點擊觸發的導覽
  • 看到 router.push():這是程式邏輯觸發的導覽
  • 看到 router.replace():導覽後不希望使用者按「上一頁」回來

404 頁面處理

全域 404 頁面

app/ 目錄下建立 not-found.tsx

// app/not-found.tsx
import Link from 'next/link'

export default function NotFound() {
  return (
    <div>
      <h2>找不到頁面</h2>
      <p>您要找的頁面不存在</p>
      <Link href="/">回到首頁</Link>
    </div>
  )
}
Code language: JavaScript (javascript)

這會處理所有不存在的路由。

在動態路由中觸發 404

當資料不存在時,可以主動觸發 404:

// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'

export default async function BlogPost({
  params
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const post = await getPost(slug)

  // 如果文章不存在,顯示 404
  if (!post) {
    notFound()
  }

  return <article>{post.content}</article>
}
Code language: JavaScript (javascript)

局部 not-found.tsx

你也可以在特定路由段落建立 not-found.tsx,提供更精確的 404 體驗:

app/
├── not-found.tsx           ← 全域 404
└── blog/
    ├── not-found.tsx       ← 部落格專用 404
    └── [slug]/
        └── page.tsx

實際專案中的路由結構範例

app/
├── layout.tsx              ← 根 layout
├── page.tsx                ← 首頁 /
├── not-found.tsx           ← 404 頁面
├── (auth)/                 ← 路由群組(不影響 URL)
│   ├── login/
│   │   └── page.tsx        ← /login
│   └── register/
│       └── page.tsx        ← /register
├── dashboard/
│   ├── layout.tsx          ← Dashboard layout
│   ├── page.tsx            ← /dashboard
│   └── [teamId]/
│       ├── page.tsx        ← /dashboard/team-123
│       └── settings/
│           └── page.tsx    ← /dashboard/team-123/settings
└── blog/
    ├── page.tsx            ← /blog(文章列表)
    └── [slug]/
        └── page.tsx        ← /blog/my-post

補充:括號開頭的資料夾 (auth) 是「路由群組」,用來組織檔案但不會影響 URL 結構。

重點整理

概念 說明
檔案式路由 資料夾結構 = 網址結構
page.tsx 定義頁面內容,必須存在才能訪問該路由
layout.tsx 定義共用外框,會包住子路由
[slug] 動態路由,匹配單一值
[…slug] Catch-all 路由,匹配多層路徑
[[…slug]] Optional Catch-all,也匹配根路徑
Link 宣告式導覽,自動預先載入
useRouter 程式化導覽,需要 ‘use client’
not-found.tsx 處理 404 錯誤

下一步

現在你已經掌握了 Next.js 的路由系統,能夠:

  • 快速理解專案的頁面結構
  • 分辨靜態路由與動態路由
  • 看懂 Link 和 useRouter 的使用時機

下一篇我們將學習資料取得的方式,了解 Server Component 如何直接在伺服器端取得資料。


參考資源

進階測驗:Next.js 頁面路由與導覽

測驗目標:驗證你是否能在實際情境中應用所學。
共 5 題,包含情境題與錯誤診斷題。

1. 你正在開發一個部落格網站,需要讓每篇文章都有獨立的網址(如 /blog/my-first-post)。你應該如何設計檔案結構? 情境題

  • A. 為每篇文章手動建立一個資料夾,如 app/blog/my-first-post/page.tsx
  • B. 建立 app/blog/[slug]/page.tsx,使用動態路由
  • C. 在 app/blog/page.tsx 中使用 query string 來區分文章
  • D. 建立 app/blog/[[...slug]]/page.tsx,使用 Optional Catch-all

2. 同事寫了以下程式碼,但點擊按鈕後沒有任何反應。問題出在哪裡? 錯誤診斷

// app/components/LogoutButton.tsx import { useRouter } from ‘next/navigation’ export default function LogoutButton() { const router = useRouter() const handleLogout = () => { // 登出邏輯… router.push(‘/login’) } return ( <button onClick={handleLogout}> 登出 </button> ) }
  • A. 缺少 'use client' 指令,useRouter 只能在 Client Component 使用
  • B. 應該使用 router.replace('/login') 而非 router.push
  • C. useRouter 應該從 'next/router' 匯入而非 'next/navigation'
  • D. handleLogout 函式需要宣告為 async

3. 你正在建立一個後台管理系統,希望所有 /dashboard 開頭的頁面都有共用的側邊欄,但首頁不需要。最佳做法是什麼? 情境題

  • A. 在每個 dashboard 頁面中都手動引入側邊欄元件
  • B. 在根目錄的 layout.tsx 中判斷路徑來決定是否顯示側邊欄
  • C. 在 app/dashboard/layout.tsx 中加入側邊欄,利用巢狀 layout 機制
  • D. 使用 usePathname hook 在每個頁面動態載入側邊欄

4. 使用者反應網站的文章頁面有時會顯示空白,查看程式碼如下。問題最可能出在哪裡? 錯誤診斷

// app/blog/[slug]/page.tsx import { notFound } from ‘next/navigation’ export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params const post = await getPost(slug) if (!post) { return <div></div> // 文章不存在時顯示空白 } return <article>{post.content}</article> }
  • A. getPost 函式沒有正確處理非同步操作
  • B. 當文章不存在時應該呼叫 notFound() 而非回傳空 div
  • C. params 的型別定義錯誤,應該是物件而非 Promise
  • D. 缺少 loading.tsx 來處理載入狀態

5. 你的電商網站需要一個商品分類頁面,網址結構為 /shop、/shop/clothes、/shop/clothes/shirts 等多層級。你希望用同一個頁面元件處理所有層級。最適合的路由結構是什麼? 情境題

  • A. app/shop/[category]/page.tsx 加上 app/shop/[category]/[subcategory]/page.tsx
  • B. app/shop/[...category]/page.tsx
  • C. app/shop/page.tsx 加上 query string 處理
  • D. app/shop/[[...category]]/page.tsx

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *