第二章:路由系统

App Router 文件系统路由与嵌套布局

01 App Router 简介

自 Next.js 13 起,框架引入 App Router:在 app/ 目录下用文件与文件夹描述 URL,并默认基于 React Server Components 组织页面与数据获取逻辑。

与 Pages Router(pages/)相比

  • 路由入口由 page.tsx / layout.tsx 等约定文件驱动,而不是单一的 pages/*.tsx
  • 布局可嵌套、共享 UI 与加载状态;数据获取更自然地贴近组件树(含服务端组件)。
  • 本章只聚焦 App Router;旧项目若仍用 Pages Router,可查阅官方迁移指南逐步切换。

要点

Next.js 15 默认以 App Router 为主流;新项目请优先使用 app/ 目录,与官方文档和生态示例保持一致。

02 文件系统路由基础

在 App Router 中,page.tsx(或 page.js)声明该路径下的页面 UI。目录层级即 URL 层级(在 app 根之下)。

常见映射(以 src/app 为例):

最小可用的首页示例:

// src/app/page.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "首页",
  description: "站点首页",
};

export default function HomePage() {
  return (
    <main className="min-h-screen p-8">
      <h1 className="text-3xl font-bold">欢迎来到首页</h1>
      <p className="mt-4 text-gray-600">这是由 app/page.tsx 渲染的路由 /</p>
    </main>
  );
}

要点

只有包含 page.tsx(或 page.js)的目录才会成为可访问的「路由段」;仅有文件夹而没有 page 时,该路径通常无法直接打开(可用于分组、布局或私有模块)。

03 布局 (Layout)

layout.tsx 包裹同级及以下路由,用于放置导航栏、侧边栏、主题容器等。在子路由之间切换时,布局默认保持挂载状态,因此适合放不需要随页面重置的全局 UI 或上下文(注意与「模板」的区别见下)。

根布局(必须包含 <html><body>):

// src/app/layout.tsx
import type { Metadata } from "next";
import "./globals.css";

export const metadata: Metadata = {
  title: { default: "我的站点", template: "%s | 我的站点" },
  description: "根布局提供全站 HTML 外壳",
};

export default function RootLayout({
  children,
}: Readonly<{ children: React.ReactNode }>) {
  return (
    <html lang="zh-CN">
      <body className="antialiased">{children}</body>
    </html>
  );
}

嵌套布局示例(例如控制台子树共享侧栏):

// src/app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
}: { children: React.ReactNode }) {
  return (
    <div className="flex min-h-screen">
      <aside className="w-56 border-r p-4">侧栏</aside>
      <section className="flex-1 p-6">{children}</section>
    </div>
  );
}

// src/app/dashboard/page.tsx → URL: /dashboard
export default function DashboardHome() {
  return <h1>控制台首页</h1>;
}

template.tsxlayout.tsx

两者都可包装子路由,但 template 在每次导航时都会重新挂载(内部 state 会重置、useEffect 会再跑),适合需要进场动画或强制刷新子树的场景;layout 在导航时保持挂载,适合持久 shell。

04 动态路由

文件夹名使用方括号表示动态段。Next.js 15 中,在服务端页面/布局里,params(以及 searchParams)为 Promise,需在 async 组件内 await

单段动态 [slug]

// src/app/posts/[slug]/page.tsx
type Props = { params: Promise<{ slug: string }> };

export default async function PostPage({ params }: Props) {
  const { slug } = await params;
  return (
    <article>
      <h1>文章:{slug}</h1>
    </article>
  );
}

Catch-all [...slug]

// src/app/docs/[...slug]/page.tsx
type Props = { params: Promise<{ slug: string[] }> };

export default async function DocsPage({ params }: Props) {
  const { slug } = await params; // 例如 /docs/a/b → ['a','b']
  return <pre>{JSON.stringify(slug, null, 2)}</pre>;
}

Optional catch-all [[...slug]]

// src/app/wiki/[[...slug]]/page.tsx
type Props = { params: Promise<{ slug?: string[] }> };

export default async function WikiPage({ params }: Props) {
  const { slug } = await params;
  const segments = slug ?? [];
  return <p>段落:{segments.join(" / ") || "(根)"}</p>;
}

三种模式对比

模式 目录示例 URL 示例 params(await 后)
[slug] app/posts/[slug] /posts/hello { slug: 'hello' }
[...slug] app/docs/[...slug] /docs/api/auth { slug: ['api','auth'] }
[[...slug]] app/wiki/[[...slug]] /wiki/wiki/a/b { slug?: string[] },根路径可能为 undefined

05 路由组与特殊文件

括号文件夹名 (segment)路由组:仅用于组织代码与共享布局,不会出现在 URL 中。例如 app/(marketing)/page.tsx 仍对应 /

src/app/
  (marketing)/
    layout.tsx      # 营销区布局
    page.tsx        # /
  (app)/
    dashboard/
      page.tsx      # /dashboard

loading.tsx

在对应路由段显示加载 UI,并由框架用 Suspense 包裹;适合列表、慢数据占位。

// src/app/dashboard/loading.tsx
export default function Loading() {
  return <p className="animate-pulse">加载中…</p>;
}

error.tsx

错误边界,必须是客户端组件,接收 errorreset

// src/app/dashboard/error.tsx
"use client";

export default function DashboardError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div>
      <h2>出错了</h2>
      <button type="button" onClick={() => reset()}>
        重试
      </button>
    </div>
  );
}

not-found.tsx

当调用 notFound() 或没有匹配路由时展示。

// src/app/not-found.tsx
import Link from "next/link";

export default function NotFound() {
  return (
    <div>
      <h1>404</h1>
      <p>页面不存在</p>
      <Link href="/">返回首页</Link>
    </div>
  );
}

06 导航

App Router 中客户端导航请使用 next/linknext/navigation(不要使用旧版 next/router 中的 useRouter 写 App Router 页面逻辑)。

Link:声明式导航 + 预取

import Link from "next/link";

export default function Nav() {
  return (
    <nav className="flex gap-4">
      <Link href="/about" prefetch>
        关于
      </Link>
      <Link href="/posts/first" replace>
        文章(替换历史记录)
      </Link>
    </nav>
  );
}

useRouter:编程式导航(客户端组件)

"use client";

import { useRouter } from "next/navigation";

export function GoDashboardButton() {
  const router = useRouter();
  return (
    <button type="button" onClick={() => router.push("/dashboard")}>
      进入控制台
    </button>
  );
}

redirect:服务端 / Server Action 中重定向

import { redirect } from "next/navigation";

export default async function AdminPage() {
  const role = "guest"; // 实际应从会话或数据库读取
  if (role !== "admin") {
    redirect("/login");
  }
  return <h1>管理后台</h1>;
}

usePathnameuseSearchParams(客户端)

"use client";

import { usePathname, useSearchParams } from "next/navigation";

export function DebugRoute() {
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const tab = searchParams.get("tab");
  return (
    <p>
      当前路径:{pathname};查询参数 tab:{tab ?? "无"}
    </p>
  );
}

提示:含 useSearchParams 的客户端子树在静态渲染场景下可能需要外层 <Suspense> 包裹以避免构建警告,详见官方文档「静态渲染与动态 API」。

07 本章要点

路由入口

page.tsx 定义可访问路径;目录结构映射 URL。

布局持久化

layout.tsx 嵌套包裹子路由;template.tsx 每次导航重新挂载。

动态段

[][...][[...]] 覆盖单段、多级与可选多级;Next.js 15 服务端中 await params

体验与边界

loading / error / not-found 分工明确;路由组 () 组织代码不改 URL。

导航 API

LinkuseRouterredirectusePathnameuseSearchParams 覆盖声明式、命令式与地址解析。