Next.js: App Router 文件系统路由
最后更新:2026-08-26
文件系统路由就像图书馆的图书分类——文件名决定 URL 路径,目录结构就是你的路由表,无需任何手动配置。
1. 你将学到
- 文件系统路由的核心原理:文件名即 URL
- 六大文件约定:page、layout、loading、error、not-found、route
- 动态路由
[id]单参数模式 - Catch-all 路由
[...slug]与可选 Catch-all[[...catchAll]] - Route Handlers API 路由的定义与使用
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
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]
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]
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]]
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 — 加载骨架屏
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 基本用法
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"
>
← 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 都匹配),适合"可选分类浏览"场景。
📖 小节
- 文件系统路由:文件名即 URL 路径,无需手动配置路由表
page.tsx定义页面内容,layout.tsx定义布局包裹- 动态路由
[id]匹配单参数,[...slug]匹配多级路径 [[...catchAll]]允许零个参数(可选),[...slug]至少需要一个参数loading.tsx自动包裹 Suspense,显示加载骨架屏error.tsx是 Client Component,提供错误边界 + 重试按钮not-found.tsx处理 404 页面,notFound()函数手动触发route.ts定义 API 端点,支持 GET/POST/PUT/DELETE
📝 作业
-
基础题(⭐):在项目中创建
app/blog/[slug]/page.tsx页面,使访问/blog/hello-world时控制台输出slug: hello-world。 -
进阶题(⭐⭐):创建
app/api/todos/route.ts实现一个简单的 TODO API(GET 返回列表,POST 创建 TODO),用浏览器访问验证。 -
挑战题(⭐⭐⭐):创建
app/docs/[...slug]/page.tsx和app/docs/[[...slug]]/page.tsx两种实现,分别测试/docs的访问行为,用表格对比两者的差异。