Next.js 生产级开发教程

4 阅读20分钟

面向 中级 → 高级 工程师的生产级开发手册。每章附 原理章节(💡)和 踩坑提醒(⚠️)。

本文档由 11 个子主题拼接而成,可一口气读完,也可按下面目录跳读。


总目录

  1. 00 - 架构总览与技术选型
  2. 01 - 脚手架与工程化
  3. 02 - App Router + RSC 核心
  4. 03 - 数据获取与四层缓存
  5. 04 - Server Actions 与表单
  6. 05 - 状态管理与 UI 体系
  7. 06 - 性能与无障碍
  8. 07 - 测试金字塔(前端视角)
  9. 08 - 可观测性与监控(前端视角)
  10. 09 - 部署:Docker / Vercel / K8s
  11. 10 - 安全加固与上线 Checklist(前端视角)

附录(高级进阶)

  1. B1 - 前端高级专题
  2. B2 - 前端事故 10 例
  3. B3 - 浏览器侧性能诊断
  4. B4 - 工程师软实力(前端版)

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)
数据获取getServerSidePropsasync 组件 + 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 风险倒灌
表单/SchemaZod 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 审计、CSPAPI 输入校验(你也校验,但不是兜底)
客户端错误监控(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.tscomponents/all-buttons.tsx)。一个功能改起来要跳 5 个目录。按 功能(feature)分,内部再按需细化。

8. 你将得到什么

完整跟完这套教程后,你将有一份生产级 Next 模板,具备:

  • ✅ App Router + RSC 心智清晰,知道什么时候 server / client
  • ✅ 四层缓存掌握自如,数据更新 / 失效有标准流程
  • ✅ 表单走 RHF + Zod + 共享 schema,前后端类型一致
  • ✅ Bundle 分析在 CI 里,体积有预算有告警
  • ✅ Vitals + Sentry 覆盖真实用户体验
  • ✅ Docker / K8s 或 Vercel 任一方式上线,可灰度可回滚
  • ✅ 一份"前端也要做"的安全 checklist

延伸阅读


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包到该段的 ErrorBoundaryClient
global-error.tsx兜底替换根 layoutClient
not-found.tsxnotFound() 时显示Server
template.tsx每次导航重新挂载(动画用)视使用
route.tsHTTP 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 组件作为 children prop)

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 传给 ClientShellClientShell 在 client 拿到的是"已 render 好的 React 元素",不会重新执行 ServerHeavy。这是 RSC 最关键的协作模式

2.5 经典反模式:把整个页面标 client

// app/dashboard/page.tsx —— 错误示范
"use client";
export default function Dashboard() {
  // 几百行,有一些状态、一些数据请求...
}

问题:

  1. 整个组件树进 client bundle,即便其中 80% 是静态展示
  2. 失去 Server Component 的优势(零 JS、零 RTT、SEO)
  3. 数据获取被迫走客户端 (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.mjsexperimental.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 每次重渲染。只有真正需要"每个请求都新鲜"的页面才用(比如交易中页面)。展示类页面应该用 revalidatecache: "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)
404notFound() + not-found.tsx
重定向redirect() / permanentRedirect()
全局拦截middleware.ts(Edge)
HTTP 端点route.ts(谨慎用)
SEO 元数据metadata / generateMetadata
动态 OG 图next/og ImageResponse
字段类型化 URLexperimental.typedRoutes

延伸阅读