面向 中级 → 高级 工程师的生产级开发手册。每章附 原理章节(💡)和 踩坑提醒(⚠️)。
本文档由 11 个子主题拼接而成,可一口气读完,也可按下面目录跳读。
总目录
- 00 - 架构总览与技术选型
- 01 - 脚手架与工程化
- 02 - App Router + RSC 核心
- 03 - 数据获取与四层缓存
- 04 - Server Actions 与表单
- 05 - 状态管理与 UI 体系
- 06 - 性能与无障碍
- 07 - 测试金字塔(前端视角)
- 08 - 可观测性与监控(前端视角)
- 09 - 部署:Docker / Vercel / K8s
- 10 - 安全加固与上线 Checklist(前端视角)
附录(高级进阶)
00 - 架构总览与技术选型
目标:在写第一行代码之前,搞清楚 Next.js 14 App Router 在生产架构中的定位、你要负责什么、有哪些 不可逆决策。
1. Next.js 的两种部署形态
形态 A:Next 作为 BFF + 独立 API
浏览器 ───► CDN/Edge ───► Next.js (Node) ───► 独立 API (NestJS/Go/...)
│
├─ RSC 渲染
├─ Server Actions(轻业务)
└─ 静态资源
- Next 不直接连数据库,所有写操作通过 API
- 适合:有移动端 / 第三方接入 / 业务复杂度高 / 团队前后端分工明确
形态 B:Next 全栈一体(Next 直连 DB)
浏览器 ───► CDN/Edge ───► Next.js (Node) ───► DB / Cache
│
├─ RSC 渲染
├─ Server Actions(承担所有业务)
└─ Route Handlers
- 适合:MVP、内部工具、小团队、没有移动端
- 风险:业务复杂后 Server Action 散落难管;移动端来了就要把业务搬一次
本教程默认形态 A,因为这是更安全的长期选择。形态 B 的同学,把"调 API"换成"调 Prisma"即可。
💡 原理:为什么 BFF 不该写复杂业务? 单一可信源原则。一旦 Next 也能写库,你就会面对两套校验、两套事务、两套权限。短期省了一次网络跳;长期会演变成"业务逻辑散落在 Server Action 里"的难维护代码。唯一例外是纯读、且不涉及权限的页面(如公开博客),可以让 Next 直连只读副本。
2. App Router 的世界观(必须先理解)
App Router 不是 Pages Router 的换皮,核心思想完全不同:
| 维度 | Pages Router(pages/) | App Router(app/) |
|---|---|---|
| 渲染默认 | 客户端 React | 服务器组件(RSC) |
| 数据获取 | getServerSideProps | async 组件 + fetch |
| 路由 | 文件 = 页面 | 文件 = 段(layout / page / loading / error) |
| 状态 | 全部走 client | 默认 server,client 显式标 "use client" |
| 缓存 | 几乎没有 | 四层 fetch 缓存 + Router cache |
💡 RSC 的本质:组件在服务器跑完,把 React 树序列化成一种特殊流(RSC payload),浏览器拿到后直接挂载 DOM,不需要重新执行组件代码。能用 server 就用 server,client 越少越快。
3. 不可逆决策清单
| 决策 | 推荐 | 不可逆原因 |
|---|---|---|
| Router 选型 | App Router(新项目) | 切换成本极大,Pages 也将进维护模式 |
| 严格模式 | TS strict + noUncheckedIndexedAccess | 关掉后代码会迅速劣化 |
| 路由结构 | 分组 (auth) (app) + 子段 layout | 大改 URL 影响 SEO 和外链 |
| 错误响应模型 | 期望 API 返回 RFC 7807 | 前端兜底逻辑会绑死格式 |
| i18n 方案 | URL 前缀(/en/... /zh/...) | 改回中间件 cookie 模式动 SEO |
| 鉴权 token 存放 | HttpOnly Cookie,不用 localStorage | 改回 localStorage = XSS 风险倒灌 |
| 表单/Schema | Zod schema 与后端共享 | 重复定义两套类型是维护噩梦 |
| 状态分层 | URL / Server / TanStack Query / Zustand | 全塞 Redux 后期重构成本极大 |
| 包大小预算 | 首屏 JS < 200KB gzip | 超出后再压缩是攻坚战 |
⚠️ 不要把 token 存 localStorage 任何被劫持的 npm 包(供应链攻击)都可以读 localStorage,token 泄漏 = 用户被盗号。HttpOnly Cookie 在 XSS 下也读不到。这是几年前 npm 生态多次教训出来的结论。
4. Next 的"前端工程师"职责清单
很多人来到 App Router 之后还按 Pages Router 习惯做事。新的职责画面是:
| 你负责的 | 你不负责的(API 负责) |
|---|---|
| RSC 渲染策略、Suspense 边界 | 业务规则、事务、权限授予 |
| Next 四层缓存的选择 | 数据库 schema、事务隔离 |
| URL 结构、metadata、SEO | 后端鉴权 / token 签发 |
| 表单交互、乐观更新、错误兜底 | API 限流、幂等 |
| 包大小、字体、图片、Vitals | 后端可观测性 |
dangerouslySetInnerHTML 审计、CSP | API 输入校验(你也校验,但不是兜底) |
| 客户端错误监控(Sentry browser) | 服务端错误监控(Sentry node) |
| Cookie / Session 读取与转发 | Session 创建与撤销 |
💡 "前端" 现在是个误导词 Next 14 让前端工程师写真正在服务器跑的代码(RSC、Server Actions、Route Handlers)。这部分代码会读 cookie、调内网 API、做 HTML 拼装。它跑在 Node 里,可以被攻击,可以被监控,必须按服务端工程对待。第 10 章会反复强调这点。
5. 系统全景(本教程默认形态)
┌────────────────────────────┐
用户浏览器 ───────►│ CDN / Edge │
│ (Vercel / CloudFront) │
└──────────────┬─────────────┘
│ HTTPS
┌──────────────▼─────────────┐
│ Next.js (Node 20) │
│ - RSC 渲染 │
│ - Server Actions │
│ - Route Handlers │
│ - middleware.ts(Edge) │
└──────────────┬─────────────┘
│ 内网 / mTLS
┌──────────────▼─────────────┐
│ NestJS API(独立服务) │
└────────────────────────────┘
┌────────────────────────────┐
│ S3 兼容存储(预签名 URL) │
└────────────────────────────┘
┌────────────────────────────┐
│ 可观测性平面 │
│ Sentry(浏览器+Next 服务)│
│ OTel(可选,跨服务追踪) │
│ Web Vitals → 自建/Vercel │
└────────────────────────────┘
6. 性能预算(从设计阶段定)
| 指标 | 良好(目标) | 需改进 | 差 |
|---|---|---|---|
| LCP p75 真实用户 | < 2.5s | < 4s | ≥ 4s |
| INP p75 | < 200ms | < 500ms | ≥ 500ms |
| CLS p75 | < 0.1 | < 0.25 | ≥ 0.25 |
| TTFB p75 | < 800ms | < 1.8s | ≥ 1.8s |
| 首屏 client JS | < 200KB gzip | < 350KB | ≥ 500KB |
| 单页 client component 数 | 越少越好,关键页 < 20 | — | — |
| RSC 首屏 server 渲染时间 | < 200ms | — | — |
💡 预算不是上线后定的,是设计阶段定的 等你"功能堆完一测发现没救"再回头优化,大概率要砍功能或重写组件。前期就放一个 size-limit 在 CI 里,超了就 fail。
7. 路由组织建议
src/
├── app/
│ ├── layout.tsx # 根布局
│ ├── page.tsx # 首页
│ ├── (marketing)/ # 公开页(可静态化)
│ │ ├── about/page.tsx
│ │ └── pricing/page.tsx
│ ├── (auth)/ # 登录/注册
│ │ ├── login/page.tsx
│ │ └── register/page.tsx
│ ├── (app)/ # 受保护页面
│ │ ├── layout.tsx # 鉴权 layout
│ │ ├── dashboard/page.tsx
│ │ └── users/
│ │ ├── page.tsx
│ │ └── [id]/page.tsx
│ ├── api/ # Route Handlers(尽量少)
│ └── (api-internal)/ # webhook 等
├── components/ # 组件
│ ├── ui/ # shadcn 原子
│ └── features/ # 业务组件按 feature 分组
├── lib/ # 工具(api 客户端、auth、utils)
├── hooks/ # 自定义 hook
├── styles/ # 全局样式
└── middleware.ts # 鉴权 / i18n / 注入 requestId
⚠️ 不要按"层"分目录(
hooks/all-hooks.ts、components/all-buttons.tsx)。一个功能改起来要跳 5 个目录。按 功能(feature)分,内部再按需细化。
8. 你将得到什么
完整跟完这套教程后,你将有一份生产级 Next 模板,具备:
- ✅ App Router + RSC 心智清晰,知道什么时候 server / client
- ✅ 四层缓存掌握自如,数据更新 / 失效有标准流程
- ✅ 表单走 RHF + Zod + 共享 schema,前后端类型一致
- ✅ Bundle 分析在 CI 里,体积有预算有告警
- ✅ Vitals + Sentry 覆盖真实用户体验
- ✅ Docker / K8s 或 Vercel 任一方式上线,可灰度可回滚
- ✅ 一份"前端也要做"的安全 checklist
延伸阅读
- Next.js 官方文档 - App Router
- React 团队 Server Components RFC
- The Two Reacts by Dan Abramov
- web.dev Vitals
01 - 脚手架与工程化
目标:从零搭出一个 可直接进入业务开发 的 Next.js 14 仓库,跑通 lint、type-check、test、本地构建。
1. 单仓 vs Monorepo
| 项 | 单仓 | Monorepo |
|---|---|---|
| 复杂度 | 低 | 中 |
| 共享代码 | 不必 | 用 pnpm workspaces |
| 适用 | 纯前端项目 | Next + 共享 schema 包 / 多 app |
本教程默认 pnpm workspaces 单 monorepo,留出 packages/shared(放与后端共享的 Zod schema)和 packages/ui(若有可复用组件库需求)的位置。只一个 Next 应用也用 workspaces,因为后期加 schema 包成本极低,直接加。
💡 pnpm 为什么是必选 npm/yarn 的 hoisting 会把所有依赖提升到顶层
node_modules,导致"声明里没写但能 import"的幽灵依赖。pnpm 的隔离式 store 杜绝幽灵依赖,且磁盘空间省一个数量级。
2. 目录结构
my-web/
├── apps/
│ └── web/ # Next.js 应用
├── packages/
│ ├── config/ # tsconfig / eslint / prettier 基础配置
│ ├── shared/ # Zod schemas、错误码(与后端共享)
│ └── ui/ # (可选)跨 app 共用组件
├── .github/workflows/
├── .editorconfig
├── .gitignore
├── .nvmrc # 锁 Node 版本
├── .npmrc
├── package.json # 根
├── pnpm-workspace.yaml
├── tsconfig.base.json
└── README.md
3. 初始化
3.1 锁定运行时
echo "20.11.1" > .nvmrc
echo "auto-install-peers=true
shamefully-hoist=false
strict-peer-dependencies=true" > .npmrc
根 package.json:
{
"name": "my-web",
"private": true,
"engines": { "node": ">=20.10", "pnpm": ">=8.15" },
"packageManager": "pnpm@8.15.4",
"scripts": {
"dev": "pnpm --filter @my-app/web dev",
"build": "pnpm --filter @my-app/web build",
"start": "pnpm --filter @my-app/web start",
"lint": "pnpm -r lint",
"typecheck": "pnpm -r typecheck",
"test": "pnpm -r test",
"test:e2e": "pnpm --filter @my-app/web test:e2e"
},
"devDependencies": {
"typescript": "^5.4.3",
"@types/node": "^20.11.30",
"prettier": "^3.2.5",
"prettier-plugin-tailwindcss": "^0.5.11"
}
}
pnpm-workspace.yaml:
packages:
- "apps/*"
- "packages/*"
4. TS / ESLint / Prettier 基线
4.1 共享 TS 配置
packages/config/typescript/base.json:
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"useUnknownInCatchVariables": true,
"exactOptionalPropertyTypes": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"resolveJsonModule": true,
"verbatimModuleSyntax": true
}
}
💡
noUncheckedIndexedAccess是高级工程师的护身符 默认arr[0]类型是T,但运行时可能是undefined。开启后类型变T | undefined,强制处理。线上 50% 的"undefined is not a function"都是这条规则能挡住的。
Next 专用 packages/config/typescript/next.json:
{
"extends": "./base.json",
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"jsx": "preserve",
"incremental": true,
"noEmit": true,
"allowJs": false,
"paths": { "@/*": ["./src/*"] },
"plugins": [{ "name": "next" }]
}
}
4.2 ESLint
pnpm add -Dw eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin \
eslint-config-next eslint-plugin-jsx-a11y eslint-plugin-import \
eslint-config-prettier
apps/web/.eslintrc.json:
{
"extends": [
"next/core-web-vitals",
"plugin:@typescript-eslint/strict-type-checked",
"plugin:@typescript-eslint/stylistic-type-checked",
"plugin:jsx-a11y/recommended",
"prettier"
],
"parserOptions": { "projectService": true, "tsconfigRootDir": "." },
"rules": {
"@typescript-eslint/consistent-type-imports": "error",
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": "error",
"import/order": ["error", {
"groups": ["builtin", "external", "internal", "parent", "sibling"],
"newlines-between": "always",
"alphabetize": { "order": "asc" }
}]
}
}
💡
no-floating-promises是必开 Server Component 是 async,Server Action 是 async,忘了 await 就是潜在 bug。这条 lint 把所有未 await 的 Promise 揪出来。
⚠️
jsx-a11y不要 disable。a11y 失误在编译期就该被挡。
4.3 Prettier + Tailwind 类排序
.prettierrc.json:
{
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"plugins": ["prettier-plugin-tailwindcss"]
}
prettier-plugin-tailwindcss 自动按官方推荐顺序排序 Tailwind class,team review 时永远一致。
5. 创建 Next.js 应用
pnpm dlx create-next-app@latest apps/web \
--typescript --tailwind --eslint --app \
--src-dir --import-alias "@/*" --no-turbo
调整 apps/web/package.json:
{
"name": "@my-app/web",
"private": true,
"scripts": {
"dev": "next dev -p 3000",
"build": "next build",
"start": "next start -p 3000",
"lint": "next lint --max-warnings=0",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:e2e": "playwright test"
},
"dependencies": {
"@my-app/shared": "workspace:*",
"next": "14.2.0",
"react": "18.3.0",
"react-dom": "18.3.0"
}
}
6. next.config.mjs 生产级配置
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
poweredByHeader: false,
productionBrowserSourceMaps: true, // 上 Sentry 时方便定位
output: "standalone", // 部署用,见 09 章
experimental: {
typedRoutes: true, // <Link href> 类型校验
serverActions: { bodySizeLimit: "2mb" },
},
transpilePackages: ["@my-app/shared", "@my-app/ui"],
images: {
remotePatterns: [
{ protocol: "https", hostname: "cdn.example.com" },
],
},
async headers() {
return [{
source: "/(.*)",
headers: [
{ key: "X-Frame-Options", value: "DENY" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
{ key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" },
],
}];
},
};
export default nextConfig;
要点:
output: "standalone"让 Next 输出能独立运行的产物,Docker 镜像最小化(见 09 章)typedRoutes:<Link href="/users/foo-bar">写错时编译就报transpilePackages:monorepo 中@my-app/shared是 symlink,默认会被当node_modules跳过编译。加进来让 Next 当源码处理headers():基础安全头(详 10 章)images.remotePatterns必须显式 allowlist 第三方域,避免被滥用
💡
transpilePackages救命 这个坑非常常见:在 monorepo 里 import 共享包,本地 dev 没事,生产 build 报"Unexpected token"。原因是默认 Next 不编译 node_modules,而 workspace 包通过 symlink 出现在 node_modules 里。
7. 共享 schema:packages/shared
放 Zod schema(与后端共享)、错误码常量、与 API 通信的 fetcher 工具。
packages/shared/package.json:
{
"name": "@my-app/shared",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./schemas": "./src/schemas/index.ts"
},
"dependencies": { "zod": "^3.22.4" }
}
示例:
// packages/shared/src/schemas/user.ts
import { z } from "zod";
export const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(80),
password: z.string().min(12).max(200),
});
export type CreateUserInput = z.infer<typeof CreateUserSchema>;
💡 同一份 Zod schema,前后端共用 RHF 用它做客户端校验;Server Action 用它做服务端校验;API 用它做后端校验。改字段名、长度、枚举都只改一处。无独立 DTO 类、无类型漂移。
8. 环境变量
## .env.example
NEXT_PUBLIC_APP_URL=https://app.example.com
API_URL=http://api:3001
SESSION_SECRET=replace-me-32-chars-minimum-xxxxxxx
SENTRY_DSN=
NEXT_PUBLIC_SENTRY_DSN=
校验:
// src/lib/env.ts
import { z } from "zod";
const ServerEnv = z.object({
NODE_ENV: z.enum(["development", "test", "production"]),
API_URL: z.string().url(),
SESSION_SECRET: z.string().min(32),
SENTRY_DSN: z.string().optional(),
});
const ClientEnv = z.object({
NEXT_PUBLIC_APP_URL: z.string().url(),
NEXT_PUBLIC_SENTRY_DSN: z.string().optional(),
});
export const env = (() => {
const parsed = ServerEnv.merge(ClientEnv).safeParse(process.env);
if (!parsed.success) {
console.error("❌ Invalid env:", parsed.error.flatten().fieldErrors);
throw new Error("Invalid env");
}
return parsed.data;
})();
⚠️
NEXT_PUBLIC_*暴露给浏览器 这是 Next 的硬性约定:NEXT_PUBLIC_前缀的变量在 build 时被内联到 client bundle。任何 secret 绝对不能用NEXT_PUBLIC_前缀。SESSION_SECRET / API_URL / DATABASE_URL永远没有这个前缀。
💡
import "server-only"在任何只能在服务端运行的文件第一行加import "server-only";。如果被某个"use client"组件 import → 编译报错。防止把 secret 带进 client bundle。
9. Git hooks(husky + lint-staged)
pnpm add -Dw husky lint-staged
pnpm exec husky init
.husky/pre-commit:
pnpm exec lint-staged
package.json:
"lint-staged": {
"*.{ts,tsx,js,jsx}": ["eslint --fix --max-warnings=0", "prettier --write"],
"*.{json,md,yml,yaml,css}": ["prettier --write"]
}
⚠️ 不要在 pre-commit 跑全量 typecheck 或 build 那是 CI 的事。pre-commit 必须 < 10s,否则同事会
--no-verify绕过。
10. 验证脚手架
pnpm install
pnpm dev # http://localhost:3000
pnpm typecheck # 应该过
pnpm lint # 应该过
pnpm build # 应该过,产物在 apps/web/.next/standalone
import 共享 schema 测试一下:
// src/app/page.tsx
import { CreateUserSchema } from "@my-app/shared/schemas/user";
console.log(CreateUserSchema.shape); // 编译能过、运行能跑
11. CI 最小骨架(09 章详写)
.github/workflows/ci.yml:
name: CI
on:
push: { branches: [main] }
pull_request:
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version-file: ".nvmrc" }
- run: corepack enable
- uses: actions/cache@v4
with:
path: ~/.local/share/pnpm/store
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm typecheck
- run: pnpm test
- run: pnpm build
env:
NEXT_PUBLIC_APP_URL: https://app.example.com
API_URL: http://localhost:3001
SESSION_SECRET: ${{ secrets.CI_SESSION_SECRET }}
12. 提交前检查清单
-
.env.example完整(列所有变量,值用占位) -
pnpm-lock.yaml入 git - tsconfig 严格模式全开
- CI 跑 lint / typecheck / test / build
-
next.config.mjs设了output: "standalone"和安全头 -
import "server-only"用于含 secret 的工具
延伸阅读
02 - App Router + RSC 核心
目标:把 Next 14 App Router 当作 真正的服务器渲染框架 用,而不是"客户端 React 但放在
app/目录"。本章讲文件约定、RSC 心智、客户端边界、metadata、错误处理、middleware。
1. 文件约定速查
app/
├── layout.tsx # 根布局(必须)
├── page.tsx # 首页
├── loading.tsx # 段级 Suspense fallback
├── error.tsx # 段级错误边界(必须 "use client")
├── not-found.tsx
├── global-error.tsx # 根级错误兜底(必须 "use client",包整个 html)
├── template.tsx # 像 layout 但每次导航重新挂载
├── (auth)/ # 路由分组,不影响 URL,可有独立 layout
│ ├── login/page.tsx → /login
│ └── register/page.tsx → /register
├── (app)/ # 另一组,有独立 layout
│ ├── layout.tsx # 这一组共享的 layout(可包鉴权 wrapper)
│ ├── dashboard/page.tsx → /dashboard
│ └── users/
│ ├── page.tsx → /users
│ ├── new/page.tsx → /users/new
│ └── [id]/
│ ├── page.tsx → /users/:id
│ └── @modal/... # 平行路由(可选)
└── api/ # Route Handlers
└── webhook/route.ts
特殊文件备忘:
| 文件 | 作用 | 默认渲染 |
|---|---|---|
layout.tsx | 持久布局,不随 page 重渲染 | Server |
page.tsx | 路由叶子 | Server |
loading.tsx | 包到该段的 <Suspense fallback> | Server |
error.tsx | 包到该段的 ErrorBoundary | Client |
global-error.tsx | 兜底替换根 layout | Client |
not-found.tsx | notFound() 时显示 | Server |
template.tsx | 每次导航重新挂载(动画用) | 视使用 |
route.ts | HTTP endpoint(GET/POST/...) | Node 或 Edge |
default.tsx | 平行路由的占位 | Server |
💡 layout 不重渲染的好处 用户在
/users↔/users/123来回切,(app)/layout.tsx只挂载一次。全局导航、用户头像、通知中心放 layout 里,不会闪烁。
⚠️
error.tsx/global-error.tsx必须"use client"错误边界需要订阅、reset 等客户端能力。global-error.tsx特殊:它要在根 layout 都崩了的时候兜底,所以必须自己包<html>和<body>。
2. Server Component vs Client Component
2.1 默认 Server
// app/users/page.tsx —— 默认 Server Component
export default async function UsersPage() {
const users = await getUsersFromApi(); // 在服务器跑
return (
<ul>
{users.map(u => <li key={u.id}>{u.name}</li>)}
</ul>
);
}
Server Component 的能力:
- ✅
async / await - ✅ 直接读 cookies / headers / env
- ✅ 直接调内网 API / DB(形态 B)
- ✅ 不会进 client bundle(零字节给浏览器)
- ❌ 不能用 hooks(
useState/useEffect等) - ❌ 不能监听事件(
onClick) - ❌ 不能用浏览器 API
2.2 Client 时机
需要客户端能力时显式标:
"use client";
import { useState } from "react";
export function Counter() {
const [n, setN] = useState(0);
return <button onClick={() => setN(n + 1)}>{n}</button>;
}
该标 "use client" 的信号:
- 用 hooks(useState / useEffect / useReducer / useContext)
- 监听事件(onClick / onChange / onSubmit)
- 访问浏览器 API(window / localStorage / IntersectionObserver / matchMedia)
- 用只支持 client 的库(framer-motion 大部分 API、redux、zustand 等)
2.3 组合规则
"use client" 标记的是边界,不是文件。一旦标了,这个组件和它 import 的子组件都被"客户端化"(默认进客户端 bundle)。
可以:
- Server import Client(server 把 client 当作"叶子",发送序列化的 props)
- Client 不能直接 import Server(但可以接收 server 组件作为
childrenprop)
2.4 关键模式:用 children 把 Server 塞进 Client
这是 App Router 最重要的一个模式,99% 的 RSC 优化围绕它转:
// ClientShell.tsx —— "use client"
"use client";
import { useState } from "react";
export function ClientShell({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
return (
<div>
<button onClick={() => setOpen(!open)}>{open ? "Close" : "Open"}</button>
{open && children}
</div>
);
}
// page.tsx —— Server
import { ClientShell } from "./ClientShell";
import { ServerHeavy } from "./ServerHeavy"; // 数据获取重的 server 组件
export default async function Page() {
return (
<ClientShell>
<ServerHeavy /> {/* 仍然是 Server 组件! */}
</ClientShell>
);
}
💡 原理:
children是 prop,React 在 server 端就 render 好ServerHeavy的 RSC payload,作为 prop 传给ClientShell。ClientShell在 client 拿到的是"已 render 好的 React 元素",不会重新执行 ServerHeavy。这是 RSC 最关键的协作模式。
2.5 经典反模式:把整个页面标 client
// app/dashboard/page.tsx —— 错误示范
"use client";
export default function Dashboard() {
// 几百行,有一些状态、一些数据请求...
}
问题:
- 整个组件树进 client bundle,即便其中 80% 是静态展示
- 失去 Server Component 的优势(零 JS、零 RTT、SEO)
- 数据获取被迫走客户端 (
useEffect + fetch),首屏空白
正确做法:页面是 Server,只把需要互动的子组件抽成 Client。
⚠️ 代码 review 时看到
"use client"在app/.../page.tsx顶部要警惕。99% 是过度客户端化。
3. 路由参数
// app/users/[id]/page.tsx
interface Props {
params: { id: string };
searchParams: { tab?: string; page?: string };
}
export default async function UserPage({ params, searchParams }: Props) {
const user = await getUser(params.id);
const tab = searchParams.tab ?? "overview";
return <div>{user.name} - {tab}</div>;
}
要点:
- 动态段(
[id])→params.id(字符串,或string[]当用[...slug]) - 查询参数 →
searchParams(对象,值可能是string | string[] | undefined) - 访问
searchParams会让该路由变 dynamic(无法静态化)
💡 类型化 routes
next.config.mjs开experimental.typedRoutes,<Link href="/users/123">在编译时校验。配合params类型化,路由错误编译期就挡住。
4. 动态 / 静态 与 dynamic 配置
// 在 page/layout 顶部显式声明
export const dynamic = "auto" | "force-dynamic" | "force-static" | "error";
export const revalidate = 60; // 段级 ISR,秒
export const fetchCache = "default-cache" | "force-no-store" | "force-cache";
export const runtime = "nodejs" | "edge"; // 运行时
export const preferredRegion = "iad1"; // Edge 偏好区
| 选项 | 行为 |
|---|---|
dynamic: "auto" | 默认。用了 cookies/headers/dynamic param/no-store → dynamic;否则 static |
dynamic: "force-static" | 强制静态;遇到动态 API 报错 |
dynamic: "force-dynamic" | 强制每次新鲜 |
dynamic: "error" | 严格模式:遇到动态 API 报错(用来锁定静态意图) |
⚠️
dynamic: "force-dynamic"是性能毒药 写出来很爽,但整页都不再被缓存,server 每次重渲染。只有真正需要"每个请求都新鲜"的页面才用(比如交易中页面)。展示类页面应该用revalidate或cache: "no-store"在具体 fetch 上控制。
5. metadata 与 SEO
5.1 静态 metadata
// app/layout.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
metadataBase: new URL("https://app.example.com"),
title: { default: "My App", template: "%s | My App" },
description: "Production-grade SaaS",
openGraph: {
type: "website",
locale: "zh_CN",
siteName: "My App",
},
twitter: { card: "summary_large_image" },
icons: {
icon: "/favicon.ico",
apple: "/apple-touch-icon.png",
},
};
5.2 动态 metadata
// app/users/[id]/page.tsx
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const user = await getUser(params.id);
return {
title: user.name,
description: user.bio,
openGraph: {
images: [`/api/og?name=${encodeURIComponent(user.name)}`],
},
};
}
5.3 动态 OG 图
// app/api/og/route.tsx
import { ImageResponse } from "next/og";
export const runtime = "edge";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const name = searchParams.get("name") ?? "Guest";
return new ImageResponse(
(
<div style={{ display: "flex", fontSize: 60, background: "white", width: "100%", height: "100%", alignItems: "center", justifyContent: "center" }}>
{name}
</div>
),
{ width: 1200, height: 630 },
);
}
💡
metadataBase必填 否则openGraph.images: ["/foo.png"]会用相对路径 → 大部分爬虫拿不到。
5.4 sitemap / robots
// app/sitemap.ts
export default async function sitemap() {
const users = await getPublicUsers();
return [
{ url: "https://app.example.com", lastModified: new Date(), priority: 1 },
...users.map(u => ({
url: `https://app.example.com/u/${u.slug}`,
lastModified: u.updatedAt,
priority: 0.7,
})),
];
}
// app/robots.ts
export default function robots() {
return {
rules: { userAgent: "*", allow: "/", disallow: ["/(app)/", "/api/"] },
sitemap: "https://app.example.com/sitemap.xml",
};
}
6. 错误与 404
6.1 触发 404
import { notFound } from "next/navigation";
export default async function Page({ params }) {
const user = await getUser(params.id);
if (!user) notFound(); // 渲染 not-found.tsx
return ...;
}
app/users/[id]/not-found.tsx:
export default function UserNotFound() {
return <p>该用户不存在</p>;
}
6.2 段级 error 边界
app/(app)/error.tsx:
"use client";
import { useEffect } from "react";
import * as Sentry from "@sentry/nextjs";
export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
useEffect(() => { Sentry.captureException(error); }, [error]);
return (
<div>
<h2>出错了</h2>
<p>错误号:{error.digest}</p>
<button onClick={reset}>重试</button>
</div>
);
}
要点:
- 错误对象有
digest字段(Next 生成的服务端错误 ID,日志关联用) reset是 React 提供的重试函数(重渲染该段)- 必须
"use client"
6.3 根级 global-error
app/global-error.tsx:
"use client";
export default function GlobalError({ error, reset }: { error: Error; reset: () => void }) {
return (
<html>
<body>
<h2>应用崩了</h2>
<button onClick={reset}>重试</button>
</body>
</html>
);
}
⚠️ production 才显示 global-error 开发环境 Next 会优先显示 dev error overlay,你测试时记得
pnpm build && pnpm start验证。
6.4 redirect
import { redirect, permanentRedirect } from "next/navigation";
if (!user) redirect("/login"); // 307
if (legacyUrl) permanentRedirect("/new"); // 308
💡
redirect是 throw,不是 return 它内部 throw 一个特殊 error 让 Next 处理。所以别用 try/catch 包它,会吞掉重定向。如果一定要包,在 catch 里if (isRedirectError(e)) throw e;重抛。
7. Middleware(middleware.ts)
在所有请求前跑,Edge runtime。
// middleware.ts
import { NextResponse, type NextRequest } from "next/server";
const PUBLIC_PATHS = ["/login", "/register", "/api/webhook"];
export function middleware(req: NextRequest) {
// 1. 注入 request id
const requestId = req.headers.get("x-request-id") ?? crypto.randomUUID();
const headers = new Headers(req.headers);
headers.set("x-request-id", requestId);
// 2. 简单鉴权(基于 cookie 存在性)
const pathname = req.nextUrl.pathname;
const isPublic = PUBLIC_PATHS.some(p => pathname.startsWith(p));
const hasSession = req.cookies.has("sid");
if (!isPublic && !hasSession) {
const url = req.nextUrl.clone();
url.pathname = "/login";
url.searchParams.set("from", pathname);
return NextResponse.redirect(url);
}
const res = NextResponse.next({ request: { headers } });
res.headers.set("x-request-id", requestId);
return res;
}
export const config = {
matcher: [
// 排除 _next 静态资源和图标
"/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml).*)",
],
};
⚠️ Middleware 是 Edge runtime 不能用 Node 库(
fs/crypto.createHmac行为不同 /Buffer),不能调 DB。鉴权决策只基于 cookie 自身(签名 JWT 在 cookie 里),不要在 middleware 里查 DB。
💡 不要在 middleware 里做完整鉴权 Middleware 是第一道防线 + 路由分发,真正的鉴权要在 layout / page 里再做一次(可读完整 session、做权限判断)。理由:1) middleware 不能查 DB,无法做"用户被封禁"等检查;2) 防御不要单点;3) middleware 性能敏感,慢一点全站受影响。
8. Route Handlers(app/api/.../route.ts)
// app/api/health/route.ts
import { NextResponse } from "next/server";
export const runtime = "nodejs"; // 或 "edge"
export const dynamic = "force-dynamic";
export async function GET() {
return NextResponse.json({ status: "ok", at: new Date().toISOString() });
}
何时该用 Route Handlers(00 章 BFF 边界已讲)?
- ✅ 接 webhook(GitHub / Stripe / 钉钉)—— 需要公网入口
- ✅ 静态资源生成(sitemap / og-image)
- ✅ BFF 转发 / 适配(多个 API 合并)
- ❌ 业务 CRUD(走 NestJS API,不在 Next 里写)
// app/api/webhook/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
export const runtime = "nodejs";
export async function POST(req: NextRequest) {
const sig = req.headers.get("stripe-signature");
if (!sig) return new NextResponse("missing sig", { status: 400 });
const body = await req.text(); // 注意 raw body 验签
// 验签 + 转发到后端
const res = await fetch(`${process.env.API_URL}/webhooks/stripe`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-internal-secret": process.env.INTERNAL_WEBHOOK_SECRET!,
"x-stripe-signature": sig,
},
body,
});
return NextResponse.json({ received: true });
}
⚠️ Route Handler 默认是 Node runtime 需要 streaming / KV / 极低冷启动时选
runtime = "edge",但 Edge 没 Node 内置库(crypto / Buffer / fs 等)且包大小有限,谨慎选择。
9. 平行路由(Parallel Routes)与拦截路由(Intercepting Routes)
进阶模式,新项目大部分不需要,但要知道存在。
9.1 平行路由
app/
├── @modal/
│ └── default.tsx # 默认空
├── layout.tsx # 接收 modal slot
└── users/[id]/page.tsx
layout.tsx:
export default function Layout({ children, modal }: { children: React.ReactNode; modal: React.ReactNode }) {
return <>{children}{modal}</>;
}
9.2 拦截路由(用于"列表页打开详情走 modal,直接访问 URL 走全屏页")
app/
├── users/page.tsx # 列表
├── users/[id]/page.tsx # 全屏详情(直接访问)
└── @modal/
└── (..)users/[id]/page.tsx # 列表内打开时显示为 modal
💡 大多场景用 shadcn Dialog + 客户端状态就够了 平行路由 + 拦截路由让 URL 优雅(modal 状态进 URL,可分享、可后退),但学习曲线陡。不要为了用而用。
10. metadata 之外的 SEO
- 服务端渲染的内容对搜索引擎可见(RSC 一大优势)
- 客户端组件里的内容也会在 RSC payload 里 SSR,也可见
- 但:
useEffect里设置的 DOM 内容不可见(只对真实用户可见)
💡 SEO 测试:
curl https://your-page然后 grep 你期望被搜索的关键词。如果不在响应里 → 改成 Server Component 或在 generateMetadata 里加。
11. 心智速查表
| 想做 | 用 |
|---|---|
| 渲染页面 | Server Component(默认) |
| 互动 / 状态 | Client Component("use client") |
| 服务端数据 | Server Component 里 async/await fetch |
| 持久化布局 | layout.tsx |
| 段级加载占位 | loading.tsx 或 <Suspense> |
| 段级错误兜底 | error.tsx(client) |
| 404 | notFound() + not-found.tsx |
| 重定向 | redirect() / permanentRedirect() |
| 全局拦截 | middleware.ts(Edge) |
| HTTP 端点 | route.ts(谨慎用) |
| SEO 元数据 | metadata / generateMetadata |
| 动态 OG 图 | next/og ImageResponse |
| 字段类型化 URL | experimental.typedRoutes |