Next.js 進階路由

Next.js App Router 進階路由完整指南 — Parallel Routes、Intercepting Routes、Middleware、Route Handlers


目錄


什麼是進階路由?

「進階路由」涵蓋 App Router 提供的非基礎檔案路由功能

功能 解決什麼問題
Parallel Routes 同一頁面並排顯示多個獨立路由(儀表板、雙側欄)
Intercepting Routes 攔截導航用 modal 開啟而非真的跳頁
Route Handlers 寫 HTTP API(取代 Pages Router 的 /pages/api
Middleware 在 request 到達頁面之前全域攔截(auth、A/B test、語系)

基礎路由(file-based、dynamic、catch-all、route group)見 basics.md 路由與導航入門


Parallel Routes(@slot)

概念

讓 layout 可以同時 render 多個獨立的路由區塊,每個區塊有自己的 loading / error 狀態。

檔案結構

app/dashboard/
├── layout.tsx
├── page.tsx                  ← 預設 children
├── @analytics/
│   ├── page.tsx              ← @analytics slot
│   ├── loading.tsx
│   └── error.tsx
└── @team/
    ├── page.tsx              ← @team slot
    ├── loading.tsx
    └── error.tsx

Layout 接收 slot 作為 prop

// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
  analytics,
  team,
}: {
  children: React.ReactNode;
  analytics: React.ReactNode;
  team: React.ReactNode;
}) {
  return (
    <div className="grid grid-cols-2 gap-4">
      <div>{children}</div>
      <div>{analytics}</div>
      <div>{team}</div>
    </div>
  );
}

每個 slot 都是獨立路由:

  • @analytics/page.tsx 對應 /dashboard
  • @analytics/loading.tsx 只影響 @analytics 區塊

default.tsx

當 slot 沒匹配到任何路由時,會 render default.tsx

app/dashboard/
├── @analytics/
│   ├── page.tsx       ← 對應 /dashboard
│   ├── views/page.tsx ← 對應 /dashboard/views
│   └── default.tsx    ← 其他路徑下 fallback

當 URL 是 /dashboard/team-settings@analytics 沒對應路由時,會 render @analytics/default.tsx

使用情境

  • 儀表板的並排 widgets
  • 兩個獨立載入速度的區塊(一個快、一個慢)
  • 條件式渲染(登入/未登入顯示不同 slot)

Intercepting Routes

概念

當使用者點擊連結時,攔截導航行為,改成 modal 開啟。但直接打 URL 仍會走完整頁面

約定符號

符號 意義
(.) 同層攔截
(..) 上一層攔截
(..)(..) 上兩層攔截
(...) 從 root 攔截

範例:相簿 modal

app/
├── photos/
│   └── [id]/
│       └── page.tsx           ← 完整頁面 /photos/123
└── feed/
    ├── page.tsx               ← feed 頁
    └── @modal/
        └── (..)photos/
            └── [id]/
                └── page.tsx   ← 從 feed 攔截 /photos/123 → 開 modal

配合 Parallel Routes

通常 Intercepting 跟 @modal slot 一起用:

// app/feed/layout.tsx
export default function FeedLayout({
  children,
  modal,
}: {
  children: React.ReactNode;
  modal: React.ReactNode;
}) {
  return (
    <>
      {children}
      {modal}
    </>
  );
}
// app/feed/@modal/default.tsx
export default function Default() {
  return null; // 沒 modal 時不 render 任何東西
}
// app/feed/@modal/(..)photos/[id]/page.tsx
import Modal from "@/components/Modal";

