Next.js 中的 App Router:Layout、Page 和 Route

1. 概述

App Router 是 Next.js 13 及更高版本中的默认路由机制,逐步替代了 Pages Router (pages/)。它基于 app/ 文件夹中的文件约定:page.tsx 创建路由,layout.tsx 创建共享 UI,route.ts 创建 API 端点。阅读完本文后,你将了解如何按照 App Router 组织 Next.js 项目,编写嵌套 layout,创建动态路由和路由处理程序,以及如何测试这些组件。

2. 基本文件夹结构

App Router 使用 文件夹 = URL 段。app/ 中的每个文件夹都可以包含决定路由行为的特殊文件。

app/
├── layout.tsx          # 根布局,必须包含 <html>、<body>
├── page.tsx             # 路由 "/"
├── about/
│   └── page.tsx         # 路由 "/about"
├── products/
│   ├── layout.tsx       # /products/* 的独立布局
│   ├── page.tsx         # 路由 "/products"
│   └── [id]/
│       └── page.tsx     # 路由 "/products/123"
└── api/
    └── orders/
        └── route.ts     # API "/api/orders"
  • layout.tsx 包装同级或更深层的所有 page.tsx。
  • page.tsx 是实际为 URL 呈现内容的端点。
  • route.ts 不呈现 UI,它返回类似 REST 端点的 Response。

注意: 文件夹名称不以小写字母开头(例如 Products)仍然可以工作,但 Next.js 建议使用 kebab-case 或统一的 camelCase,以避免在区分大小写的操作系统上出现混淆。

3. Page — 定义路由

文件 page.tsx 导出一个 React 组件作为路由的主要 UI。默认情况下它是 Server Component。

// app/products/page.tsx
import { ProductService } from "@/services/product-service";

export default async function ProductsPage() {
  const products = await ProductService.findAll();

  return (
    <main>
      <h1>产品列表</h1>
      <ul>
        {products.map((product) => (
          <li key={product.id}>{product.name} - {product.price}đ</li>
        ))}
      </ul>
    </main>
  );
}
  • async function ProductsPage 允许在组件中直接 await 数据,因为这是 Server Component,不需要 useEffect 或 getServerSideProps。
  • Next.js 自动在服务器端呈现 HTML,向浏览器发送已有内容的页面 — 这对 SEO 和首次显示时间很有利。
  • 路由对应于 URL /products。

3.1. 带参数的动态路由

// app/products/[id]/page.tsx
import { ProductService } from "@/services/product-service";
import { notFound } from "next/navigation";

type ProductPageProps = {
  params: { id: string };
};

export default async function ProductDetailPage({ params }: ProductPageProps) {
  const product = await ProductService.findById(params.id);

  if (!product) {
    notFound();
  }

  return <h1>{product.name}</h1>;
}
  • 文件夹 [id] 创建动态段,Next.js 将实际值注入 params.id。
  • notFound() 呈现最近的 not-found.tsx 文件,而不是让应用崩溃 — 这是 App Router 中处理 404 的标准方式。

4. Layout — 共享 UI

Layout 接收 children prop(路由子页面),在同一 layout 的页面之间导航时保持状态(例如侧边栏不会重新渲染)。

// app/products/layout.tsx
export default function ProductsLayout({ children }: { children: React.ReactNode }) {
  return (
    <div className="products-layout">
      <aside>产品过滤器</aside>
      <section>{children}</section>
    </div>
  );
}
  • children 是 app/products/page.tsx 或 app/products/[id]/page.tsx 的内容。
  • 从 /products 导航到 /products/123 时,<aside> 不会被卸载/重新挂载 — 这是相对于在每个 page 中自己导入共享组件的主要优势。

4.1. 根布局是必需的

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

export const metadata: Metadata = {
  title: "Nón Lá 商店",
  description: "App Router 演示商店",
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="zh">
      <body>
        <header>Nón Lá 商店</header>
        {children}
      </body>
    </html>
  );
}
  • 根布局必须包含 <html> 和 <body>,因为它是最高级布局,包装整个应用程序。
  • export const metadata 是声明 <title> 和 <meta> 的方式,Next.js 自动将其注入 <head> — 无需手动操作 DOM。

注意: 如果忘记创建 app/layout.tsx,Next.js 会在构建时立即报错,因为每条路由都需要至少一个根布局。

5. Route Groups 和 Parallel Segments

有时你想要对路由进行分组来组织代码,但不希望该分组出现在 URL 中。使用带有一对圆括号 (name) 的 Route Group。

app/
├── (marketing)/
│   ├── layout.tsx       # 营销页面的独立布局
│   ├── page.tsx         # 路由 "/"
│   └── pricing/
│       └── page.tsx     # 路由 "/pricing"
└── (dashboard)/
    ├── layout.tsx       # 仪表板的独立布局
    └── settings/
        └── page.tsx     # 路由 "/settings"
  • (marketing) 和 (dashboard) 不会出现在 URL 中,它们只是帮助为不同页面组应用不同的 layout。
  • /pricing 和 /settings 使用不同的 layout,尽管它们都在 app/ 下。

6. Route Handlers — 在 App Router 中构建 API

