Next.js: App Router 文件系统路由

最后更新:2026-08-26

文件系统路由就像图书馆的图书分类——文件名决定 URL 路径,目录结构就是你的路由表,无需任何手动配置。

1. 你将学到


2. 一个技术主管的真实故事

(1) 痛点:手动路由配置混乱

Bob 是一家电商公司的技术主管,团队维护着一个有 50 个页面的 React SPA。每次添加新页面,开发者都要手动配置 3 个文件:

"我们的路由配置表 routes.js 有 300 行。上周小明新加了一个商品详情页,忘了在路由表里注册 path: '/products/:id',结果上线 404 了整整 2 小时才被发现。"

Bob 团队遇到了这些路由管理问题:

问题 影响 频率
路由配置遗漏 上线后 404 每月 1-2 次
嵌套路由手动处理 代码复杂,路由层级混乱 每个新页面
404/500 错误页面分散 不一致,用户体验差 多个页面
API 路由独立维护 前端/后端各一套路由 每个 API

(2) Next.js 文件系统路由的解法

文件名即 URL,目录结构就是路由表——不再需要 react-router-dom 的路由配置。

TEXT 📖 仅展示
src/app/
├── page.tsx                    # → /
├── about/
│   └── page.tsx                # → /about
├── products/
│   ├── page.tsx                # → /products
│   └── [id]/
│       ├── page.tsx            # → /products/1, /products/2
│       └── reviews/
│           └── page.tsx        # → /products/1/reviews
└── api/
    └── products/
        └── route.ts            # → /api/products (GET/POST)

(3) 收益

维度 之前(React Router) 之后(文件系统路由)
添加页面步骤 3 步(建组件 + 配置路由 + 导入) 1 步(建文件)
路由配置维护 300 行路由表 零行,由目录自动确定
上线 404 事故 每月 1-2 次 0 次
404/500 统一管理 手动 import 文件约定自动生效

3. 文件系统路由原理

(1) 文件名即 URL

100%
graph TB
    subgraph "src/app/ 目录结构"
        A[page.tsx] --> B[/]
        C[about/page.tsx] --> D[/about]
        E[products/page.tsx] --> F[/products]
        G[products/promo/page.tsx] --> H[/products/promo]
        I[products/id/page.tsx] --> J[/products/:id]
        K[dashboard/settings/page.tsx] --> L[/dashboard/settings]
    end

    style A fill:#d4edda
    style C fill:#d4edda
    style E fill:#d4edda
文件名 对应 URL 说明
app/page.tsx / 首页
app/about/page.tsx /about 静态页面
app/blog/page.tsx /blog 博客列表
app/blog/[id]/page.tsx /blog/1 动态路由
app/dashboard/settings/page.tsx /dashboard/settings 嵌套多级

(2) 六大文件约定

文件名 作用 必须? 渲染行为
page.tsx 页面组件(UI 内容) Server Component 默认
layout.tsx 布局包裹(持久化状态) ✅ 根 layout 导航时不重新挂载
loading.tsx 加载骨架屏 可选 Suspense fallback
error.tsx 错误边界 UI 可选 捕获子组件错误
not-found.tsx 404 页面 可选 notFound() 触发
route.ts API 端点 可选 服务端运行

▶ 示例:创建第一个页面

TSX
// ============================================
// 创建 /about 页面
// 文件:src/app/about/page.tsx
// 只需创建文件,路由自动注册
// ============================================

