Next.js 全栈基础实战:从 SPA、SSR 到 App Router 与 Todo API
- 前言
- [1. 为什么需要 Next.js](#1. 为什么需要 Next.js)
-
- [1.1 Next.js、Nuxt 与 NestJS 的定位](#1.1 Next.js、Nuxt 与 NestJS 的定位)
- [1.2 从 Vite SPA 看浏览器端渲染](#1.2 从 Vite SPA 看浏览器端渲染)
- [1.3 CSR、SSR 与 SEO 的真实关系](#1.3 CSR、SSR 与 SEO 的真实关系)
- [2. 创建项目并建立整体框架](#2. 创建项目并建立整体框架)
-
- [2.1 使用 create-next-app](#2.1 使用 create-next-app)
- [2.2 从目录读懂项目职责](#2.2 从目录读懂项目职责)
- [2.3 一次请求在 Next.js 中怎样流动](#2.3 一次请求在 Next.js 中怎样流动)
- [3. App Router 的核心约定](#3. App Router 的核心约定)
-
- [3.1 文件夹定义片段,特殊文件赋予能力](#3.1 文件夹定义片段,特殊文件赋予能力)
- [3.2 page 与 layout 的嵌套规则](#3.2 page 与 layout 的嵌套规则)
- [3.3 静态、动态与组织型路由语法](#3.3 静态、动态与组织型路由语法)
- [3.4 Link 为什么能兼顾服务端页面与 SPA 体验](#3.4 Link 为什么能兼顾服务端页面与 SPA 体验)
- [3.5 loading、error、not-found 与 template 的约定](#3.5 loading、error、not-found 与 template 的约定)
- [3.6 Metadata、public 与 src 目录约定](#3.6 Metadata、public 与 src 目录约定)
- [3.7 并行路由与拦截路由的命名约定](#3.7 并行路由与拦截路由的命名约定)
- [4. 从首页到嵌套后台:一步步建立页面路由](#4. 从首页到嵌套后台:一步步建立页面路由)
-
- [4.1 app/page.tsx:根路由页面](#4.1 app/page.tsx:根路由页面)
- [4.2 app/about/page.tsx:增加一级路由](#4.2 app/about/page.tsx:增加一级路由)
- [4.3 app/layout.tsx:全站导航、字体与 Metadata](#4.3 app/layout.tsx:全站导航、字体与 Metadata)
- [4.4 dashboard:嵌套布局如何逐层包裹页面](#4.4 dashboard:嵌套布局如何逐层包裹页面)
- [5. Server Component 与 Client Component](#5. Server Component 与 Client Component)
-
- [5.1 默认的 Server Component 解决什么问题](#5.1 默认的 Server Component 解决什么问题)
- [5.2 use client 声明的是模块边界](#5.2 use client 声明的是模块边界)
- [5.3 选择组件边界的原则](#5.3 选择组件边界的原则)
- [6. Route Handler:在 App Router 中编写后端接口](#6. Route Handler:在 App Router 中编写后端接口)
-
- [6.1 type.ts:共享 Todo 数据结构](#6.1 type.ts:共享 Todo 数据结构)
- [6.2 GET:让 /api/todos 返回 JSON](#6.2 GET:让 /api/todos 返回 JSON)
- [6.3 POST:接收请求体并创建 Todo](#6.3 POST:接收请求体并创建 Todo)
- [7. todos/page.tsx:拆开理解完整客户端页面](#7. todos/page.tsx:拆开理解完整客户端页面)
-
- [7.1 第一段:声明客户端边界与 state](#7.1 第一段:声明客户端边界与 state)
- [7.2 第二段:水合后获取 Todo 列表](#7.2 第二段:水合后获取 Todo 列表)
- [7.3 第三段:提交 POST 并同步 React state](#7.3 第三段:提交 POST 并同步 React state)
- [7.4 第四段:用 JSX 把 state 映射成界面](#7.4 第四段:用 JSX 把 state 映射成界面)
- [8. 从页面到接口:复盘完整全栈流程](#8. 从页面到接口:复盘完整全栈流程)
-
- [8.1 首次访问 /todos](#8.1 首次访问 /todos)
- [8.2 点击添加按钮](#8.2 点击添加按钮)
- [8.3 扩展完成状态与删除功能](#8.3 扩展完成状态与删除功能)
- [8.4 什么时候应改为 Server Component 取数](#8.4 什么时候应改为 Server Component 取数)
- [9. 运行项目与建立最终心智模型](#9. 运行项目与建立最终心智模型)
-
- [9.1 开发、检查和生产命令](#9.1 开发、检查和生产命令)
- [9.2 用 URL 验证路由约定](#9.2 用 URL 验证路由约定)
- [9.3 一张表记住 App Router](#9.3 一张表记住 App Router)
- 总结
前言
只会写 React 组件,并不等于已经掌握了 Next.js。Next.js 真正需要建立的是一套新的工程心智模型:URL 怎样匹配 app 目录、layout.tsx 与 page.tsx 怎样组成页面、哪些代码在服务器运行、哪些代码需要浏览器水合,以及页面如何调用同一项目中的后端接口。
本文基于 Next.js 16.3.0、React 19.2.8 和 App Router,通过一个 Todo 示例逐步讲清这套流程。读完后应能够独立回答下面几个问题:
| 学习目标 | 应掌握的核心结论 |
|---|---|
| 创建项目 | 理解 create-next-app 生成了哪些能力 |
| 阅读目录 | 能从 app 目录直接推导 URL |
| 编写页面 | 知道 page.tsx、layout.tsx 的职责和嵌套关系 |
| 理解渲染 | 分清 Server Component、Client Component、SSR、CSR 与水合 |
| 编写接口 | 能用 route.ts 处理 GET、POST 等 HTTP 请求 |
| 串联全栈流程 | 能解释浏览器、页面组件、Route Handler 和 React state 如何协作 |
1. 为什么需要 Next.js
1.1 Next.js、Nuxt 与 NestJS 的定位
名字相似不代表用途相同。Next.js 和 Nuxt 都是围绕前端框架构建的全栈 Web 框架,而 NestJS 主要用于开发 Node.js 服务端应用。
| 技术 | 核心技术栈 | 主要定位 | 页面能力 | 后端接口能力 |
|---|---|---|---|---|
| Next.js | React | React 全栈 Web 框架 | 有 | 有 |
| Nuxt | Vue | Vue 全栈 Web 框架 | 有 | 有 |
| NestJS | Node.js、TypeScript | 服务端应用框架 | 通常交给其他前端项目 | 有 |
Next.js 所说的"全栈",最直观的表现是:app/about/page.tsx 可以返回页面,app/api/todos/route.ts 可以返回 JSON,而页面和接口还能共享 TypeScript 类型。前端路由、服务端渲染和接口不再需要分别搭建三套基础设施。
1.2 从 Vite SPA 看浏览器端渲染
spa-demo 基本保留了 Vite 的 React 初始化模板。它的 index.html 没有具体业务内容,只提供 React 挂载点:
html
<body>
<!-- React 会把组件生成的 DOM 放入这个节点 -->
<div id="root"></div>
<!-- 浏览器加载 JavaScript 入口 -->
<script type="module" src="/src/main.jsx"></script>
</body>
随后,src/main.jsx 在浏览器中找到 #root 并渲染 App:
javascript
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
// 获取 HTML 中预留的挂载节点
const rootElement = document.getElementById('root')
// 在浏览器中创建 React 根节点并渲染 App
createRoot(rootElement).render(
<StrictMode>
<App />
</StrictMode>,
)
这条链路是典型的客户端渲染:服务器返回 HTML 外壳,浏览器下载 JavaScript,JavaScript 再生成界面。App.jsx 中的计数按钮使用 useState 保存数字,点击后只更新局部 DOM,不需要整页刷新。
浏览器效果: 页面显示 Vite + React 欢迎界面,计数按钮从
Count is 0开始递增。这个 Demo 用来理解 SPA 挂载过程,后续路由与全栈能力以next-demo为主。
SPA 的核心并不是"只有一个组件",而是站内跳转通常由客户端路由接管,不重新请求并刷新整个 HTML 文档。这能带来流畅交互,但如果页面主要内容也完全依赖浏览器脚本生成,就会影响首屏内容交付和搜索引擎抓取。
1.3 CSR、SSR 与 SEO 的真实关系
先把三个概念放回正确维度:SEO 是网站优化的目标与方法体系,CSR 和 SSR 是页面的渲染方式,SPA 是应用的导航与组织形态。 SSR 可以改善 SEO 的技术基础,但 SSR 本身不等于 SEO;SPA 经常使用 CSR,却也可以拥有服务端生成的首屏 HTML。
SEO(Search Engine Optimization,搜索引擎优化) 是通过内容、网站结构和技术手段,帮助搜索引擎顺利发现、抓取、理解并收录页面,在相关搜索中获得合适的展示位置,最终带来有价值的自然搜索流量。
SEO 不是只写 title 和 description。在用户看到搜索结果之前,搜索引擎通常要经历以下过程:
| 阶段 | 搜索引擎的工作 | 网站需要具备的条件 |
|---|---|---|
| 发现 | 通过链接或站点地图找到 URL | 稳定 URL、内部链接、sitemap.xml |
| 抓取 | 向 URL 发起 HTTP 请求 | 正确状态码、可访问服务器、合理的 robots 规则 |
| 渲染与理解 | 解析 HTML,必要时执行 JavaScript | 可读取正文、清楚语义、必要元数据 |
| 建立索引 | 判断页面主题、质量与是否值得保存 | 原创、有价值、非重复的内容 |
| 排名与展示 | 针对搜索词选择结果 | 相关性、可信度、体验和性能 |
因此,SEO 至少包含内容、元数据、抓取能力、性能体验与可信度。meta keywords 可以声明,但早已不是现代主流搜索引擎排名的核心因素。没有可靠正文,仅堆叠关键词不能完成 SEO。
CSR、SSR、SSG 与水合回答的是"页面内容在何时、何处产生":
- CSR(Client-Side Rendering):浏览器执行 JavaScript 后生成主要界面或请求业务数据。
- SSR(Server-Side Rendering):每次请求到达服务器后,服务器先取得数据并生成 HTML。
- SSG(Static Site Generation):在构建或重新验证阶段提前生成 HTML,请求到来时直接复用。
- 水合(Hydration):浏览器为已有 HTML 加载 React JavaScript、绑定事件并激活交互。
| 首次访问过程 | 传统 CSR | SSR 或 SSG |
|---|---|---|
| 首次响应 | HTML 外壳与脚本地址 | 已包含标题、正文等主要内容 |
| 内容出现 | 执行 JS、请求数据后 | 解析 HTML 时即可出现 |
| 交互激活 | React 客户端渲染后 | React 水合后 |
| 爬虫成本 | 可能还要执行脚本和等待接口 | 读取首次 HTML 即可获得主要语义 |
| 主要风险 | 脚本或接口失败导致正文缺失 | 服务端耗时、缓存策略与服务器压力 |
搜索引擎抓取纯 CSR 文章页时,首次可能只得到:
html
<!-- 首次 HTML 中还没有文章标题与正文 -->
<div id="root"></div>
<!-- 需要继续执行脚本,才能生成业务内容 -->
<script type="module" src="/src/main.jsx"></script>
部分搜索引擎能够执行 JavaScript,因此不能简单说"CSR 一定无法收录"。真正的问题是:内容出现得越晚,依赖的脚本和请求越多,抓取成本与失败概率通常越高。
SSR 或 SSG 可以让首次响应直接包含内容:
html
<head>
<!-- 帮助搜索引擎理解页面身份 -->
<title>理解 Next.js 渲染</title>
<meta name="description" content="讲解 CSR、SSR 与 SEO 的关系" />
</head>
<body>
<!-- 首次响应中已经存在主要语义内容 -->
<h1>理解 Next.js 渲染</h1>
<article>这里是文章正文......</article>
</body>
这会降低内容抓取和首屏展示的门槛,但SSR 并不会自动带来高排名。如果正文质量差、页面重复、服务器缓慢或结构混乱,即使全部 SSR,也不是优秀的 SEO。
Next.js 的优势是组合这些能力:首次访问可以由服务器或静态文件提供 HTML,站内跳转再通过 <Link> 做客户端过渡。于是,"首屏内容便于抓取"和"页面切换具有 SPA 体验"可以同时成立。
| 常见说法 | 正确理解 |
|---|---|
| SPA 就是 CSR | 严格 SPA 常用 CSR,但 SPA 式导航可以和 SSR、SSG 组合 |
| CSR 完全没有 SEO | 部分爬虫能执行 JS,但内容链路更长、更难保证稳定 |
| SSR 就是 SEO | SSR 只解决内容交付,SEO 还取决于内容、结构、性能和可信度 |
| 使用 Next.js 就自动 SEO 友好 | Next.js 提供能力,页面怎样取数和渲染仍由开发者决定 |
/todos 就是很好的判断案例。如果页面初始 state 是空数组,Todo 数据要等浏览器执行 useEffect 并请求接口后才出现,那么首次 HTML 不包含 Todo 正文。若它是登录后的个人工具,这种客户端取数完全合理;若它是希望参与搜索的公开榜单,则更适合由 Server Component 先读取数据,再把交互部分交给 Client Component。
GEO(Generative Engine Optimization)关注内容是否容易被生成式搜索或 AI 助手理解、引用和追溯。它与 SEO 的入口不同,但共同基础仍是可访问、结构清楚、事实可靠和来源明确。GEO 不是新的 React 渲染方式,也不能代替 SEO。
2. 创建项目并建立整体框架
2.1 使用 create-next-app
Next.js 16.3.0 要求 Node.js 不低于 20.9。创建项目时执行:
bash
# 交互式创建最新稳定版 Next.js 项目
npx create-next-app@latest
# 使用推荐默认配置创建 next-demo
npx create-next-app@latest next-demo --yes
推荐配置会启用 TypeScript、ESLint、Tailwind CSS、App Router 和 Turbopack,并配置 @/* 路径别名。
| 工具 | 在项目中的作用 |
|---|---|
| TypeScript | 检查组件参数、状态和接口数据类型 |
| ESLint | 检查 React Hooks 与代码规范 |
| Tailwind CSS | 提供原子化 CSS 能力 |
| App Router | 使用目录和特殊文件定义路由 |
| Turbopack | 负责开发环境的快速编译与热更新 |
启动项目:
bash
# 进入项目目录
cd next-demo
# 启动开发服务器
npm run dev
浏览器访问 http://localhost:3000。开发状态下修改 app/page.tsx,页面会自动更新。
2.2 从目录读懂项目职责
spa-demo 只承担 CSR 对照,Next.js 学习集中在 next-demo:
text
next-demo/
├── app/
│ ├── layout.tsx 全站根布局
│ ├── page.tsx 首页 /
│ ├── globals.css 全局样式
│ ├── about/
│ │ └── page.tsx 关于页 /about
│ ├── dashboard/
│ │ ├── layout.tsx 后台区域布局
│ │ ├── page.tsx 后台首页 /dashboard
│ │ └── setting/
│ │ └── page.tsx 设置页 /dashboard/setting
│ ├── todos/
│ │ ├── page.tsx Todo 页面 /todos
│ │ └── type.ts Todo 类型,不生成路由
│ └── api/
│ └── todos/
│ └── route.ts Todo 接口 /api/todos
├── public/ 静态资源
├── package.json 依赖与脚本
├── tsconfig.json TypeScript 配置
└── next.config.ts Next.js 配置
package.json 决定依赖与命令,tsconfig.json 开启严格类型检查并配置 @/*,next.config.ts 是框架配置入口,public 中的文件可以从网站根路径访问。真正决定页面与接口地址的是 app 目录。
2.3 一次请求在 Next.js 中怎样流动
学习 App Router 时,最重要的不是背文件名,而是理解 Next.js 如何把 URL 变成响应:
text
浏览器请求 URL
↓
App Router 按文件夹匹配路由片段
↓
判断叶子文件
├── page.tsx:生成页面 UI
└── route.ts:执行 HTTP 处理函数
↓
如果是页面,按目录层级组合 layout.tsx
↓
服务器生成 HTML 与 React Server Component Payload
↓
浏览器显示 HTML
↓
Client Component 下载 JavaScript 并完成水合
访问 /dashboard/setting 时,Next.js 组合三个文件:
text
app/layout.tsx
└── app/dashboard/layout.tsx
└── app/dashboard/setting/page.tsx
访问 /api/todos 时则不同:它直接匹配 app/api/todos/route.ts,执行对应 HTTP 方法并返回 JSON,不会经过页面布局。
RSC Payload(React Server Component Payload) 是 React 对服务器组件树的紧凑描述,其中包含服务器组件的渲染结果、客户端组件占位信息及其 JavaScript 引用。首次访问时,HTML 用于尽快显示页面,RSC Payload 用于让 React 在浏览器中还原并协调完整组件树。
这就是项目的两条主线:
| 路由类型 | 入口文件 | 返回内容 | 是否经过 layout |
|---|---|---|---|
| 页面路由 | page.tsx |
HTML、RSC Payload | 是 |
| 数据路由 | route.ts |
JSON、文本、文件或其他 Response | 否 |
3. App Router 的核心约定
3.1 文件夹定义片段,特殊文件赋予能力
App Router 采用文件系统路由。app 下的每一层文件夹都可以表示一个 URL 片段,但文件夹本身不会自动公开路由 。只有该片段中出现 page.tsx 或 route.ts,用户才能访问对应地址。
例如:
text
app/
├── about/
│ └── page.tsx
└── todos/
└── type.ts
about 中存在 page.tsx,所以 /about 可访问;todos 中如果只有 type.ts,它只是普通模块,并不会自动生成 /todos/type。这说明 app 不只是路由目录,也允许把组件、类型和工具函数就近放在业务目录中。
App Router 常见特殊文件如下:
| 特殊文件 | 职责 | 关键行为 |
|---|---|---|
page.tsx |
当前 URL 的页面内容 | 默认导出 React 组件后,路由才公开 |
layout.tsx |
当前片段及后代共享 UI | 接收 children,导航时保留 |
route.ts |
HTTP 接口 | 导出 GET、POST 等函数 |
loading.tsx |
加载状态 | 自动形成 Suspense 边界 |
error.tsx |
局部错误界面 | 作为 React Error Boundary,需要客户端能力 |
not-found.tsx |
404 界面 | 配合 notFound() 或未知地址 |
template.tsx |
类似布局的共享 UI | 导航后会重新创建实例 |
同一个路由片段不能同时放置
page.tsx和route.ts,因为两者都会接管相同 URL。页面和接口通常分别放在app/todos/page.tsx与app/api/todos/route.ts。
3.2 page 与 layout 的嵌套规则
page.tsx 决定某个地址"独有的内容",layout.tsx 决定一组地址"共享的外壳"。根布局 app/layout.tsx 是必需文件,并且必须包含 <html> 和 <body>。
布局通过 children 接收下一层内容。可把它理解成 Next.js 自动传入的插槽:
ts
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
}: LayoutProps<'/dashboard'>) {
return (
<section>
{/* Dashboard 目录下面匹配到的页面会被放在这里 */}
{children}
</section>
)
}
当 URL 是 /dashboard 时,children 是 app/dashboard/page.tsx;当 URL 是 /dashboard/setting 时,children 会继续匹配更深层的设置页面。
LayoutProps<'/dashboard'> 是 Next.js 生成的全局类型,不需要导入。它可以根据路由结构推导 children 和并行路由插槽。相关类型会在 next dev、next build 或 next typegen 时生成。
布局在客户端导航时会被保留,不会像普通页面组件一样整体替换。因此,全站导航适合放根布局,后台侧边栏适合放 dashboard/layout.tsx,而某一页独有的标题应放 page.tsx。
3.3 静态、动态与组织型路由语法
App Router 不需要手写类似 <Route path="/post/:id"> 的配置。动态参数直接写在目录名中:
| 目录 | 匹配 URL | 参数含义 |
|---|---|---|
app/post/[id]/page.tsx |
/post/42 |
id = 42 |
app/shop/[...slug]/page.tsx |
/shop/a、/shop/a/b |
至少一个片段 |
app/docs/[[...slug]]/page.tsx |
/docs、/docs/a/b |
可选多个片段 |
app/(admin)/users/page.tsx |
/users |
路由组不进入 URL |
app/blog/_components/Card.tsx |
不生成 URL | 私有目录退出路由系统 |
Next.js 16 中,动态路由的 params 是 Promise:
ts
// app/post/[id]/page.tsx
export default async function PostPage({
params,
}: PageProps<'/post/[id]'>) {
// 等待路由参数解析
const { id } = await params
// 实际项目可使用 id 查询文章或商品
return <h1>文章编号:{id}</h1>
}
访问 /post/42 时,App Router 先匹配 post,再把 42 交给动态片段 [id],最后渲染页面并显示"文章编号:42"。
3.4 Link 为什么能兼顾服务端页面与 SPA 体验
Next.js 推荐使用 next/link 完成站内跳转:
ts
import Link from 'next/link'
export default function Navigation() {
return (
<nav>
{/* href 对应 App Router 中公开的 URL */}
<Link href="/">首页</Link>
<Link href="/about">关于</Link>
<Link href="/dashboard">后台</Link>
</nav>
)
}
<Link> 最终会生成可访问的链接,同时提供路由预取和客户端过渡。链接进入视口后,Next.js 可以提前准备静态路由;点击后保留共享布局,只更新发生变化的路由片段。页面内容仍可由服务器生成,但交互体验不必退回传统整页刷新。
3.5 loading、error、not-found 与 template 的约定
除了 page.tsx 和 layout.tsx,App Router 还使用特殊文件描述加载、错误和找不到资源时的页面状态。这些文件不需要手动导入,Next.js 会按照它们所在的路由片段自动建立边界。
| 特殊文件 | 触发时机 | 是否默认 Server Component | 核心作用 |
|---|---|---|---|
loading.tsx |
当前片段内容尚未完成 | 是 | 提供即时加载 UI 和 Suspense 边界 |
error.tsx |
当前片段或后代出现未处理运行时错误 | 否,必须使用 'use client' |
显示错误回退 UI,并允许重试 |
not-found.tsx |
调用 notFound() 或无法匹配资源 |
是 | 显示 404 内容 |
template.tsx |
与布局位于同一层级 | 是 | 类似 layout,但导航后会重新挂载 |
global-error.tsx |
根布局或根模板出错 | 否,必须使用 'use client' |
替换根布局并处理全局错误 |
在 dashboard 中创建加载文件:
ts
// app/dashboard/loading.tsx
export default function Loading() {
// 页面或数据尚未完成时,先显示轻量反馈
return <p>后台数据加载中......</p>
}
loading.tsx 会自动把同一片段下的 page.tsx 和更深层内容包进 Suspense 边界。导航发生时,共享布局仍然可以交互,加载内容准备完成后会自动替换占位 UI。它不会包裹同一层的 layout.tsx、template.tsx 或 error.tsx。
错误文件必须是 Client Component,因为它需要接收错误并绑定重试事件:
ts
// app/dashboard/error.tsx
'use client'
export default function DashboardError({
error,
retry,
}: {
error: Error & { digest?: string }
retry: () => void
}) {
return (
<section>
<p>后台页面加载失败:{error.message}</p>
{/* retry 会重新获取并渲染错误边界中的内容 */}
<button onClick={() => retry()}>
重新尝试
</button>
</section>
)
}
error.tsx 只捕获它所包裹的页面和更深层内容,不会捕获同一片段 layout.tsx 自身的错误。根布局出错时应使用 app/global-error.tsx,而且该文件需要自己提供 <html> 与 <body>。
找不到业务资源时,可以在服务端页面调用 notFound():
ts
// app/post/[id]/page.tsx
import { notFound } from 'next/navigation'
export default async function PostPage({
params,
}: PageProps<'/post/[id]'>) {
const { id } = await params
const post = await getPost(id)
// 没有对应文章时,交给最近的 not-found.tsx
if (!post) notFound()
return <h1>{post.title}</h1>
}
layout.tsx 与 template.tsx 都能包裹子页面,但行为不同:
| 对比 | layout.tsx |
template.tsx |
|---|---|---|
| 客户端导航后是否保留实例 | 保留 | 当前片段变化时重新挂载 |
| 子组件 state | 保留 | 重置 |
| 子组件 Effect | 通常不会因布局重建而重跑 | 重新挂载后会再次执行 |
| 典型用途 | 导航栏、侧边栏、稳定外壳 | 需要在路由变化时重置的表单或动画 |
3.6 Metadata、public 与 src 目录约定
App Router 除了支持导出 metadata,还会识别一组 Metadata 特殊文件:
| 文件约定 | 用途 |
|---|---|
app/favicon.ico |
浏览器标签与收藏图标 |
app/icon.png、app/apple-icon.png |
网站和 Apple 设备图标 |
app/opengraph-image.png |
社交平台分享预览图 |
app/twitter-image.png |
Twitter/X 分享预览图 |
app/robots.txt 或 app/robots.ts |
搜索引擎抓取规则 |
app/sitemap.xml 或 app/sitemap.ts |
站点 URL 列表 |
这些约定文件可以是静态资源,也可以使用 .ts 或 .tsx 动态生成。放在更深路由片段中的 Metadata 文件只作用于对应路由范围;favicon.ico 则应位于根 app 目录。
静态资源统一放在项目根目录的 public 中。文件地址从网站根路径开始,不包含 public:
text
public/logo.png
↓
浏览器访问 /logo.png
如果希望把应用代码与配置文件分开,可以把 app 移到 src/app:
text
next-demo/
├── public/ 仍然保留在项目根目录
├── src/
│ └── app/ App Router
├── package.json 配置文件仍在根目录
├── next.config.ts
└── tsconfig.json
app 和 src/app 不应同时存在;如果根目录已经有 app,src/app 会被忽略。public、.env*、package.json、next.config.ts 和 tsconfig.json 仍然放在项目根目录。
3.7 并行路由与拦截路由的命名约定
并行路由使用 @folder 创建命名插槽,让同一个布局同时渲染多个可以独立导航的区域。@folder 是插槽而不是 URL 片段,因此不会出现在浏览器地址中。
text
app/dashboard/
├── @analytics/
│ └── page.tsx
├── @team/
│ └── page.tsx
├── layout.tsx
└── page.tsx
两个插槽会作为 props 传入父布局:
ts
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
analytics,
team,
}: {
children: React.ReactNode
analytics: React.ReactNode
team: React.ReactNode
}) {
return (
<main>
{/* children 本身也是一个隐式插槽 */}
{children}
{analytics}
{team}
</main>
)
}
客户端软导航时,Next.js 会记录每个插槽当前激活的子页面;浏览器刷新属于硬导航,无法恢复不匹配插槽的状态,此时会使用该插槽中的 default.tsx。如果没有 default.tsx,则显示 404。
拦截路由用于在当前页面上下文中展示另一个路由,最典型的场景是"列表中打开详情弹窗,但详情仍拥有可分享 URL":
| 目录前缀 | 匹配位置 |
|---|---|
(.)photo |
拦截同级 photo 片段 |
(..)photo |
拦截上一级的 photo 片段 |
(..)(..)photo |
向上两级匹配 |
(...)photo |
从根 app 目录匹配 |
拦截层级按路由片段 计算,而不是按物理文件夹层级计算,@slot 不计入路由片段。它通常与并行路由组合:客户端点击时把详情显示成弹窗,直接访问或刷新详情 URL 时则渲染完整详情页。
路由组 (group) 也有两个需要注意的约定:不同路由组不能生成相同 URL,否则会发生冲突;如果项目使用多个根布局,在不同根布局之间跳转会触发完整页面加载。
4. 从首页到嵌套后台:一步步建立页面路由
4.1 app/page.tsx:根路由页面
app/page.tsx 对应网站根地址 /:
ts
// app/page.tsx
export default function Home() {
// page.tsx 的默认导出就是当前地址的页面内容
return <h1>Hello World</h1>
}
为什么不需要 React Router?因为 App Router 已经把文件位置作为路由声明。请求 / 时,Next.js 找到 app/page.tsx,调用 Home,把 JSX 渲染成页面内容,再交给根布局的 children。
这个文件没有 'use client',所以默认是 Server Component。它可以在服务器生成内容,不需要把这段组件逻辑作为交互 JavaScript 发送给浏览器。
浏览器效果: 访问
http://localhost:3000/,页面主体显示一级标题Hello World。
4.2 app/about/page.tsx:增加一级路由
在 app 中创建 about 文件夹,再放入 page.tsx:
ts
// app/about/page.tsx
export default function AboutPage() {
// about 文件夹成为 URL 中的 /about
return <h1>About Us</h1>
}
执行过程是:
| 步骤 | Next.js 的行为 |
|---|---|
| 1 | 收到 GET /about |
| 2 | 在 app 下匹配 about 路由片段 |
| 3 | 找到 about/page.tsx,确认路由公开 |
| 4 | 渲染 AboutPage |
| 5 | 把结果作为 children 放入根布局 |
浏览器效果: 访问
/about时,全站外壳保持不变,页面主体显示About Us。
4.3 app/layout.tsx:全站导航、字体与 Metadata
根布局会包裹所有页面,适合放置全站导航、字体、全局样式和 SEO 元数据:
ts
// app/layout.tsx
import type { Metadata } from 'next'
import { Geist, Geist_Mono } from 'next/font/google'
import Link from 'next/link'
import './globals.css'
// next/font 会优化字体并暴露 CSS 变量
const geistSans = Geist({
variable: '--font-geist-sans',
subsets: ['latin'],
})
const geistMono = Geist_Mono({
variable: '--font-geist-mono',
subsets: ['latin'],
})
// Next.js 根据 metadata 自动生成 head 中的标签
export const metadata: Metadata = {
title: 'Next.js 全栈基础示例',
description: '学习 App Router、组件边界与 Route Handler。',
}
export default function RootLayout({
children,
}: LayoutProps<'/'>) {
return (
<html
lang="zh-CN"
className={geistSans.variable + ' ' + geistMono.variable}
>
<body>
<nav>
<ul>
{/* 这些导航会出现在所有页面顶部 */}
<li><Link href="/">首页</Link></li>
<li><Link href="/about">关于</Link></li>
<li><Link href="/dashboard">后台</Link></li>
<li><Link href="/dashboard/setting">设置</Link></li>
<li><Link href="/todos">待办事项</Link></li>
</ul>
</nav>
{/* App Router 把当前页面或下一层布局注入这里 */}
<main>{children}</main>
</body>
</html>
)
}
这段代码同时体现了五个 Next.js 约定:
| 代码 | 框架行为 |
|---|---|
app/layout.tsx |
被识别为根布局 |
<html>、<body> |
根布局必须提供文档骨架 |
children |
接收当前路由匹配到的页面树 |
metadata |
自动生成 title 和 meta description |
next/font |
优化字体加载,减少布局偏移 |
静态 metadata 和动态 generateMetadata 只能从 Server Component 导出。lang="zh-CN" 帮助浏览器、辅助技术与搜索引擎识别页面语言。
根布局还导入了 globals.css。全局样式只需在根布局引入一次,之后会作用于整个应用:
css
/* app/globals.css:加载 Tailwind CSS 4 */
@import "tailwindcss";
:root {
/* 全站共享的颜色变量 */
--background: #ffffff;
--foreground: #171717;
}
body {
/* 所有页面继承相同的基础颜色 */
background: var(--background);
color: var(--foreground);
}
globals.css 适合放重置样式、CSS 变量和全站基础规则;某个组件独有的样式则更适合 Tailwind 类或 CSS Module,避免无意影响其他页面。
浏览器效果: 访问首页、关于页、后台页或 Todo 页,顶部都会出现同一组导航;浏览器标签标题显示"Next.js 全栈基础示例",页面主体由当前路由决定。
4.4 dashboard:嵌套布局如何逐层包裹页面
后台区域通常有自己的导航,因此在 app/dashboard/layout.tsx 中增加局部布局:
ts
// app/dashboard/layout.tsx
import Link from 'next/link'
export default function DashboardLayout({
children,
}: LayoutProps<'/dashboard'>) {
return (
<section>
<nav>
{/* 这组导航只出现在 dashboard 及其后代路由 */}
<Link href="/dashboard">后台首页</Link>
<Link href="/dashboard/setting">系统设置</Link>
</nav>
{/* dashboard 下匹配到的页面放在这里 */}
{children}
</section>
)
}
再分别创建两个页面:
ts
// app/dashboard/page.tsx
export default function DashboardPage() {
return <h1>Hello, Dashboard! 后台项目系统</h1>
}
// app/dashboard/setting/page.tsx
export default function SettingPage() {
return <h1>Hello, Setting!</h1>
}
访问 /dashboard/setting 时,三个组件不是并列执行,而是逐层嵌套:
| 层级 | 文件 | 提供的 UI |
|---|---|---|
| 第 1 层 | app/layout.tsx |
全站导航和文档骨架 |
| 第 2 层 | app/dashboard/layout.tsx |
后台局部导航 |
| 第 3 层 | app/dashboard/setting/page.tsx |
设置页独有内容 |
浏览器效果:
/dashboard显示两层导航和"后台项目系统";/dashboard/setting保留两层导航,只把页面主体替换为"Hello, Setting!"。访问/about时不会出现后台局部导航。
5. Server Component 与 Client Component
5.1 默认的 Server Component 解决什么问题
App Router 中的 page.tsx 和 layout.tsx 默认是 Server Component。它们适合:
- 在靠近数据源的位置访问数据库或后端服务。
- 使用不会暴露给浏览器的密钥和环境变量。
- 减少发送到浏览器的 JavaScript。
- 先生成可阅读内容,再逐步交付页面。
Server Component 不能直接使用 useState、useEffect、onClick、window 或 localStorage,因为这些能力依赖浏览器运行环境。它也不需要写 'use server';默认就是服务器组件。
5.2 use client 声明的是模块边界
当组件需要状态、事件或浏览器 API 时,在文件顶部写 'use client':
ts
'use client'
import { useState } from 'react'
export default function Counter() {
const [count, setCount] = useState(0)
return (
<button onClick={() => setCount((value) => value + 1)}>
{/* 点击后更新客户端 state */}
当前计数:{count}
</button>
)
}
'use client' 不是"这个函数只会在浏览器执行一次"的字面开关,而是 Server 与 Client 模块图的边界。一个文件被标记后,它导入的客户端依赖也会进入客户端 bundle,因此应尽量把边界放在真正需要交互的小组件上。
首次访问包含 Client Component 的页面时,Next.js 的处理过程如下:
| 阶段 | 使用的内容 | 结果 |
|---|---|---|
| 服务器预渲染 | Server Component、Client Component 描述、RSC Payload | 生成可快速展示的 HTML |
| 浏览器首次显示 | HTML | 用户先看到静态界面 |
| React 协调组件树 | RSC Payload | 对齐服务器与客户端组件 |
| 水合 | Client Component JavaScript | 绑定点击、输入等事件 |
所以,Client Component 仍可能参与首屏预渲染;真正由浏览器独有的是状态变化、事件处理、Effect 和浏览器 API。开发环境的 Strict Mode 可能额外执行检查,不能把水合机械理解为"组件永远固定执行两次"。
5.3 选择组件边界的原则
| 需求 | 推荐组件 |
|---|---|
| 查询数据库并显示文章 | Server Component |
| 输出 SEO 正文 | Server Component |
| 读取服务端秘密 | Server Component |
| 输入框、计数器、弹窗 | Client Component |
useState、useEffect |
Client Component |
window、localStorage |
Client Component |
最常见的组合是:Server Component 获取初始数据,Client Component 接收可序列化 props 并负责交互。 这样既保留服务端数据能力,又不会把整个页面都变成客户端 bundle。
6. Route Handler:在 App Router 中编写后端接口
6.1 type.ts:共享 Todo 数据结构
Todo 页面和接口都需要理解同一份数据形状,因此先创建 app/todos/type.ts:
ts
// app/todos/type.ts
export type Todo = {
id: number // 唯一标识
content: string // 待办内容
completed: boolean // 是否完成
}
这个文件位于 app/todos 中,但不会生成路由,因为 type.ts 不是 App Router 特殊文件。类型只在编译阶段约束代码,最终会被擦除,因此页面不会出现任何可见变化。
6.2 GET:让 /api/todos 返回 JSON
在 app/api/todos/route.ts 中导出名为 GET 的函数:
ts
// app/api/todos/route.ts
import { type Todo } from '../../todos/type'
// 教学阶段暂时把数据保存在当前服务进程内存中
const todos: Todo[] = [
{ id: 1, content: '学习 App Router', completed: false },
{ id: 2, content: '使用 Next.js 开发个人官网', completed: false },
]
// 处理 GET /api/todos
export async function GET() {
// Response.json 会生成 JSON 响应并设置 Content-Type
return Response.json(todos)
}
为什么文件必须叫 route.ts?因为 App Router 只把这个特殊文件识别为 HTTP 处理入口。为什么函数必须叫 GET?因为 Next.js 根据导出的函数名匹配请求方法。
当浏览器访问 /api/todos 时,执行链路是:
text
GET /api/todos
↓
匹配 app/api/todos/route.ts
↓
调用导出的 GET()
↓
Response.json(todos)
↓
浏览器收到 JSON 数组
浏览器效果: 页面直接显示两条 Todo 的 JSON,不会出现根布局导航。Route Handler 返回的是数据响应,不参与
layout.tsx的 UI 组合。
在当前 Next.js 版本中,Route Handler 的 GET 默认不缓存;如果接口内容确定可以静态化,可以显式选择缓存策略。POST、PATCH、DELETE 等修改数据的方法不会被缓存。
6.3 POST:接收请求体并创建 Todo
同一个 route.ts 可以继续导出 POST:
ts
// app/api/todos/route.ts
export async function POST(request: Request) {
// 把 JSON 请求体解析为普通对象
const body: { content?: unknown } = await request.json()
// 接口边界需要验证外部输入
if (typeof body.content !== 'string' || !body.content.trim()) {
return Response.json(
{ message: 'content 不能为空' },
{ status: 400 },
)
}
const newTodo: Todo = {
id: Date.now(),
content: body.content.trim(),
completed: false,
}
// 保存到当前进程内的数组
todos.push(newTodo)
// 201 表示成功创建资源
return Response.json(newTodo, { status: 201 })
}
客户端发送的请求大致如下:
bash
# 向 Todo 接口提交 JSON
curl -X POST http://localhost:3000/api/todos \
-H "Content-Type: application/json" \
-d '{"content":"学习 Route Handler"}'
请求执行过程:
| 步骤 | 发生的位置 | 行为 |
|---|---|---|
| 1 | 客户端 | 把对象序列化为 JSON |
| 2 | App Router | 根据 URL 找到 route.ts,根据方法选择 POST |
| 3 | Route Handler | request.json() 解析请求体 |
| 4 | Route Handler | 验证内容并创建 Todo |
| 5 | Route Handler | 返回状态码 201 和新对象 |
当前数组只适合讲解请求流程。服务器重启后数据会恢复,多进程与 Serverless 部署也不能共享这份内存。正式项目应把数据访问提取到独立模块,并连接 PostgreSQL、MySQL 等持久化存储。
7. todos/page.tsx:拆开理解完整客户端页面
app/todos/page.tsx 同时包含客户端边界、状态、Effect、GET 请求、POST 请求和 JSX。一次性阅读整份文件容易只看到语法,因此下面按实际执行顺序拆成四段。四段代码按顺序属于同一个 TodosPage 组件。
7.1 第一段:声明客户端边界与 state
ts
// app/todos/page.tsx
'use client'
import { useEffect, useState } from 'react'
import { type Todo } from './type'
export default function TodosPage() {
// 保存接口返回的列表;首次渲染时为空
const [todos, setTodos] = useState<Todo[]>([])
// 保存受控输入框内容
const [text, setText] = useState('')
// 后续三段代码继续写在 TodosPage 函数内部
这段代码首先建立客户端模块边界,因为后面需要 useState、useEffect、onChange 和 onClick。两个 state 的职责不同:todos 决定列表渲染什么,text 决定输入框显示什么。
初始值也决定了首次预渲染结果:todos 是空数组,因此服务器能先生成标题和表单,但列表还没有业务数据;text 是空字符串,因此输入框开始为空。
7.2 第二段:水合后获取 Todo 列表
ts
useEffect(() => {
// Effect 只在客户端挂载后执行
const fetchTodos = async () => {
const response = await fetch('/api/todos')
if (!response.ok) {
throw new Error('Todo 列表加载失败')
}
// 把 JSON 转换为 Todo 数组
const data: Todo[] = await response.json()
// 更新 state,触发列表重新渲染
setTodos(data)
}
// 记录请求失败,避免 Promise 拒绝后无人处理
void fetchTodos().catch((error) => console.error(error))
}, [])
空依赖数组 [] 表示 Effect 在本次挂载后执行。真正的运行顺序是:
| 顺序 | 页面状态 |
|---|---|
| 1 | 服务器按照空 todos 预渲染页面外壳 |
| 2 | 浏览器显示标题、输入框和按钮 |
| 3 | React 完成水合 |
| 4 | useEffect 请求 GET /api/todos |
| 5 | Route Handler 返回 JSON |
| 6 | setTodos(data) 触发重新渲染 |
| 7 | 两条 Todo 出现在列表中 |
浏览器效果: 页面外壳先出现,接口响应后显示"学习 App Router"和"使用 Next.js 开发个人官网"。
7.3 第三段:提交 POST 并同步 React state
ts
const handleAdd = async () => {
const content = text.trim()
// 阻止空字符串或纯空格提交
if (!content) return
const response = await fetch('/api/todos', {
method: 'POST',
headers: {
// 告诉 Route Handler 请求体是 JSON
'Content-Type': 'application/json',
},
// 把 JavaScript 对象序列化为请求字符串
body: JSON.stringify({ content }),
})
// 创建失败时结束本次事件,实际项目可显示错误提示
if (!response.ok) return
// 接口返回服务端创建的新对象
const newTodo: Todo = await response.json()
// 使用函数式更新,在最新数组末尾追加新任务
setTodos((currentTodos) => [...currentTodos, newTodo])
// state 变为空字符串后,受控输入框也会清空
setText('')
}
这里最关键的 Next.js 全栈闭环不是 fetch 本身,而是四个状态转换:
text
text state
↓ JSON.stringify
POST /api/todos
↓ Route Handler 创建资源
返回 newTodo
↓ setTodos
React 重新渲染列表
接口中的数组变化不会自动通知浏览器。页面之所以立即出现新任务,是因为客户端读取了响应,并主动调用 setTodos 更新 React state。
7.4 第四段:用 JSX 把 state 映射成界面
ts
return (
<section style={{ marginTop: '12px' }}>
<h1>待办事项</h1>
<input
value={text}
// 输入事件把最新文本写回 state
onChange={(event) => setText(event.target.value)}
placeholder="请输入待办事项"
/>
<button
onClick={handleAdd}
style={{ marginLeft: '8px' }}
>
添加
</button>
<ul style={{ paddingLeft: 0, listStyle: 'none' }}>
{todos.map((item) => (
<li
key={item.id}
style={{ margin: '8px 0' }}
>
{/* 每一项的文字来自 todos state */}
{item.content}
</li>
))}
</ul>
</section>
)
}
input 是受控组件:浏览器显示的值来自 text,用户输入再通过 onChange 写回 text。todos.map 把数据数组转换为 JSX 列表,key={item.id} 帮助 React 在更新时稳定识别每个任务。
浏览器效果: 访问
/todos,顶部显示根布局导航,主体包含"待办事项"、输入框、添加按钮和两条初始任务。输入文字并点击"添加"后,POST 请求创建新任务,列表立即追加一项,输入框自动清空。
8. 从页面到接口:复盘完整全栈流程
8.1 首次访问 /todos
把页面路由、组件边界和数据请求放进同一条时间线:
| 时间 | 浏览器 | Next.js 页面层 | Route Handler |
|---|---|---|---|
| 1 | 请求 GET /todos |
匹配根布局与 todos/page.tsx |
尚未调用 |
| 2 | 等待响应 | 用空 state 预渲染 HTML | 尚未调用 |
| 3 | 显示表单外壳 | 返回 HTML 与 RSC Payload | 尚未调用 |
| 4 | React 水合 | Client Component 激活 | 尚未调用 |
| 5 | Effect 发起 fetch | 等待接口响应 | 执行 GET /api/todos |
| 6 | 接收 JSON | setTodos 更新 state |
返回 Todo 数组 |
| 7 | 显示列表 | React 重新渲染 | 请求结束 |
这条流程说明了两个容易混淆的事实:
/todos是页面路由 ,返回 UI,并经过layout.tsx。/api/todos是数据路由,返回 JSON,不经过页面布局。
两者位于同一个 Next.js 项目,但职责完全不同。
8.2 点击添加按钮
点击"添加"之后不再发生整页导航,而是在当前 Client Component 中完成数据提交:
text
用户点击按钮
↓
onClick 调用 handleAdd
↓
浏览器 POST /api/todos
↓
App Router 匹配 route.ts 的 POST
↓
服务端验证并创建 Todo
↓
浏览器收到 newTodo
↓
setTodos 追加数据
↓
React 更新当前列表
页面路由与 API 路由共同组成了最小全栈闭环:页面负责交互和呈现,Route Handler 负责处理 HTTP 数据,TypeScript 类型负责约束双方约定。
8.3 扩展完成状态与删除功能
Todo 类型已经包含 completed,列表也适合继续增加切换与删除。可以在 Route Handler 中补充 PATCH 和 DELETE:
ts
// PATCH /api/todos:切换完成状态
export async function PATCH(request: Request) {
const body: { id?: unknown } = await request.json()
if (typeof body.id !== 'number') {
return Response.json({ message: 'id 无效' }, { status: 400 })
}
const todo = todos.find((item) => item.id === body.id)
if (!todo) {
return Response.json({ message: 'Todo 不存在' }, { status: 404 })
}
todo.completed = !todo.completed
return Response.json(todo)
}
// DELETE /api/todos:删除指定任务
export async function DELETE(request: Request) {
const body: { id?: unknown } = await request.json()
if (typeof body.id !== 'number') {
return Response.json({ message: 'id 无效' }, { status: 400 })
}
const index = todos.findIndex((item) => item.id === body.id)
if (index === -1) {
return Response.json({ message: 'Todo 不存在' }, { status: 404 })
}
const [deletedTodo] = todos.splice(index, 1)
return Response.json(deletedTodo)
}
客户端对应地发送请求,并使用接口响应更新 state:
ts
const handleToggle = async (id: number) => {
const response = await fetch('/api/todos', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
})
if (!response.ok) return
const updatedTodo: Todo = await response.json()
// 只替换被服务端更新的那一项
setTodos((currentTodos) =>
currentTodos.map((item) =>
item.id === id ? updatedTodo : item,
),
)
}
const handleDelete = async (id: number) => {
const response = await fetch('/api/todos', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
})
if (!response.ok) return
// 删除成功后,从客户端列表中过滤掉该任务
setTodos((currentTodos) =>
currentTodos.filter((item) => item.id !== id),
)
}
最后把事件绑定到列表项:
ts
<li key={item.id}>
<button
onClick={() => handleToggle(item.id)}
style={{
// completed 为 true 时显示删除线
textDecoration: item.completed ? 'line-through' : 'none',
}}
>
{item.content}
</button>
<button onClick={() => handleDelete(item.id)}>
删除
</button>
</li>
| 操作 | HTTP 方法 | 服务端变化 | 客户端变化 |
|---|---|---|---|
| 查询 | GET | 读取列表 | setTodos(data) |
| 新增 | POST | push 新对象 |
追加 newTodo |
| 切换 | PATCH | 修改 completed |
替换对应项 |
| 删除 | DELETE | splice 移除对象 |
filter 移除对应项 |
8.4 什么时候应改为 Server Component 取数
当前 Todo 页面使用 Client Component + useEffect,非常适合学习浏览器调用 Route Handler,也适合登录后的强交互工具。如果列表是公开内容,并希望首次 HTML 就包含 Todo,可以采用"服务端取初始数据 + 客户端负责交互"的结构:
text
app/todos/page.tsx Server Component,读取初始数据
app/todos/todos-client.tsx Client Component,处理输入和按钮
lib/todos.ts 共享数据访问层,连接数据库
服务端页面把数据作为 props 传给客户端组件:
ts
// app/todos/page.tsx:默认是 Server Component
import TodosClient from './todos-client'
import { getTodos } from '@/lib/todos'
export default async function TodosPage() {
// 直接调用数据层,不需要绕行自己的 /api/todos
const initialTodos = await getTodos()
// props 必须是 React 可序列化的数据
return <TodosClient initialTodos={initialTodos} />
}
客户端组件只接管交互:
ts
// app/todos/todos-client.tsx
'use client'
import { useState } from 'react'
import { type Todo } from './type'
export default function TodosClient({
initialTodos,
}: {
initialTodos: Todo[]
}) {
// 首次 state 直接使用服务器传来的数据
const [todos, setTodos] = useState(initialTodos)
// 输入、新增、切换和删除逻辑可以继续写在这里
return (
<ul>
{todos.map((item) => (
<li key={item.id}>{item.content}</li>
))}
</ul>
)
}
这样做以后,Todo 正文能够进入首次 HTML,浏览器也不需要在水合后再发一次 GET 请求。需要注意,Server Component 应直接调用数据库或数据层,不建议通过 HTTP 请求自己的 Route Handler,否则会增加一次没有必要的服务器往返。
9. 运行项目与建立最终心智模型
9.1 开发、检查和生产命令
bash
# 启动开发服务器
npm run dev
# 单独执行 ESLint
npm run lint
# 创建生产构建并进行类型检查
npm run build
# 启动已经构建的生产版本
npm run start
Next.js 16 的 next build 不再自动执行 ESLint,因此 lint 与 build 应分别运行。
9.2 用 URL 验证路由约定
| URL | 匹配文件 | 预期界面或响应 |
|---|---|---|
/ |
app/page.tsx |
全站导航 + Hello World |
/about |
app/about/page.tsx |
全站导航 + About Us |
/dashboard |
dashboard/layout.tsx + dashboard/page.tsx |
全站导航 + 后台导航 + 后台首页 |
/dashboard/setting |
两层 layout + setting/page.tsx |
两层导航 + 设置页 |
/todos |
app/todos/page.tsx |
Todo 表单与列表 |
/api/todos |
app/api/todos/route.ts |
JSON,不显示页面布局 |
9.3 一张表记住 App Router
| 遇到的需求 | 应想到的 Next.js 机制 |
|---|---|
| 新建一个页面地址 | 创建文件夹和 page.tsx |
| 多个页面共享导航 | 在共同父目录创建 layout.tsx |
| 根据 ID 创建多个页面 | 使用 [id] 动态路由 |
| 创建 JSON 接口 | 使用 route.ts 并导出 HTTP 方法 |
| 服务端查询数据 | 使用默认 Server Component |
| 使用 state、Effect、点击事件 | 缩小范围并添加 'use client' |
| 首屏显示服务器数据并保留交互 | Server Component 取数,Client Component 接收 props |
| 站内无刷新跳转 | 使用 next/link |
| 设置页面标题和描述 | 导出 metadata 或 generateMetadata |
真正掌握 App Router 的标志,不是记住所有特殊文件,而是看到一个 URL 就能推导它的路由树,看到一个交互需求就能判断组件边界,看到一次数据请求就能说清它在浏览器与服务器之间经过了哪些步骤。
总结
本文从 SPA 挂载与 SEO 基础出发,系统讲解了 Next.js App Router 的目录约定、页面与布局嵌套、动态路由、客户端导航以及 Server Component 和 Client Component 的职责边界。通过 Todo 示例,完整串联了页面预渲染、水合、Effect 请求、Route Handler、React state 更新和增删改查流程。掌握这些规则后,开发者便能从 URL 推导文件结构,从交互需求判断运行环境,并在服务端内容、客户端体验与工程可维护性之间做出清晰选择。