文件 route.ts 替代了 Pages Router 的 pages/api/*.ts,导出以 HTTP 方法命名的函数。

// app/api/orders/route.ts
import { NextRequest, NextResponse } from "next/server";
import { OrderService } from "@/services/order-service";

export async function GET() {
  const orders = await OrderService.findAll();
  return NextResponse.json(orders);
}

export async function POST(request: NextRequest) {
  const body = await request.json();
  const order = await OrderService.create(body);
  return NextResponse.json(order, { status: 201 });
}
  • GET 和 POST 是两个单独的导出,Next.js 根据 HTTP 方法自动将请求路由到正确的函数。
  • NextResponse.json(...) 自动设置 Content-Type: application/json,你无需手动 JSON.stringify。
  • 该端点在 /api/orders 提供服务,完全独立于 UI — 不呈现 React。

6.1. 动态路由处理程序

// app/api/orders/[id]/route.ts
import { NextResponse } from "next/server";
import { OrderService } from "@/services/order-service";

export async function GET(_: Request, { params }: { params: { id: string } }) {
  const order = await OrderService.findById(params.id);

  if (!order) {
    return NextResponse.json({ message: "找不到订单" }, { status: 404 });
  }

  return NextResponse.json(order);
}
  • 与动态 page 类似,文件夹 [id] 允许通过 params.id 从 URL 获取值。
  • 明确返回 status: 404 而不是让客户端自己推测错误。

7. 特殊文件:loading、error、not-found

App Router 通过文件约定在后台使用 React Suspense 和 Error Boundary,你无需自己编写边界组件。

// app/products/loading.tsx
export default function ProductsLoading() {
  return <p>正在加载产品列表...</p>;
}
// app/products/error.tsx
"use client";

export default function ProductsError({ error, reset }: { error: Error; reset: () => void }) {
  return (
    <div>
      <p>错误:{error.message}</p>
      <button onClick={reset}>重试</button>
    </div>
  );
}
  • 当同一段的 page.tsx 在 await 数据时,loading.tsx 自动显示 — 无需 useState(isLoading)。
  • error.tsx 必须是 Client Component("use client"),因为它使用 reset — 一个客户端交互函数,用于重新呈现出错的段。
  • Next.js 根据这些文件自动使用 <Suspense> 和对应的错误边界包装段。

8. Metadata 和嵌套布局

每个段可以导出自己的 metadata,Next.js 按照从根布局到 page 的顺序合并它们。

// app/products/[id]/page.tsx
import type { Metadata } from "next";
import { ProductService } from "@/services/product-service";

type Props = { params: { id: string } };

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const product = await ProductService.findById(params.id);
  return { title: product ? `${product.name} - Nón Lá 商店` : "未找到" };
}

export default async function ProductDetailPage({ params }: Props) {
  const product = await ProductService.findById(params.id);
  return <h1>{product?.name}</h1>;
}
  • generateMetadata 是一个动态函数,允许根据产品的实际数据设置 <title> — 对详情页面的 SEO 很有用。
  • page 的 metadata 覆盖父 layout 中同名字段,其他字段保持不变。

9. 测试 page 组件

对于异步 Server Component,最简单的测试方式是直接调用组件函数并检查返回的 JSX,或在渲染后使用 React Testing Library。

<!-- package.json(devDependencies 部分,简化) -->
<dependencies>
  <vitest>^2.0.0</vitest>
  <testing-library-react>^16.0.0</testing-library-react>
</dependencies>
// services/product-service.test.ts
import { describe, it, expect, vi } from "vitest";
import { ProductService } from "@/services/product-service";
import ProductsPage from "@/app/products/page";
import { render, screen } from "@testing-library/react";

vi.mock("@/services/product-service", () => ({
  ProductService: {
    findAll: vi.fn(),
  },
});

describe("ProductsPage", () => {
  it("显示从服务返回的产品列表", async () => {
    (ProductService.findAll as any).mockResolvedValue([
      { id: "1", name: "Nón Lá A", price: 50000 },
    ]);

    const jsx = await ProductsPage();
    render(jsx);

    expect(screen.getByText("Nón Lá A - 50000đ")).toBeInTheDocument();
  });
});
  • ProductsPage() 被作为纯异步函数调用,因为本质上它只是返回 JSX 的函数 — 不需要特殊的 Server Component 测试框架。
  • vi.mock 用 stub 替换 ProductService.findAll,返回假数据,帮助测试不依赖真实数据库。
  • render(jsx) 来自 Testing Library,检查呈现的 JSX 是否正确包含预期的内容。

10. 常见错误和何时不应使用

  • 在 Client Component 中使用 useState/useEffect 时忘记 "use client":Server Component 不支持这些 hook,Next.js 会在构建时报出明确错误。
  • 在 Client Component 中放置敏感数据获取逻辑(API 密钥):因为代码会被打包到浏览器中。始终优先在 Server Component 或 Route Handler 中获取数据。
  • 滥用 route group 只是为了“看起来漂亮”:如果没有真正不同的 layout 需求,route group 只会让文件夹结构变得更复杂。
  • 混合 pages/ 和 app/ 处理同一路由:两个路由器不能同时处理一个 URL,Next.js 将优先选择 app/,pages/ 中的路由将被忽略 — 在逐步迁移时容易造成混淆。

注意: 如果你的项目在 Pages Router 下运行良好,并且不需要 Server Components、Streaming 或 Server Actions,立即迁移到 App Router 并不总是必要的。在迁移整个路由系统之前,请评估好处。

11. 总结

App Router 通过三种主要文件类型组织 Next.js 应用程序:page.tsx 定义路由,layout.tsx 在多个路由之间共享 UI,route.ts 构建不呈现 HTML 的 API。结合 loading.tsx、error.tsx 和 not-found.tsx 等文件约定,你可以构建流畅的加载/错误体验,而无需自己编写边界组件。设计文件夹树时,优先对需要真正共享的 layout 进行分组,避免不必要的 route group,并始终检查 Server/Client Component 的边界,以防止敏感逻辑泄露到浏览器中。

Next.jsApp RouterReactServer Components路由