export default function AboutPage() {
  return (
    <div className="max-w-2xl mx-auto p-8">
      <h1 className="text-3xl font-bold">About TaskFlow</h1>
      <p className="mt-4 text-gray-600 leading-relaxed">
        TaskFlow is a collaborative project management platform that helps
        teams plan, track, and deliver projects efficiently. Built with
        Next.js 16 and React 19, it provides real-time updates, seamless
        collaboration, and enterprise-grade security.
      </p>
      <div className="mt-8 grid grid-cols-3 gap-4">
        <div className="p-4 bg-blue-50 rounded-lg text-center">
          <div className="text-2xl font-bold text-blue-600">10K+</div>
          <div className="text-sm text-gray-500">Active Users</div>
        </div>
        <div className="p-4 bg-green-50 rounded-lg text-center">
          <div className="text-2xl font-bold text-green-600">50K+</div>
          <div className="text-sm text-gray-500">Projects</div>
        </div>
        <div className="p-4 bg-purple-50 rounded-lg text-center">
          <div className="text-2xl font-bold text-purple-600">99.9%</div>
          <div className="text-sm text-gray-500">Uptime</div>
        </div>
      </div>
    </div>
  );
}

输出:

TEXT 📖 仅展示
访问 http://localhost:3000/about 看到:
About TaskFlow
TaskFlow is a collaborative project management platform...
10K+  Active Users | 50K+  Projects | 99.9%  Uptime

4. 动态路由

(1) 单参数动态路由 [id]

100%
graph LR
    A[app/products] --> B[page.tsx → /products]
    A --> C[[id]]
    C --> D[page.tsx → /products/1]
    C --> E[/products/2]

    style B fill:#d4edda
    style D fill:#cce5ff
文件模式 URL 示例 params
app/blog/[slug]/page.tsx /blog/hello-world { slug: 'hello-world' }
app/products/[id]/page.tsx /products/42 { id: '42' }
app/users/[userId]/settings/page.tsx /users/123/settings { userId: '123' }

▶ 示例:商品详情动态页面

TSX
// ============================================
// 商品详情页 — 动态路由 [id]
// 文件:src/app/products/[id]/page.tsx
// ============================================

// Next.js 16 中 params 是 Promise(异步)
export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const product = await fetch(`https://fakestoreapi.com/products/${id}`);

  return (
    <div className="container mx-auto p-8">
      <div className="flex gap-8">
        <img
          src={product.image}
          alt={product.title}
          className="w-80 h-80 object-contain"
        />
        <div>
          <h1 className="text-2xl font-bold">{product.title}</h1>
          <p className="text-xl text-green-600 font-bold mt-2">
            ${product.price}
          </p>
          <p className="text-sm text-gray-500 mt-1">
            Category: {product.category}
          </p>
          <p className="mt-4 text-gray-700">{product.description}</p>
        </div>
      </div>
    </div>
  );
}

输出:

TEXT 📖 仅展示
访问 /products/1 看到:
Fjallraven - Foldsack No. 1 Backpack
$109.95
Category: men's clothing
Your perfect pack for day trips and hikes...

(2) Catch-all 路由 [...slug]

100%
graph TB
    A[app/docs] --> B[[...slug]]
    B --> C[page.tsx]
    C --> D[/docs/getting-started]
    C --> E[/docs/guides/installation]
    C --> F[/docs/api/authentication/overview]

    style C fill:#cce5ff
文件模式 URL params.slug
app/docs/[...slug]/page.tsx /docs 404(必须有一级)
app/docs/[...slug]/page.tsx /docs/getting-started ['getting-started']
app/docs/[...slug]/page.tsx /docs/guides/installation ['guides', 'installation']
app/docs/[...slug]/page.tsx /docs/a/b/c ['a', 'b', 'c']

▶ 示例:Catch-all 文档页面

TSX
// ============================================
// Catch-all 路由 — 多级文档页面
// 文件:src/app/docs/[...slug]/page.tsx
// ============================================

export default async function DocsPage({
  params,
}: {
  params: Promise<{ slug: string[] }>;
}) {
  const { slug } = await params;

  return (
    <div className="container mx-auto p-8">
      <nav className="text-sm text-gray-500 mb-4">
        Home / Docs / {slug.join(" / ")}
      </nav>
      <h1 className="text-3xl font-bold">Docs: {slug.join(" > ")}</h1>
      <div className="mt-8 p-6 bg-yellow-50 border border-yellow-200 rounded-lg">
        <p className="font-medium">You are viewing documentation for:</p>
        <ul className="mt-2 list-disc list-inside">
          {slug.map((segment, index) => (
            <li key={index}>
              Level {index + 1}: <code className="bg-gray-100 px-2 py-0.5 rounded">{segment}</code>
            </li>
          ))}
        </ul>
      </div>
      <p className="mt-4 text-gray-600">
        Total depth: {slug.length} levels
      </p>
    </div>
  );
}

