第 2 章

App Router 文件系统路由完全指南

第2章:App Router 文件系统路由完全指南

在 App Router 中,目录结构即路由结构,文件名即功能角色。看目录树就知道应用有哪些路由,看文件名就知道各文件的职责。

本章核心问题:App Router 的文件系统路由如何工作?七个约定文件各自扮演什么角色?

读完本章你将理解


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 — 错误边界。必须是客户端组件,接收 errorreset 两个 props。

not-found.tsx — 在路由不存在或调用 notFound() 函数时触发。

route.ts — 将目录变为 HTTP 端点(API 路由),同一目录不能同时存在 page.tsxroute.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} />;
}

generateMetadatapage 组件调用相同的数据函数,但 Next.js 通过 React 的 cache() 机制对这些请求进行去重——数据库只会被查询一次。

route.ts 与 page.tsx 的本质区别

两者共存的唯一约束是:同一目录不能同时有 page.tsxroute.ts。这是因为它们代表了对同一 URL 路径的不同处理方式:

// 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.tsxlayout.tsxparamssearchParams 都是 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.tsxlayout.tsxloading.tsxerror.tsxnot-found.tsxroute.ts,以及下一章要讲的 template.tsx)构成了路由系统的完整词汇表。代码共置特性让组件和路由放在一起,而不会造成意外的 URL 污染。理解这套约定,是掌握 App Router 所有高级特性的前提。

本章评分
4.6  / 5  (93 评分)

💬 留言讨论