彻底搞懂 App Router 约定式路由:文件放对位置,路由就自动生成了
一个 next-demo 项目,串起
page.tsx、layout.tsx、route.ts、'use client'------App Router 的"约定大于配置"一次讲清。
开篇:咦,我的路由哪去了?
用 React Router 写 SPA 的时候,路由是显式写出来的------像我们项目 readme 里记的那样:
jsx
// SPA 里,路由得自己一条条声明
<Routes>
<Route path="/todos" element={<Todos />} />
</Routes>
一条 URL 对应一个 <Route>,清清楚楚。但当我打开这个 Next.js 项目,满世界找路由表,发现根本没有 。没有 routes.ts、没有 createBrowserRouter、没有任何"配置路由"的地方。
可页面又是真实存在的:/、/about、/dashboard、/dashboard/settings、/todos、/api/todos......全都访问得到。
路由藏在哪?答案在 app/ 这个目录的结构里。
这篇文章就是讲清楚:App Router 里,路由不是你"写"出来的,是你"放"出来的。
核心:约定大于配置------文件即路由、文件即角色
先给一句定义:
App Router 用文件系统的目录结构直接生成路由:一个文件夹的层级就是 URL 的层级,一个文件的文件名就是它的"角色"。
这就是 Next.js 读我 readme 时反复强调的那句------「约定大于一切」。你不需要写路由配置,只需要把文件放到约定好的位置、起约定好的名字。
对照项目目录,一眼看懂:
text
app/ URL
├─ layout.tsx ← 全局布局
├─ page.tsx ← 首页 /
├─ about/
│ └─ page.tsx ← 关于页 /about
├─ dashboard/
│ ├─ layout.tsx ← 后台布局
│ ├─ page.tsx ← 后台首页 /dashboard
│ └─ settings/
│ └─ page.tsx ← 设置页 /dashboard/settings
├─ todos/
│ └─ page.tsx ← 待办页 /todos
└─ api/
└─ todos/
└─ route.ts ← 数据接口 /api/todos
两个关键规律:
- 文件即路由 :
app/about/page.tsx这个文件的位置 (about/)就是 URL 路径(/about)。想加/xxx页面?建app/xxx/page.tsx就行,不用动任何配置文件。 - 文件即角色 :同一套目录下,不同文件名干不同的事------
page.tsx是页面,layout.tsx是布局,route.ts是接口。文件名就是组件身份的"暗号"。
下面逐个拆开看这些"暗号"。
page.tsx:一个文件就是一张页面
这是最基础也最常见的约定。看 app/about/page.tsx:
javascript
function About() {
return <h1>About Us</h1>;
}
export default About;
就这么点代码,Next.js 就自动给它注册了 /about 这个路由。page.tsx 必须是默认导出 (export default)一个 React 组件,框架拿到它渲染成页面。
注意:只有
page.tsx才会成为"可访问的页面"。你在app/下建一个helper.tsx、type.ts(比如todos/type.ts),它不会变成路由------因为文件名不叫page,只是被 import 的普通模块。
所以"建页面"这件事,从"写路由配置 + 写组件"两步,简化成了"建文件夹 + 写 page.tsx"一步。
layout.tsx:嵌套布局,父子页面共享外壳
layout.tsx 解决的是"多个页面共享同一块结构"的问题。项目里有两层嵌套布局。
第一层,根布局 app/layout.tsx,所有页面都套在里面:
javascript
export const metadata: Metadata = {
title: "Create Next App", // 🔑 SEO 的 title
description: "Generated by create next app",
};
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html lang="en">
<body>
<nav> {/* 全局导航,每个页面都在 */}
<ul>
<li><Link href="/">首页</Link></li>
<li><Link href="/about">关于我们</Link></li>
<li><Link href="/dashboard">后台管理系统</Link></li>
<li><Link href="/todos">Todos</Link></li>
</ul>
</nav>
{children} {/* 🔑 子页面渲染在这里 */}
</body>
</html>
);
}
注意两件事:
{children}是子页面内容注入的位置 。访问/about时,About组件就替换掉{children}渲染出来,外面的<nav>导航一直保留。export const metadata是 Next.js 给每个页面提供 SEO 元信息 的约定位。这里title/description会直接渲染成<title>、<meta name="description">,是 SSR 对 SEO 友好的一部分。
第二层,子布局 app/dashboard/layout.tsx:
javascript
export default function DashboardLayout({ children }: LayoutProps<"/dashboard">) {
return (
<div>
<nav><Link href="/dashboard/settings">settings</Link></nav>
{children}
</div>
);
}
布局会嵌套 :访问 /dashboard/settings 时,实际渲染结构是------
text
根布局 RootLayout(全局 nav)
└─ DashboardLayout(settings 导航)
└─ SettingsPage(settings 页面内容)
这就是 layout.tsx 的价值:同一个目录下的 layout.tsx 只作用于该目录及其子目录。dashboard 里的 settings 导航,不会跑到首页去。
route.ts:同一个项目里的"后端接口"
这是 App Router 最颠覆 SPA 认知的地方------一个前端项目里,居然能直接写接口 。看 app/api/todos/route.ts:
javascript
// next.js 除了 use client 都是后端
// /api 数据接口 仍然满足 App Router 约定
// route.ts 返回 json 数据接口的
let todos: Todo[] = [
{ id: 1, content: '学习AppRouter', completed: true },
{ id: 2, content: 'Next.js个人官网开发', completed: false },
];
// 🔑 导出的函数名 = HTTP 方法:GET 就是处理 GET 请求
export async function GET() {
return Response.json(todos);
}
export async function POST(req: Request) {
const data = await req.json();
const newTodo: Todo = { id: Date.now(), content: data.content, completed: false };
todos.push(newTodo);
return Response.json(newTodo);
}
规则同样"约定式":
- 文件放在
app/api/下、名字叫route.ts,它就不再是页面,而是接口。 - 导出的函数名就是 HTTP 方法------
GET、POST、PUT、DELETE......一个文件搞定一个资源的全部操作。 - 前端直接
fetch('/api/todos')就能打到它,没有跨域、不用单独起一个 Node 服务。
这就是 Next.js 被称为"全栈框架"的原因:app/page.tsx 是前端,app/api/*/route.ts 是后端,同一个项目、同一套约定、同一个端口。
注意:这里的
todos是内存数组,服务一重启数据就清空,真实项目要接数据库。demo 用它只是为了把结构讲清楚。
'use client':文件即角色的最后一块拼图
约定式路由里,还有一个藏在组件顶部 的"角色标记",就是项目 app/todos/page.tsx 里这句:
javascript
'use client';
import { useState, useEffect } from 'react';
export default function TodosPage() {
const [todos, setTodos] = useState<Todo[]>([]);
// ...
}
要理解它,得先知道 App Router 的一个默认规则:
app/下的组件默认是 Server Component(在服务器渲染),只有写了'use client',它才成为 Client Component。
对照项目里的两个文件,区别一目了然:
app/page.tsx(首页) |
app/todos/page.tsx(待办页) |
|
|---|---|---|
有没有 'use client' |
❌ 没有 | ✅ 有 |
| 类型 | Server Component | Client Component |
能用 useState/useEffect/onClick |
❌ 不能 | ✅ 能 |
| 在哪渲染 | 只在服务器 | 服务器 + 浏览器 |
为什么 todos/page.tsx 必须加?因为它用了 useState、useEffect、onClick 这些交互能力 ------这些只有浏览器才有。纯展示的 page.tsx(首页就一个 <h1>)不需要,加了的代价是打包更多 JS 到浏览器。
一个反直觉的真相:'use client' 组件也会在服务器渲染一遍
很多人(包括曾经的我)以为 'use client' 的意思是"这个组件只在浏览器渲染"。错。 真相是:
'use client'不是把组件"赶出"服务器,而是把它"标记"成需要水合。它照样先在服务器渲染成 HTML,再额外发一份 JS 到浏览器去激活交互。
我们自己验证一下。todos/page.tsx 的 state 初始值是空数组:
javascript
const [todos, setTodos] = useState<Todo[]>([]); // 🔑 初始是空数组
useEffect(() => { // ⚠️ useEffect 只在客户端水合后才执行
fetchTodos();
}, []);
那么用户打开 /todos 的第一个瞬间 看到什么?一个空列表。
因为服务器端渲染用的是 useState([]) 这个初始值,所以 SSR 出的 HTML 里 <ul> 是空的。等浏览器下载完 JS、完成水合(hydration) ,useEffect 才第一次跑起来去 fetch('/api/todos') 拉数据、setTodos 更新,列表才出现内容。
这就是 'use client' 组件"跑两次"的含义:
text
'use client' 组件 = 被渲染两次
服务器端 浏览器
┌──────────────┐ ┌─────────────────┐
│ React 渲染 │ ── HTML ──► │ 先拿到静态 HTML │
│ JSX ──► HTML │ │ 首屏立刻能看见 │
└──────────────┘ └────────┬────────┘
│ 下载 JS chunk
▼
┌─────────────────┐
│ 水合 hydration │
│ 绑事件、激活交互 │
│ useEffect 执行 │
└─────────────────┘
一句话记住:SSR 负责"看见",水合负责"能动"。
⚠️ 避坑 :
useEffect永远不会在服务器端执行。所以"首屏就想让用户/爬虫直接看到数据",不能靠useEffect拉数据,得用 Server Component 在服务器直接把数据查好、渲染进 HTML------这才是 Next.js 推崇的模式。
顺手拎出三个真实的小坑
既然代码是真实写出来的,它身上的坑也值得说------都是新手大概率会犯的。
坑 1:添加了任务,列表却不刷新 ⚠️
javascript
const handleAdd = async () => {
if (!text.trim()) return;
await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: text }),
});
// ⚠️ POST 成功了,但本地 todos state 没更新!
// 后端 push 进去了,前端却看不到新任务
};
你点了「添加」,请求发出去、后端数据也进去了,但页面上没有新任务 ------因为本地 todos 这个 state 根本没变。前端 state 和后端内存数组是两条独立的数据流 ,不会自动同步。正确做法是 POST 成功后 setTodos 乐观更新,或重新 fetchTodos()。
坑 2:删除按钮是个摆设
javascript
{todos.map((item) => (
<li key={item.id}>
<span>{item.content}</span>
<button>删除</button> // ⚠️ 没绑 onClick,点了没反应
</li>
))}
<button>删除</button> 没有任何事件处理器,点击毫无反应。本该有一个 DELETE 请求 + setTodos 过滤。
坑 3:console.log 调试残留
javascript
const data: Todo[] = await res.json();
setTodos(data);
console.log(data); // ⚠️ 上线前该删,水合后每次都会打一次
收尾:一张表记住整个 App Router 约定
把所有"暗号"收进一张表,这就是你以后搭 Next.js 页面的速查:
| 文件/写法 | 角色 | 会变成什么 |
|---|---|---|
app/xxx/page.tsx |
页面 | URL /xxx |
app/xxx/layout.tsx |
布局 | 包裹该目录及子目录的共享外壳 |
app/api/xxx/route.ts |
接口 | URL /api/xxx,导出 GET/POST 等 |
组件顶部 'use client' |
标记 | 让组件成为可交互的 Client Component |
其余 .tsx/.ts(如 type.ts) |
普通模块 | 只被 import,不生成路由 |
回到开头那个问题------"路由哪去了?"
App Router 里,路由不是写出来的,是"放"出来的:文件放对位置、起对名字,路由、布局、接口就全部自动就位。
下次你要加一个 /profile 页面,别再找路由表了,直接建 app/profile/page.tsx,一行路由配置都不用写。
一个开放问题 :约定式路由省去了写路由配置的麻烦,但也意味着目录结构一旦定了就很难"骗过"框架。如果某个 URL 你想让它长得和目录不一致(比如 /post/:id),App Router 里是怎么解决的?欢迎在评论区聊聊动态路由。