输出:

TEXT 📖 仅展示
访问 /docs/getting-started/installation 看到:
Home / Docs / getting-started / installation
Docs: getting-started > installation

You are viewing documentation for:
  - Level 1: getting-started
  - Level 2: installation
Total depth: 2 levels

(3) 可选 Catch-all [[...catchAll]]

100%
graph TB
    A[app/categories] --> B[[[...catchAll]]]
    B --> C[page.tsx]
    C --> D[/categories]
    C --> E[/categories/electronics]
    C --> F[/categories/electronics/phones]

    style C fill:#d4edda
    style D fill:#d4edda
文件模式 URL params.catchAll
app/categories/[[...catchAll]]/page.tsx /categories undefined
app/categories/[[...catchAll]]/page.tsx /categories/electronics ['electronics']
app/categories/[[...catchAll]]/page.tsx /categories/electronics/phones ['electronics', 'phones']

▶ 示例:可选 Catch-all 分类页面

TSX
// ============================================
// 可选 Catch-all — 分类浏览页面
// 文件:src/app/categories/[[...categories]]/page.tsx
// /categories 和 /categories/electronics 都生效
// ============================================

export default async function CategoriesPage({
  params,
}: {
  params: Promise<{ categories?: string[] }>;
}) {
  const { categories } = await params;

  const allProducts = await fetch("https://fakestoreapi.com/products");
  const categoriesList = [...new Set(allProducts.map(p => p.category))];

  const filteredProducts = categories
    ? allProducts.filter(p => p.category === categories[0])
    : allProducts;

  return (
    <div className="container mx-auto p-8">
      <h1 className="text-3xl font-bold mb-6">
        {categories ? `Category: ${categories[0]}` : "All Categories"}
      </h1>

      {!categories && (
        <div className="flex gap-2 mb-8 flex-wrap">
          {categoriesList.map(cat => (
            <a
              key={cat}
              href={`/categories/${encodeURIComponent(cat)}`}
              className="px-4 py-2 bg-gray-100 rounded-full hover:bg-blue-100"
            >
              {cat}
            </a>
          ))}
        </div>
      )}

      <div className="grid grid-cols-4 gap-6">
        {filteredProducts.slice(0, 8).map(p => (
          <div key={p.id} className="border rounded-lg p-4 hover:shadow-lg">
            <img src={p.image} alt={p.title} className="h-40 mx-auto" />
            <p className="mt-2 font-medium text-sm truncate">{p.title}</p>
            <p className="text-green-600 font-bold">${p.price}</p>
          </div>
        ))}
      </div>
    </div>
  );
}

输出:

TEXT 📖 仅展示
访问 /categories 看到:
所有分类按钮(electronics, jewelery, men's clothing, women's clothing)

访问 /categories/electronics 看到:
Category: electronics
只显示电子产品(8个商品卡片)

5. loading.tsx 与 error.tsx

(1) loading.tsx — 加载骨架屏

100%
graph LR
    A[用户导航] --> B[loading.tsx<br/>骨架屏]
    B --> C[page.tsx<br/>数据就绪]
    C --> D[完整页面]

    style B fill:#f8d7da
    style C fill:#d4edda

▶ 示例:加载骨架屏

TSX
// ============================================
// 加载骨架屏 — 页面数据获取时显示
// 文件:src/app/products/loading.tsx
// ============================================