export default async function PhotoModal({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const photo = await getPhoto(id);

  return (
    <Modal>
      <img src={photo.url} alt={photo.title} />
    </Modal>
  );
}

使用者體驗

  • 從 feed 點縮圖 → modal 開啟(不離開 feed)
  • 直接訪問 /photos/123 → 看到完整頁面
  • 在 modal 重整頁面 → 自動 fallback 到完整頁面
  • 分享 URL 給朋友 → 朋友看到完整頁面

Route Handlers 進階

基本結構

app/api/foo/route.ts 可以 export 多個 HTTP method handler:

// app/api/posts/route.ts
import { NextResponse } from "next/server";

export async function GET() {
  const posts = await db.post.findMany();
  return NextResponse.json(posts);
}

export async function POST(request: Request) {
  const body = await request.json();
  const post = await db.post.create({ data: body });
  return NextResponse.json(post, { status: 201 });
}

export async function DELETE(request: Request) {
  const { id } = await request.json();
  await db.post.delete({ where: { id } });
  return NextResponse.json({ ok: true });
}

動態 Route Handler

// app/api/posts/[id]/route.ts
export async function GET(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  const post = await db.post.findUnique({ where: { id } });
  return NextResponse.json(post);
}

Request / Response 進階

import { NextRequest, NextResponse } from "next/server";

export async function POST(request: NextRequest) {
  // 1. Headers
  const auth = request.headers.get("authorization");

  // 2. Query params
  const query = request.nextUrl.searchParams.get("q");

  // 3. Cookies(read-only 在 GET,可寫於 response)
  const token = request.cookies.get("token");

  // 4. Body 多種讀法
  const json = await request.json();
  // const text = await request.text();
  // const form = await request.formData();

  // 5. Response 設定
  return NextResponse.json(
    { ok: true },
    {
      status: 200,
      headers: {
        "X-Custom": "value",
        "Cache-Control": "no-store",
      },
    }
  );
}

Streaming Response

export async function GET() {
  const stream = new ReadableStream({
    async start(controller) {
      for (let i = 0; i < 10; i++) {
        controller.enqueue(new TextEncoder().encode(`chunk ${i}\n`));
        await new Promise((r) => setTimeout(r, 100));
      }
      controller.close();
    },
  });

  return new Response(stream, {
    headers: { "Content-Type": "text/plain" },
  });
}

Route Handler 與 Server Action 的選擇

情境
同站表單 mutation Server Actions
對外開放 API(第三方 / mobile app) Route Handlers
Webhook 接收端 Route Handlers
檔案上傳 / streaming Route Handlers
內部 BFF(給自己 client 用) Server Actions 或 Route Handlers 都行

Server Actions 細節見 server-actions.md


Middleware 基礎

是什麼

Middleware 在 request 到達頁面/route handler 之前執行,可以:

  • 重寫 URL(rewrite)
  • 重新導向(redirect)
  • 設定 / 讀取 cookies & headers
  • 拒絕請求

檔案位置

middleware.ts 放在專案根目錄(與 app/ 同層,若用 src/ 則在 src/middleware.ts):

project/
├── src/
│   ├── app/
│   └── middleware.ts   ← 在這裡
├── next.config.ts
└── package.json

基本範例

// src/middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function middleware(request: NextRequest) {
  // 1. 讀 cookies
  const token = request.cookies.get("token")?.value;

  // 2. 未登入訪問受保護路徑 → redirect
  if (request.nextUrl.pathname.startsWith("/dashboard") && !token) {
    return NextResponse.redirect(new URL("/login", request.url));
  }

  // 3. 其他情況放行
  return NextResponse.next();
}

三種回應方式

// 放行
return NextResponse.next();

// 重新導向(會更新 URL)
return NextResponse.redirect(new URL("/login", request.url));

// 重寫(URL 不變,內部 render 其他路徑)
return NextResponse.rewrite(new URL("/maintenance", request.url));

在 Middleware 設定 headers / cookies

export function middleware(request: NextRequest) {
  const response = NextResponse.next();

  // 設 response header
  response.headers.set("x-custom-header", "value");

  // 設 cookie
  response.cookies.set("visited", "true", {
    httpOnly: true,
    secure: true,
    maxAge: 60 * 60 * 24,
  });

  return response;
}

matcher 與條件

config.matcher 過濾要套用的路徑

// src/middleware.ts
export const config = {
  matcher: ["/dashboard/:path*", "/admin/:path*"],
};

多個 matcher

export const config = {
  matcher: [
    "/api/:path*",
    "/dashboard/:path*",
    "/((?!_next/static|_next/image|favicon.ico).*)", // 排除特定路徑
  ],
};

