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 的边界,以防止敏感逻辑泄露到浏览器中。