export default function ProductsLoading() {
  return (
    <div className="container mx-auto p-8">
      <div className="h-8 w-48 bg-gray-200 rounded animate-pulse mb-6" />
      <div className="grid grid-cols-4 gap-6">
        {Array.from({ length: 8 }).map((_, i) => (
          <div key={i} className="border rounded-lg p-4">
            <div className="h-40 bg-gray-200 rounded animate-pulse" />
            <div className="h-4 bg-gray-200 rounded mt-2 animate-pulse" />
            <div className="h-4 w-16 bg-gray-200 rounded mt-2 animate-pulse" />
          </div>
        ))}
      </div>
    </div>
  );
}

输出:

TEXT 📖 仅展示
访问 /products 时,数据加载完成前显示:
8 个灰色占位卡片(带脉冲动画)
数据加载完成后,骨架屏自动替换为实际内容
无闪烁、无白屏,过渡平滑

(2) error.tsx — 错误边界

▶ 示例:错误边界页面

TSX
// ============================================
// 错误边界 — 捕获子组件错误
// 文件:src/app/products/error.tsx
// ============================================

'use client';

export default function ProductsError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div className="container mx-auto p-8 text-center">
      <div className="max-w-md mx-auto">
        <h2 className="text-2xl font-bold text-red-600 mb-4">
          Something went wrong!
        </h2>
        <p className="text-gray-600 mb-6">
          {error.message || "Failed to load products. Please try again."}
        </p>
        <button
          onClick={reset}
          className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
        >
          Try Again
        </button>
      </div>
    </div>
  );
}

输出:

TEXT 📖 仅展示
当产品页面数据获取失败时,显示:
Something went wrong!
Failed to load products. Please try again.
[Try Again] 按钮 → 点击自动重试

(3) not-found.tsx — 404 页面

▶ 示例:自定义 404 页面

TSX
// ============================================
// 404 页面 — 路由不存在时显示
// 文件:src/app/not-found.tsx
// ============================================

import Link from "next/link";

export default function NotFound() {
  return (
    <div className="min-h-screen flex items-center justify-center">
      <div className="text-center">
        <h1 className="text-6xl font-bold text-gray-300">404</h1>
        <h2 className="text-2xl font-bold mt-4">Page Not Found</h2>
        <p className="text-gray-500 mt-2">
          The page you are looking for does not exist.
        </p>
        <Link
          href="/"
          className="inline-block mt-6 px-6 py-2 bg-blue-600 text-white rounded-lg"
        >
          Go Home
        </Link>
      </div>
    </div>
  );
}

输出:

TEXT 📖 仅展示
访问不存在的 URL(如 /xyz),浏览器显示:
404
Page Not Found
The page you are looking for does not exist.
[Go Home] 链接

6. Route Handlers (API 路由)

(1) route.ts 基本用法

100%
graph LR
    A[客户端请求] --> B[route.ts]
    B --> C[GET /api/products]
    B --> D[POST /api/products]
    B --> E[PUT /api/products/:id]
    B --> F[DELETE /api/products/:id]

    style B fill:#cce5ff
HTTP 方法 导出函数 用途
GET export async function GET() 读取数据
POST export async function POST() 创建数据
PUT export async function PUT() 更新数据
DELETE export async function DELETE() 删除数据
PATCH export async function PATCH() 部分更新

▶ 示例:完整的 CRUD API

TSX
// ============================================
// Route Handler — 产品 CRUD API
// 文件:src/app/api/products/route.ts
// ============================================

// 模拟数据库
const products = [
  { id: 1, name: "Wireless Mouse", price: 29.99 },
  { id: 2, name: "Mechanical Keyboard", price: 89.99 },
];

// GET /api/products — 获取产品列表
export async function GET() {
  return Response.json(products);
}

// POST /api/products — 创建新产品
export async function POST(request: Request) {
  const body = await request.json();

  if (!body.name || !body.price) {
    return Response.json(
      { error: "Name and price are required" },
      { status: 400 }
    );
  }

  const newProduct = {
    id: products.length + 1,
    name: body.name,
    price: body.price,
  };
  products.push(newProduct);

  return Response.json(newProduct, { status: 201 });
}
TSX
// ============================================
// 单个产品的 API
// 文件:src/app/api/products/[id]/route.ts
// ============================================