條件式判斷(matcher 內部)

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // 不同路徑做不同處理
  if (pathname.startsWith("/api/")) {
    // API 路徑加 rate limit
    return handleRateLimit(request);
  }

  if (pathname.startsWith("/admin/")) {
    return handleAdminAuth(request);
  }

  return NextResponse.next();
}

配合 has / source 條件

export const config = {
  matcher: [
    {
      source: "/api/:path*",
      has: [{ type: "header", key: "x-api-key" }],
    },
  ],
};

Edge Runtime 限制

Middleware 預設在 Edge Runtime 執行(不是 Node.js)。Edge 環境有限制:

不可用 / 受限的 API

不可用 替代
fspath Edge 上沒檔案系統
crypto(Node 版本) Web Crypto API (globalThis.crypto)
部分 npm 套件(依賴 Node API) 找 Edge-compatible 替代
setTimeout 長時間 Edge function 執行時間有限
直接連 PostgreSQL(傳統 TCP) 用 HTTP-based DB(Neon、Supabase REST)

容量限制

  • Middleware bundle size 通常需 < 1MB(部署平台規定)
  • 執行時間 < 30s(Vercel 預設)

何時切換到 Node Runtime

需要用 Node API 的話,可在 page / route handler 內指定:

// page.tsx 或 route.ts
export const runtime = "nodejs"; // 預設
// 或
export const runtime = "edge";

但 Middleware 目前只能在 Edge(Next.js 16 仍是如此)。


Cookies / Headers / Redirects

在 Server Component 讀 cookies / headers

import { cookies, headers } from "next/headers";

export default async function Page() {
  // Next.js 15+ 是 async
  const cookieStore = await cookies();
  const headerStore = await headers();

  const userId = cookieStore.get("uid")?.value;
  const userAgent = headerStore.get("user-agent");

  return <div>UID: {userId}</div>;
}

一旦用了 cookies()headers(),整頁變 dynamic(詳見 rendering-and-cache.md

在 Server Action 內寫 cookies

"use server";
import { cookies } from "next/headers";

export async function login(formData: FormData) {
  // ... 驗證
  const cookieStore = await cookies();
  cookieStore.set("token", "xxx", {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    maxAge: 60 * 60 * 24 * 7,
  });
}

redirect 與 permanentRedirect

import { redirect, permanentRedirect } from "next/navigation";

// 302 暫時導向
redirect("/login");

// 308 永久導向(SEO 用)
permanentRedirect("/new-url");

notFound

import { notFound } from "next/navigation";

export default async function Page({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const post = await getPost(id);
  if (!post) notFound(); // 顯示最近的 not-found.tsx
  return <article>{post.title}</article>;
}

實戰範例

範例 1:完整登入流程(Middleware + Route Handler + Server Action)

// src/middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

const PROTECTED = ["/dashboard", "/settings", "/admin"];

export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
  const isProtected = PROTECTED.some((p) => pathname.startsWith(p));
  if (!isProtected) return NextResponse.next();

  const token = request.cookies.get("token")?.value;
  if (!token) {
    const url = request.nextUrl.clone();
    url.pathname = "/login";
    url.searchParams.set("from", pathname);
    return NextResponse.redirect(url);
  }

  // 驗證 token(簡化版 — 實際應該驗 signature)
  try {
    await verifyToken(token);
    return NextResponse.next();
  } catch {
    return NextResponse.redirect(new URL("/login", request.url));
  }
}

export const config = {
  matcher: ["/dashboard/:path*", "/settings/:path*", "/admin/:path*"],
};
// app/login/actions.ts
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";

export async function login(prevState: unknown, formData: FormData) {
  const email = formData.get("email") as string;
  const password = formData.get("password") as string;

  const user = await db.user.findUnique({ where: { email } });
  if (!user || !(await verifyPassword(password, user.passwordHash))) {
    return { error: "帳密錯誤" };
  }

  const token = await signToken({ uid: user.id });
  (await cookies()).set("token", token, {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    maxAge: 60 * 60 * 24 * 7,
  });

  redirect("/dashboard");
}

範例 2:A/B Test Middleware

// src/middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
  if (pathname !== "/") return NextResponse.next();

  // 已經分組過
  let variant = request.cookies.get("ab-variant")?.value;
  if (!variant) {
    variant = Math.random() < 0.5 ? "A" : "B";
  }

  // Rewrite 到對應 variant
  const url = request.nextUrl.clone();
  url.pathname = `/variants/${variant}`;
  const response = NextResponse.rewrite(url);

  response.cookies.set("ab-variant", variant, { maxAge: 60 * 60 * 24 * 30 });
  return response;
}

