App Router 文件系统路由完全指南
第2章:App Router 文件系统路由完全指南
在 App Router 中,目录结构即路由结构,文件名即功能角色。看目录树就知道应用有哪些路由,看文件名就知道各文件的职责。
本章核心问题:App Router 的文件系统路由如何工作?七个约定文件各自扮演什么角色?
读完本章你将理解:
app/目录结构与 URL 的映射关系page.tsx、layout.tsx、loading.tsx、error.tsx、not-found.tsx、route.ts六个约定文件的用途- 代码共置(colocation)如何让组件和路由放在一起而不污染 URL
Level 1 · 你需要知道的(1-3年经验)
文件系统即路由声明
App Router 的核心设计理念之一是:目录结构即路由结构。你在 app/ 目录下创建的文件夹,直接决定了 URL 的形态。这不是新鲜概念——Pages Router 也这样做——但 App Router 将这个概念推进了一大步:不仅路由由文件系统决定,组件的功能角色也由文件名决定。
一个典型的 Next.js 15 项目的 app/ 目录可能长这样:
app/
├── layout.tsx # 根布局(必须存在)
├── page.tsx # 首页 /
├── globals.css
├── blog/
│ ├── layout.tsx # 博客区域共享布局
│ ├── page.tsx # /blog
│ └── [slug]/
│ ├── page.tsx # /blog/:slug
│ └── loading.tsx # /blog/:slug 的加载状态
├── dashboard/
│ ├── layout.tsx
│ ├── page.tsx # /dashboard
│ └── settings/
│ └── page.tsx # /dashboard/settings
└── api/
└── posts/
└── route.ts # API: GET/POST /api/posts
这个目录树直接映射到 URL 树。理解这个映射关系,是掌握 App Router 的第一步。
约定文件:每个文件名都有意义
App Router 规定了一组特殊文件名,这些文件在路由中扮演特定角色。同一目录下的非约定文件(如 components/、utils.ts)不会成为路由——这是 App Router 相比 Pages Router 的重要改进,让代码**共置(colocation)**成为可能。
page.tsx — 路由的入口
page.tsx 是让一个目录成为可访问路由的文件。没有 page.tsx,该目录仅仅是一个文件夹,不对应任何 URL。
// app/blog/page.tsx
// 访问路径:/blog
import { getPosts } from '@/lib/data';
export default async function BlogPage() {
const posts = await getPosts();
return (
<main>
<h1>Blog</h1>
<ul>
{posts.map(post => (
<li key={post.id}>
<a href={`/blog/${post.slug}`}>{post.title}</a>
</li>
))}
</ul>
</main>
);
}
page.tsx 导出的默认组件接收两个 props:params(动态路由参数)和 searchParams(URL 查询参数)。在 Next.js 15 中,这两个 props 都是 Promise,必须用 await 解构:
// app/blog/[slug]/page.tsx
interface Props {
params: Promise<{ slug: string }>;
searchParams: Promise<{ tab?: string }>;
}
export default async function PostPage({ params, searchParams }: Props) {
const { slug } = await params;
const { tab = 'content' } = await searchParams;
// ...
}
layout.tsx — 跨页面的持久 UI
layout.tsx 定义包裹子路由的持久 UI。与 page 不同,layout 在路由切换时不会重新渲染——它是持久的。这是实现导航栏、侧边栏、认证上下文的理想位置。
根布局(app/layout.tsx)是必须存在的文件,它必须包含 <html> 和 <body> 标签:
// app/layout.tsx — 根布局,整个应用的外壳
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: { template: '%s | My Site', default: 'My Site' },
description: 'A Next.js 15 application',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-CN">
<body className={inter.className}>
<header>
<nav>{/* 导航栏,所有页面共享 */}</nav>
</header>
<main>{children}</main>
<footer>{/* 页脚 */}</footer>
</body>
</html>
);
}
其他约定文件速览
loading.tsx — 自动 Suspense 边界。当该路由的 page.tsx 在服务器上进行异步操作时,loading.tsx 中的内容会立即展示给用户。
error.tsx — 错误边界。必须是客户端组件,接收 error 和 reset 两个 props。
not-found.tsx — 在路由不存在或调用 notFound() 函数时触发。
route.ts — 将目录变为 HTTP 端点(API 路由),同一目录不能同时存在 page.tsx 和 route.ts。
Level 2 · 它是怎么运行的(3-5年经验)
代码共置:非路由文件在 app/ 中
App Router 一个重要特性是:app/ 目录下除了约定文件名之外的其他文件,不会成为路由。这意味着你可以将组件、工具函数、样式文件直接放在使用它们的路由旁边:
app/
└── blog/
└── [slug]/
├── page.tsx # 路由入口(约定文件)
├── loading.tsx # 加载状态(约定文件)
├── error.tsx # 错误处理(约定文件)
├── PostContent.tsx # 组件(不是路由!)
├── AuthorCard.tsx # 组件(不是路由!)
└── utils.ts # 工具函数(不是路由!)
PostContent.tsx 不会被映射到 /blog/[slug]/PostContent 这个 URL。只有 page.tsx 才能成为可访问的路由页面。这比 Pages Router 好得多——Pages Router 中 pages/ 目录下的任何文件都会成为路由,导致很多团队不得不将组件放在 pages/ 之外,增加了项目结构的复杂性。
生成元数据
Next.js 15 提供了两种方式为页面生成 <head> 中的元数据:静态导出 metadata 对象,或动态导出 generateMetadata 函数。
// app/blog/[slug]/page.tsx — 动态元数据
import type { Metadata } from 'next';
interface Props {
params: Promise<{ slug: string }>;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
if (!post) {
return { title: '文章不存在' };
}
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [{ url: post.coverImage, width: 1200, height: 630 }],
},
};
}
export default async function PostPage({ params }: Props) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) notFound();
return <Article post={post} />;
}
generateMetadata 和 page 组件调用相同的数据函数,但 Next.js 通过 React 的 cache() 机制对这些请求进行去重——数据库只会被查询一次。
route.ts 与 page.tsx 的本质区别
两者共存的唯一约束是:同一目录不能同时有 page.tsx 和 route.ts。这是因为它们代表了对同一 URL 路径的不同处理方式:
page.tsx:返回 React 组件树,用于渲染 HTML 页面(或 RSC payload)route.ts:返回原始 HTTP 响应,是纯粹的 API 端点
// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const page = Number(searchParams.get('page') ?? 1);
const limit = Number(searchParams.get('limit') ?? 10);
const posts = await db.posts.findMany({
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: 'desc' },
});
return NextResponse.json({ posts, page, limit });
}
export async function POST(request: NextRequest) {
const body = await request.json();
const post = await db.posts.create({ data: body });
return NextResponse.json(post, { status: 201 });
}
Level 3 · 规范怎么定义的(资深)
App Router 的文件系统路由设计直接源自 Next.js RFC(Layouts RFC,2022 年 5 月发布)。该 RFC 的核心论点是:Pages Router 的单层路由模型无法满足现代应用的布局需求——嵌套布局、共享状态、独立加载状态都需要新的路由原语。
七个约定文件的设计遵循了"单一职责"原则:每个文件只负责一种关注点(渲染内容、处理加载、处理错误、处理 404)。这种设计让 Next.js 能够在编译时精确分析每个路由段的行为,而不需要运行时反射。
从 Web 标准的角度,route.ts 的设计基于 Web Fetch API(Request/Response),而非 Node.js 的 req/res。这意味着相同的代码可以在 Node.js、Edge Runtime、甚至 Deno 上运行,是跨运行时兼容的基础。
Level 4 · 边界与陷阱(所有人)
陷阱一:Next.js 15 中 params/searchParams 是 Promise
从 Next.js 14 升级到 15 时,最常见的报错就是 params 未 await。在 Next.js 15 中,page.tsx 和 layout.tsx 的 params 和 searchParams 都是 Promise,必须用 await 解构。忘记 await 会导致运行时错误或拿到一个 Promise 对象而非实际值。
陷阱二:在 app/ 中放置测试文件意外成为路由
虽然非约定文件名不会成为路由,但如果你在 app/ 下创建了 test/page.tsx 这样的目录结构,它就是一个真正的路由(/test)。建议将测试文件放在项目根目录的 __tests__/ 中,而不是 app/ 内部。
陷阱三:route.ts 的缓存行为
在 Next.js 15 中,GET 方法的 Route Handler 默认不缓存(与 Next.js 14 的行为不同)。如果你希望 API 响应被缓存,需要显式设置 export const dynamic = 'force-static' 或在 fetch 中使用 cache: 'force-cache'。
小结
App Router 文件系统路由的设计让路由结构自文档化:看目录树就知道应用有哪些路由,看文件名就知道各文件的功能。七个约定文件(page.tsx、layout.tsx、loading.tsx、error.tsx、not-found.tsx、route.ts,以及下一章要讲的 template.tsx)构成了路由系统的完整词汇表。代码共置特性让组件和路由放在一起,而不会造成意外的 URL 污染。理解这套约定,是掌握 App Router 所有高级特性的前提。