export async function GET(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  const product = products.find(p => p.id === Number(id));

  if (!product) {
    return Response.json(
      { error: "Product not found" },
      { status: 404 }
    );
  }

  return Response.json(product);
}

export async function DELETE(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  const index = products.findIndex(p => p.id === Number(id));

  if (index === -1) {
    return Response.json(
      { error: "Product not found" },
      { status: 404 }
    );
  }

  products.splice(index, 1);
  return Response.json({ message: "Product deleted" });
}

输出:

TEXT 📖 仅展示
测试 API:

GET  http://localhost:3000/api/products
→ [{"id":1,"name":"Wireless Mouse","price":29.99},...]

POST http://localhost:3000/api/products
Body: {"name":"USB Hub","price":19.99}
→ {"id":3,"name":"USB Hub","price":19.99} (201)

GET  http://localhost:3000/api/products/1
→ {"id":1,"name":"Wireless Mouse","price":29.99}

DELETE http://localhost:3000/api/products/1
→ {"message":"Product deleted"} (200)

7. 完整示例:电商商品浏览系统

TSX
// ============================================
// 综合示例:电商商品浏览系统
// 涵盖页面、布局、加载、错误、API 路由
// ============================================

// src/app/layout.tsx — 根布局
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className="bg-gray-50">
        <nav className="bg-white shadow-sm p-4">
          <a href="/" className="text-xl font-bold text-blue-600">
            ShopHub
          </a>
        </nav>
        <main>{children}</main>
      </body>
    </html>
  );
}

// src/app/page.tsx — 首页
export default function HomePage() {
  return (
    <div className="container mx-auto p-8 text-center">
      <h1 className="text-4xl font-bold">Welcome to ShopHub</h1>
      <p className="mt-4 text-gray-600">
        Browse our collection of amazing products.
      </p>
      <a
        href="/products"
        className="inline-block mt-6 px-8 py-3 bg-blue-600 text-white rounded-lg"
      >
        Browse Products
      </a>
    </div>
  );
}

// src/app/products/page.tsx — 产品列表
export default async function ProductsPage() {
  const products = await fetch(
    "https://fakestoreapi.com/products"
  );

  return (
    <div className="container mx-auto p-8">
      <h1 className="text-3xl font-bold mb-6">All Products</h1>
      <div className="grid grid-cols-4 gap-6">
        {products.map(p => (
          <a
            key={p.id}
            href={`/products/${p.id}`}
            className="border rounded-lg p-4 bg-white hover:shadow-lg"
          >
            <img
              src={p.image}
              alt={p.title}
              className="h-40 mx-auto"
            />
            <p className="mt-2 font-medium text-sm">{p.title}</p>
            <p className="text-green-600 font-bold mt-1">${p.price}</p>
          </a>
        ))}
      </div>
    </div>
  );
}

// src/app/products/[id]/page.tsx — 产品详情
export default async function ProductDetailPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const product = await fetch(
    `https://fakestoreapi.com/products/${id}`
  );

  if (!product) {
    notFound();
  }

  return (
    <div className="container mx-auto p-8">
      <a
        href="/products"
        className="text-blue-600 hover:underline mb-4 inline-block"
      >
        &larr; Back to products
      </a>
      <div className="flex gap-8 bg-white p-8 rounded-lg shadow">
        <img
          src={product.image}
          alt={product.title}
          className="w-96 h-96 object-contain"
        />
        <div>
          <h1 className="text-3xl font-bold">{product.title}</h1>
          <p className="text-2xl text-green-600 font-bold mt-4">
            ${product.price}
          </p>
          <p className="mt-6 text-gray-700 leading-relaxed">
            {product.description}
          </p>
          <button className="mt-6 px-8 py-3 bg-blue-600 text-white rounded-lg">
            Add to Cart
          </button>
        </div>
      </div>
    </div>
  );
}