export const config = { matcher: "/" };

範例 3:相簿 Modal(Intercepting Routes)

app/
├── layout.tsx
├── @modal/
│   ├── default.tsx        ← null
│   └── (..)photos/[id]/page.tsx
├── photos/[id]/page.tsx   ← 完整頁面
└── feed/page.tsx          ← 點縮圖觸發攔截
// app/layout.tsx
export default function RootLayout({
  children,
  modal,
}: {
  children: React.ReactNode;
  modal: React.ReactNode;
}) {
  return (
    <html><body>
      {children}
      {modal}
    </body></html>
  );
}

// app/@modal/default.tsx
export default function Default() {
  return null;
}

// app/@modal/(..)photos/[id]/page.tsx
import Modal from "@/components/Modal";

export default async function PhotoModal({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const photo = await getPhoto(id);
  return (
    <Modal>
      <img src={photo.url} alt={photo.title} />
      <p>{photo.caption}</p>
    </Modal>
  );
}

常見問題

問題 1:Middleware 沒被執行

檢查清單

  1. 檔案位置正確?(src/middleware.tsmiddleware.ts 在 project root,不是 src/app/middleware.ts
  2. matcher 配置是否包含目標路徑?
  3. 是否有 export const config

問題 2:Middleware 內無法用 Prisma / DB

原因:Edge Runtime 不支援傳統 DB driver

解決方案

  1. 改用 HTTP-based DB(Neon、Supabase)
  2. 把 DB 操作移到 page / route handler(Node runtime)
  3. Middleware 只驗 JWT,不查 DB

問題 3:Parallel Routes slot 顯示舊內容

原因:缺少 default.tsx,導航後該 slot 沒匹配新路徑

解決方案:每個 slot 都加 default.tsx

// 至少要 export 一個 default function
export default function Default() {
  return null;
}

問題 4:Intercepting Routes 路徑層數搞錯

規則

  • (.) = 同層
  • (..) = 上一層
  • (..)(..) = 上兩層
  • (...) = 從 root

⚠️ route group 不算層級app/(marketing)/photos 的「上一層」對攔截來說還是 root。


問題 5:Route Handler 在 dev 模式吃快取

原因:開發環境某些 Route Handler 預設不快取,但 production 會

解決方案:明確設定

export const dynamic = "force-dynamic";
// 或
export const revalidate = 0;

總結

核心要點

App Router 進階 = Parallel Routes + Intercepting Routes + Middleware + Route Handlers

使用情境速查

  • 並排儀表板 → Parallel Routes (@slot)
  • 點 thumbnail 開 modal → Intercepting Routes ((..))
  • 全站 auth / A/B / 語系 → Middleware
  • 對外 API / webhook / streaming → Route Handlers

符號速查

符號 用途
@name Parallel Routes slot
(.)foo 攔截同層 foo
(..)foo 攔截上一層 foo
(...)foo 攔截 root 層 foo
(name) Route Group(不影響 URL,跟攔截無關)

Middleware 設計準則

  • ✅ 短小、純判斷邏輯
  • ✅ 配合 matcher 限制範圍
  • ✅ 用 HTTP-based service 取代直連 DB
  • ❌ 不要做重量級資料處理(Edge runtime 有時間限制)
  • ❌ 不要存放秘密金鑰(會打包進 edge bundle)

相關閱讀


參考資源


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

🔗相關文章