// src/app/products/loading.tsx — 加载骨架屏
export default function ProductsLoading() {
  return (
    <div className="container mx-auto p-8">
      <div className="h-8 w-48 bg-gray-200 rounded animate-pulse mb-6" />
      <div className="grid grid-cols-4 gap-6">
        {Array.from({ length: 8 }).map((_, i) => (
          <div key={i} className="border rounded-lg p-4 bg-white">
            <div className="h-40 bg-gray-200 rounded animate-pulse" />
            <div className="h-4 bg-gray-200 rounded mt-2 animate-pulse" />
            <div className="h-4 w-16 bg-gray-200 rounded mt-2 animate-pulse" />
          </div>
        ))}
      </div>
    </div>
  );
}

// src/app/products/error.tsx — 错误边界
'use client';
export default function ProductsError({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  return (
    <div className="container mx-auto p-8 text-center">
      <h2 className="text-2xl font-bold text-red-600">
        Failed to load products
      </h2>
      <button
        onClick={reset}
        className="mt-4 px-6 py-2 bg-blue-600 text-white rounded-lg"
      >
        Try Again
      </button>
    </div>
  );
}

预期输出:

TEXT 📖 仅展示
首页(/):
  Welcome to ShopHub → [Browse Products]

产品列表(/products):
  加载中 → 8 个灰色骨架屏
  加载完成 → 产品卡片网格(含图片、标题、价格)

产品详情(/products/1):
  商品图片(左)+ 标题/价格/描述/Add to Cart(右)
  ← Back to products 链接返回列表

错误状态:
  Failed to load products
  [Try Again] 按钮

404 状态:
  自动由根 not-found.tsx 处理

❓ 常见问题

Q page.tsx 可以用 .jsx 代替 .tsx 吗?
A 可以。如果项目没有启用 TypeScript,可以使用 .jsx 扩展名。不过本教程推荐 TypeScript,所有代码示例使用 .tsx。
Q 为什么 params 在 Next.js 16 中是 Promise?
A Next.js 15+ 为了支持异步页面生成,将 params、searchParams 等改为 Promise。如果你直接使用 params.id 会遇到 TypeScript 错误,必须 await params 才能获取值。
Q loading.tsx 和 Suspense 什么关系?
A loading.tsx 是页面级别的 Suspense fallback。Next.js 会自动将 loading.tsx 包裹在 <Suspense> 中。如果你需要组件级别的加载态,应该手动使用 <Suspense fallback={...}>
Q error.tsx 为什么需要 'use client'?
A error.tsx 必须是一个 Client Component,因为它需要处理交互(如 reset 按钮)。Server Components 不能包含事件处理程序或 hooks,所以需要 'use client' 指令。
Q route.ts 和 page.tsx 可以同时存在吗?
A 不能。同一个路由段下,page.tsx 和 route.ts 互斥。一个目录下只能有一种路由处理方式——要么是页面(page.tsx),要么是 API(route.ts)。
Q [...slug] 和 [[...catchAll]] 到底有什么区别?
A [...slug] 要求至少有一个路径段(/docs/a 匹配,/docs 不匹配 404)。[[...catchAll]] 允许零个路径段(/categories 和 /categories/a 都匹配),适合"可选分类浏览"场景。

📖 小节


📝 作业

  1. 基础题(⭐):在项目中创建 app/blog/[slug]/page.tsx 页面,使访问 /blog/hello-world 时控制台输出 slug: hello-world

  2. 进阶题(⭐⭐):创建 app/api/todos/route.ts 实现一个简单的 TODO API(GET 返回列表,POST 创建 TODO),用浏览器访问验证。

  3. 挑战题(⭐⭐⭐):创建 app/docs/[...slug]/page.tsxapp/docs/[[...slug]]/page.tsx 两种实现,分别测试 /docs 的访问行为,用表格对比两者的差异。

Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