目录

Next.js 最佳实践

目录

Next.js 16.2.3 App Router 最佳实践

Next.js 15 已经不是“如何快速搭一个 React 站点”的工具,而是一个完整的全栈 Web 运行时与工程框架。对于生产团队而言,真正的难点从来不是“页面能不能跑起来”,而是:

  • 能否长期维持清晰的边界;
  • 能否在数据、缓存、权限和性能之间做出一致决策;
  • 能否让多人协作时不把项目演变成一团隐式耦合;
  • 能否在需求迭代、故障排查和发布过程中保持可预测性。

本文不是入门教程,而是一份面向生产环境的 Next.js 16.2.3 App Router 实践指南。重点覆盖 Server Components、缓存、Server Actions、性能、安全和团队规范,适合作为项目约定或技术基线文档。


核心原则

在具体实践之前,先统一几条原则。很多后续设计分歧,本质上都来自这几条原则是否一致。

1. 默认 Server First

在 App Router 中,默认使用服务端组件不是“新特性体验”,而是首选架构。页面、布局、数据读取、权限判断、SEO 生成,应优先在服务端完成。

这带来的收益是:

  • 更少的客户端 JavaScript;
  • 更好的首屏性能与流式渲染能力;
  • 更自然地访问数据库、私有 API、密钥和服务端能力;
  • 更清晰的数据 ownership。

只有在确实需要以下能力时,才引入客户端组件:

  • 浏览器事件处理;
  • 本地交互状态;
  • useEffectuseStateuseReducer
  • 浏览器 API,如 localStorageIntersectionObserver
  • 只能在浏览器运行的第三方 UI 库。

2. 明确边界比“代码复用”更重要

很多 Next.js 项目后期变乱,不是因为功能太多,而是因为“图方便”:

  • 客户端直接复用服务端对象;
  • 页面层直接拿数据库模型往下传;
  • 任何地方都能发请求;
  • 缓存策略由调用者随手决定;
  • 权限只做在 UI 层。

生产项目里,边界必须先于复用考虑。复用是收益,边界是约束;没有约束的复用,通常会产生更大的维护成本。

3. 缓存是架构问题,不是性能补丁

在 App Router 里,缓存不再只是“加速手段”,它会直接影响:

  • 数据新鲜度;
  • 用户可见行为;
  • 部署后的一致性;
  • 突发流量下的稳定性;
  • Server Actions 与页面刷新的耦合方式。

因此缓存策略必须在模块层统一定义,而不是在页面里临时决定。

4. 安全与授权必须在服务端落地

不要把“前端禁掉按钮”当成权限控制。真正的授权必须在服务端执行,包括:

  • Route Handler;
  • Server Action;
  • 服务端组件中的敏感数据读取;
  • 后端 service/repository 层。

UI 只是体验层,不是安全边界。

5. 团队约定优先于个人风格

Next.js 功能多、灵活度高,如果没有团队约定,很容易出现同一项目里三种数据获取方式、四种缓存写法、两套路由组织方式并存。技术债不会立刻爆炸,但会在半年后集中体现。


安装与版本策略

Node.js 与依赖基线

生产项目建议至少统一以下基线:

  • Node.js 20.9+;
  • Next.js 16.2.3;
  • React 19;
  • TypeScript 严格模式;
  • ESLint 与统一格式化规则;
  • 锁定包管理器(pnpm、npm、yarn、bun 选一种);
  • CI 中强制使用 lockfile 安装。

创建新项目示例:

pnpm create next-app@latest my-app
cd my-app
pnpm dev

建议创建项目时默认启用:

  • TypeScript
  • App Router
  • ESLint
  • src/ 目录
  • Tailwind CSS(如团队采用)
  • import alias

版本策略建议

对于 Next.js 项目,不建议长期停留在“某个老版本但还能跑”的状态。推荐策略:

  • major 版本升级先做兼容性验证,再批量迁移;
  • minor/patch 版本跟随官方节奏定期升级;
  • 升级前阅读官方 release notes 和 breaking changes;
  • 在 staging 环境验证:
    • 构建行为;
    • 缓存行为;
    • middleware 行为;
    • Server Actions;
    • 第三方库兼容性。

工程建议

  • 锁定 Node 版本:.nvmrc.node-version 或 CI 配置;
  • 锁定包管理器版本;
  • 使用 Renovate 或 Dependabot 做依赖更新;
  • 升级 Next.js 时优先验证:
    • fetch 缓存语义;
    • 静态/动态路由判定;
    • Route Handler 运行时;
    • 图片与字体处理;
    • 构建输出模式。

App Router 与 Pages Router

为什么新项目优先 App Router

对于新项目,建议默认选择 App Router。原因不是“它更新”,而是它在架构上更适合现代全栈 React 应用:

  • 以服务端组件为默认模型;
  • 支持布局嵌套与共享;
  • 更自然支持流式渲染与 Suspense;
  • 数据获取与 UI 组织更接近业务边界;
  • Metadata、错误边界、loading、not-found 等机制更内聚;
  • 更适合缓存标签、路径失效与 Server Actions。

Pages Router 仍适用的场景

如果现有系统已经稳定运行在 Pages Router,且满足以下条件,可以分阶段迁移,而不是重写:

  • 历史包袱较重;
  • 依赖大量旧生态或中间件;
  • 迁移收益短期不明显;
  • 团队尚未建立 App Router 的边界意识。

决策建议

  • 新项目:优先 App Router;
  • 增量重构:采用分区迁移,不要全站一次性翻新;
  • 老项目:只有在性能、架构复杂度、协作效率确实受限时再推动迁移。

推荐项目结构

目录结构不需要追求“唯一正确”,但必须体现职责边界。下面是一种适合中大型项目的结构:

src/
├── app/
│   ├── (marketing)/
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   └── pricing/page.tsx
│   ├── (app)/
│   │   ├── dashboard/page.tsx
│   │   ├── settings/page.tsx
│   │   └── users/[id]/page.tsx
│   ├── api/
│   │   └── reports/route.ts
│   ├── globals.css
│   ├── layout.tsx
│   ├── error.tsx
│   ├── not-found.tsx
│   └── sitemap.ts
├── components/
│   ├── ui/
│   ├── shared/
│   └── feature/
├── features/
│   ├── auth/
│   │   ├── actions.ts
│   │   ├── queries.ts
│   │   ├── dto.ts
│   │   ├── schema.ts
│   │   └── components/
│   ├── users/
│   └── billing/
├── lib/
│   ├── db/
│   ├── auth/
│   ├── cache/
│   ├── env/
│   ├── observability/
│   └── utils/
├── server/
│   ├── services/
│   ├── repositories/
│   └── policies/
├── styles/
├── tests/
├── types/
└── middleware.ts

结构建议

app/

只放路由相关内容:

  • page.tsx
  • layout.tsx
  • loading.tsx
  • error.tsx
  • not-found.tsx
  • route.ts
  • template.tsx
  • metadata 相关文件

不要把大量业务逻辑直接堆在 app/ 目录里。

features/

按业务域组织代码,而不是按技术类型平铺。比如用户域、账单域、权限域,各自包含:

  • 读查询;
  • 写操作;
  • DTO;
  • schema;
  • 该业务的小型组件;
  • domain-specific hooks(若确有客户端需求)。

server/

放明确只能在服务端运行的业务实现,如:

  • repository;
  • service;
  • policy;
  • 审计与授权逻辑;
  • 第三方系统集成。

components/

放可复用 UI 组件,但要区分层次:

  • ui/:基础设计系统组件;
  • shared/:跨业务复用的组合组件;
  • feature/:与具体业务强相关但可复用的展示组件。

Server First 与客户端边界

先从服务端组件开始

一个常见错误是:因为团队熟悉传统 React,于是默认把整个页面写成客户端组件。这样会直接失去 App Router 的主要收益。

正确方式是先写成服务端组件:

// app/dashboard/page.tsx
import { getDashboardSummary } from '@/features/dashboard/queries'

export default async function DashboardPage() {
  const summary = await getDashboardSummary()

  return (
    <main>
      <h1>Dashboard</h1>
      <p>MRR: {summary.mrr}</p>
      <p>Active users: {summary.activeUsers}</p>
    </main>
  )
}

把交互压缩到最小边界

当页面里只有一小部分需要交互,不要把整个页面都标记为 'use client'。只把那部分抽成客户端岛屿。

// app/dashboard/page.tsx
import { getDashboardSummary } from '@/features/dashboard/queries'
import { DateRangePicker } from '@/features/dashboard/components/date-range-picker'

export default async function DashboardPage() {
  const summary = await getDashboardSummary()

  return (
    <main>
      <h1>Dashboard</h1>
      <DateRangePicker />
      <p>MRR: {summary.mrr}</p>
    </main>
  )
}
// features/dashboard/components/date-range-picker.tsx
'use client'

import { useState } from 'react'

export function DateRangePicker() {
  const [range, setRange] = useState('30d')

  return (
    <div>
      <button onClick={() => setRange('7d')}>7d</button>
      <button onClick={() => setRange('30d')}>30d</button>
      <span>{range}</span>
    </div>
  )
}

边界设计原则

客户端组件应该尽量承担:

  • 交互;
  • 浏览器状态;
  • 小范围即时反馈;
  • 与 DOM 直接相关的逻辑。

服务端组件应该承担:

  • 数据获取;
  • 数据整形;
  • 访问控制;
  • SEO;
  • 大部分页面拼装。

何时不该用客户端组件

以下情况经常被误判:

  • 只是为了 map 列表展示:不需要客户端组件;
  • 只是为了异步请求数据:服务端组件更合适;
  • 只是想调用数据库:必须服务端;
  • 只是想根据 cookie/session 决定展示内容:优先服务端;
  • 只是想做首屏过滤:通常优先 URL + 服务端渲染。

server-only 与 DTO

为什么需要 server-only

在多人协作中,最危险的不是“不会用服务端组件”,而是把服务端模块无意中引入到客户端,导致:

  • 打包错误;
  • 私有实现泄漏;
  • 环境变量误用;
  • 服务端 API 与客户端边界模糊。

对于明确只能在服务端使用的模块,建议加 server-only

// server/services/user-service.ts
import 'server-only'

import { db } from '@/lib/db'

export async function getInternalUserRecord(userId: string) {
  return db.user.findUnique({
    where: { id: userId },
  })
}

这样一旦客户端误引入,会更早暴露问题。

不要把数据库模型直接传给 UI

生产项目里非常推荐使用 DTO(Data Transfer Object)作为跨层传输对象,而不是直接把 ORM 结果一路透传。

不推荐:

const user = await db.user.findUnique(...)
return <UserProfile user={user} />

问题在于:

  • 字段过多,暴露不必要信息;
  • UI 依赖数据库字段命名;
  • 一旦 schema 变化,影响面巨大;
  • 容易把敏感字段直接传给客户端。

推荐:

// features/users/dto.ts
export type UserProfileDto = {
  id: string
  displayName: string
  avatarUrl: string | null
  role: 'admin' | 'member'
}
// features/users/queries.ts
import 'server-only'

import { db } from '@/lib/db'
import type { UserProfileDto } from './dto'

export async function getUserProfileDto(userId: string): Promise<UserProfileDto | null> {
  const user = await db.user.findUnique({
    where: { id: userId },
    select: {
      id: true,
      name: true,
      avatarUrl: true,
      role: true,
    },
  })

  if (!user) return null

  return {
    id: user.id,
    displayName: user.name,
    avatarUrl: user.avatarUrl,
    role: user.role,
  }
}

DTO 约定建议

  • 页面组件消费 DTO,而不是数据库实体;
  • DTO 字段只保留页面需要的数据;
  • 敏感字段默认不进入 DTO;
  • DTO 命名清晰,如:
    • UserListItemDto
    • UserProfileDto
    • InvoiceSummaryDto

这会显著改善可维护性与安全性。


数据获取与缓存

App Router 中的数据获取与缓存策略必须被视为核心架构设计。

首选在服务端获取数据

// features/reports/queries.ts
import 'server-only'

export async function getReports() {
  const response = await fetch('https://api.example.com/reports', {
    next: { tags: ['reports'], revalidate: 300 },
  })

  if (!response.ok) {
    throw new Error('Failed to load reports')
  }

  return response.json()
}
// app/(app)/reports/page.tsx
import { getReports } from '@/features/reports/queries'

export default async function ReportsPage() {
  const reports = await getReports()

  return (
    <main>
      <h1>Reports</h1>
      <ul>
        {reports.map((report: { id: string; title: string }) => (
          <li key={report.id}>{report.title}</li>
        ))}
      </ul>
    </main>
  )
}

三种基础缓存语义

1. 强缓存:适合稳定内容

await fetch('https://api.example.com/navigation', {
  cache: 'force-cache',
})

适合:

  • 导航;
  • CMS 中变更不频繁的数据;
  • 公开静态内容;
  • 高复用配置。

2. 定时再验证:适合“可以短暂过期”的内容

await fetch('https://api.example.com/posts', {
  next: { revalidate: 300 },
})

适合:

  • 内容列表;
  • 公开页面;
  • 后台概览;
  • 对几分钟内一致性要求不极端的页面。

3. 不缓存:适合强实时数据

await fetch('https://api.example.com/account', {
  cache: 'no-store',
})

适合:

  • 用户私有数据;
  • 交易状态;
  • 结算结果;
  • 权限敏感内容;
  • 必须每次读取最新值的页面。

缓存策略建议

不要让页面作者到处随手写 cache。更推荐把缓存策略收口到查询函数:

// features/posts/queries.ts
import 'server-only'

export async function getPublishedPosts() {
  return fetch('https://api.example.com/posts', {
    next: { tags: ['posts'], revalidate: 600 },
  }).then((res) => {
    if (!res.ok) throw new Error('Failed to fetch posts')
    return res.json()
  })
}

这样页面只关心“我要什么数据”,而不是“缓存怎么写”。

缓存设计的常见原则

  • 公开内容优先缓存;
  • 用户私有数据优先 no-store
  • 高频更新的数据使用 tag/path 失效,不要盲目缩短 revalidate
  • 不要让同一类资源在不同地方使用不同缓存语义;
  • 明确哪些页面是“允许短暂陈旧”的。

revalidateTagupdateTagrevalidatePath

在 Next.js 16.2.3 中,缓存与失效策略应当是显式设计。对于可接受最终一致性的内容,优先使用 Cache Components、use cacherevalidateTag(..., 'max');对于用户刚写入后必须立即读到最新值的交互,再考虑 updateTag()

何时用 revalidateTag

当多个页面共享同一类数据源时,优先使用 tag。比如:

  • 博客文章列表;
  • 商品目录;
  • 报表摘要;
  • 多个页面共用的导航或分类数据。
// features/posts/actions.ts
'use server'

import { revalidateTag } from 'next/cache'

export async function createPost(formData: FormData) {
  const title = formData.get('title')

  if (typeof title !== 'string' || !title.trim()) {
    throw new Error('Invalid title')
  }

  // 写入数据库
  // await db.post.create(...)

  revalidateTag('posts')
}

对应读取:

await fetch('https://api.example.com/posts', {
  next: { tags: ['posts'], revalidate: 600 },
})

何时用 revalidatePath

当某个页面或某个路径层次需要立即刷新时,使用 path 更直接。例如:

  • 用户修改自己的资料页;
  • 单条详情页更新;
  • 提交表单后要刷新当前页面;
  • 某个 dashboard 路径下的数据已失效。
'use server'

import { revalidatePath } from 'next/cache'

export async function updateProfile(formData: FormData) {
  const userId = formData.get('userId')

  // await update logic...

  revalidatePath('/settings/profile')
  if (typeof userId === 'string') {
    revalidatePath(`/users/${userId}`)
  }
}

实践建议

  • “数据类别共享”用 revalidateTag
  • “特定页面刷新”用 revalidatePath
  • 可以组合使用,但不要无差别全量失效;
  • 失效策略必须与写操作绑定,不能靠页面层猜测;
  • 在业务模块里定义 cache tag 常量,避免字符串散落。
export const CACHE_TAGS = {
  posts: 'posts',
  reports: 'reports',
  users: 'users',
} as const

Promise.all 和 Suspense 避免瀑布请求

什么是瀑布请求

瀑布请求的典型特征是:一个请求完成后才开始下一个请求,导致总耗时被串联放大。

错误示例:

export default async function DashboardPage() {
  const account = await getAccount()
  const invoices = await getInvoices()
  const usage = await getUsage()

  return <Dashboard account={account} invoices={invoices} usage={usage} />
}

如果三者互不依赖,这种写法会平白增加延迟。

Promise.all 并行化

export default async function DashboardPage() {
  const [account, invoices, usage] = await Promise.all([
    getAccount(),
    getInvoices(),
    getUsage(),
  ])

  return <Dashboard account={account} invoices={invoices} usage={usage} />
}

用 Suspense 做分段流式渲染

对于耗时差异明显的区域,不要为了“整页一起展示”而阻塞首屏。可以拆成独立的异步服务端组件,再配合 Suspense:

// app/(app)/dashboard/page.tsx
import { Suspense } from 'react'
import { RevenueCard } from '@/features/dashboard/components/revenue-card'
import { ActivityFeed } from '@/features/dashboard/components/activity-feed'
import { RevenueCardSkeleton, ActivityFeedSkeleton } from '@/features/dashboard/components/skeletons'

export default function DashboardPage() {
  return (
    <main>
      <h1>Dashboard</h1>

      <Suspense fallback={<RevenueCardSkeleton />}>
        <RevenueCard />
      </Suspense>

      <Suspense fallback={<ActivityFeedSkeleton />}>
        <ActivityFeed />
      </Suspense>
    </main>
  )
}
// features/dashboard/components/revenue-card.tsx
import { getRevenue } from '@/features/dashboard/queries'

export async function RevenueCard() {
  const revenue = await getRevenue()
  return <section>Revenue: {revenue.total}</section>
}

实践建议

  • 同层独立数据优先并行获取;
  • 对慢区块使用 Suspense,不要让整页等待;
  • Suspense fallback 要贴近真实布局,避免 CLS;
  • 不要把所有内容都包在一个大 Suspense 里;
  • 只有存在真实依赖时才串行 await。

Route Handlers 与 Server Actions

两者都能在服务端执行逻辑,但职责不同。

什么时候用 Route Handlers

Route Handlers 适合做“HTTP 接口”:

  • 对外 API;
  • Webhook;
  • 文件上传入口;
  • 第三方系统回调;
  • 需要明确处理 GET/POST/PUT/DELETE;
  • 需要被非 React 客户端调用。
// app/api/reports/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getReportsForApi } from '@/server/services/report-service'

export async function GET(request: NextRequest) {
  const reports = await getReportsForApi(request)
  return NextResponse.json({ data: reports })
}

什么时候用 Server Actions

Server Actions 适合做“由 React UI 直接触发的写操作”:

  • 表单提交;
  • 创建、更新、删除;
  • 页面内直接交互后的服务端变更;
  • 变更后需要立刻触发缓存失效或跳转。
// features/projects/actions.ts
'use server'

import { redirect } from 'next/navigation'
import { revalidateTag } from 'next/cache'

export async function createProject(formData: FormData) {
  const name = formData.get('name')

  if (typeof name !== 'string' || !name.trim()) {
    throw new Error('Project name is required')
  }

  // await db.project.create(...)

  revalidateTag('projects')
  redirect('/projects')
}

在组件中使用:

export function CreateProjectForm() {
  return (
    <form action={createProject}>
      <input name="name" placeholder="Project name" />
      <button type="submit">Create</button>
    </form>
  )
}

选择建议

使用 Route Handler 的情况

  • 需要标准 HTTP API;
  • 需要给移动端、第三方或外部系统调用;
  • 需要更明确的协议边界;
  • 需要处理复杂请求/响应语义。

使用 Server Action 的情况

  • 变更来自当前 React 页面;
  • 表单与 mutation 强耦合;
  • 希望更少样板代码;
  • 想直接配合 revalidateTagrevalidatePathredirect

注意事项

  • 不要把所有 mutation 都机械改成 Server Actions;
  • 不要把对外开放接口做成 Server Actions;
  • Server Actions 里必须做参数校验与授权;
  • Route Handlers 和 Server Actions 都应调用统一 service 层,而不是重复业务逻辑。

Metadata 与 SEO

App Router 的 Metadata API 应作为生产项目标准实践,而不是每页手写 <head>

基础 Metadata

// app/layout.tsx
import type { Metadata } from 'next'

export const metadata: Metadata = {
  metadataBase: new URL('https://example.com'),
  title: {
    default: 'Acme',
    template: '%s | Acme',
  },
  description: 'Production SaaS platform built with Next.js 16.2.3',
}

动态 Metadata

对于详情页,应根据服务端数据生成 metadata:

// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
import { getPostBySlug } from '@/features/posts/queries'

export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>
}): Promise<Metadata> {
  const { slug } = await params
  const post = await getPostBySlug(slug)

  if (!post) {
    return {
      title: 'Post Not Found',
    }
  }

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      type: 'article',
    },
  }
}

SEO 实践建议

  • 每个可索引页面都要有明确 title 和 description;
  • canonical、Open Graph、Twitter Card 统一生成;
  • sitemap、robots 走框架标准能力;
  • 结构化数据按业务场景注入;
  • 不要把 SEO 逻辑散落在客户端组件中;
  • 关键内容必须可服务端渲染,不要依赖客户端请求后再补。

性能优化

性能优化不是“上线前做一轮检查”,而是架构阶段就要决定。

1. 优先减少客户端 JavaScript

最有效的优化往往不是 memo,而是根本不把代码送到浏览器。

做法包括:

  • 默认服务端组件;
  • 缩小 'use client' 范围;
  • 避免把大型第三方库放进公共客户端层;
  • 仅在需要时动态导入重型交互模块。

2. 使用 next/image

import Image from 'next/image'

export function Avatar() {
  return (
    <Image
      src="/avatars/jane.png"
      alt="Jane"
      width={80}
      height={80}
      sizes="80px"
    />
  )
}

建议:

  • 永远提供 alt
  • 指定合理尺寸;
  • 对首屏关键图像才使用 priority
  • 响应式场景填写 sizes

3. 使用 next/font

import { Inter } from 'next/font/google'

const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
})

避免外链字体导致的阻塞与闪动问题。

4. 动态导入重型客户端模块

'use client'

import dynamic from 'next/dynamic'

const Chart = dynamic(() => import('./chart'), {
  loading: () => <p>Loading chart...</p>,
})

适合:

  • 图表;
  • 富文本编辑器;
  • 地图;
  • 大型可视化组件。

5. 减少重复数据请求

  • 将同一数据读取聚合在查询层;
  • 避免客户端 mount 后再次请求服务端已拿到的数据;
  • 理解 fetch 去重能力,但不要依赖偶然行为弥补架构问题。

6. 控制流式渲染体验

  • 使用合理的 loading skeleton;
  • 避免 fallback 布局与最终布局差异过大;
  • 保证首屏关键区域优先返回。

7. 关注真实指标

建议至少跟踪:

  • LCP
  • INP
  • CLS
  • TTFB
  • hydration cost
  • 服务端请求耗时
  • 缓存命中率

样式方案与组件分层

样式选择没有绝对标准,但需要和组件分层一起设计。

推荐的组件层次

1. UI 基础层

例如 Button、Input、Dialog、Badge。

要求:

  • 低业务耦合;
  • API 稳定;
  • 可组合;
  • 可访问性达标。

2. 共享组合层

例如 PageHeader、EmptyState、DataTableToolbar。

要求:

  • 可以跨业务复用;
  • 不直接依赖具体数据库模型;
  • 接受 DTO 或显式 props。

3. 业务特性层

例如 BillingSummaryCard、UserRoleEditor。

要求:

  • 贴近业务;
  • 可以依赖业务术语;
  • 不强求跨域复用。

样式方案建议

常见可选方案:

  • Tailwind CSS:适合设计系统与高效率协作;
  • CSS Modules:适合局部样式隔离;
  • CSS Variables:适合主题体系;
  • 少量 clsx / cva:帮助管理变体。

无论使用哪种方案,建议统一:

  • 设计 token;
  • 间距、颜色、圆角、阴影层级;
  • 响应式断点;
  • 组件状态命名;
  • 暗色模式策略。

避免的问题

  • 页面内堆叠超长 class 串且无抽象;
  • 业务组件直接复制 UI 组件样式;
  • 同一按钮在不同页面有不同交互语义;
  • 在客户端组件里塞过多展示逻辑。

安全与授权

安全在 Next.js 中最大的风险点,是团队误以为“React 项目主要是前端,所以安全边界在接口层之外”。实际上,App Router 让大量逻辑直接回到服务端,安全要求更高。

基本原则

  • 所有输入都要校验;
  • 所有写操作都要授权;
  • 所有敏感读取都要做权限判断;
  • 不向客户端返回不必要字段;
  • 密钥只存在服务端;
  • 不信任来自客户端的 role、userId、tenantId。

参数校验

无论 Route Handler 还是 Server Action,都要做 schema 校验。示例:

// features/users/schema.ts
import { z } from 'zod'

export const updateUserSchema = z.object({
  name: z.string().min(1).max(80),
  role: z.enum(['admin', 'member']),
})
'use server'

import { updateUserSchema } from './schema'

export async function updateUserAction(formData: FormData) {
  const parsed = updateUserSchema.safeParse({
    name: formData.get('name'),
    role: formData.get('role'),
  })

  if (!parsed.success) {
    throw new Error('Invalid input')
  }

  // 授权 + 更新
}

授权必须在服务端

import 'server-only'

export async function deleteProject(projectId: string, currentUserId: string) {
  const membership = await getProjectMembership(projectId, currentUserId)

  if (!membership || membership.role !== 'admin') {
    throw new Error('Forbidden')
  }

  // 执行删除
}

敏感信息最小化

不要把以下字段下发到客户端,除非绝对必要:

  • 密码 hash;
  • access token / refresh token;
  • 内部审计字段;
  • 供应商返回的原始私密 payload;
  • 内部风控标签。

CSRF、XSS 与输出安全

  • 使用框架默认转义能力,不要危险拼接 HTML;
  • 对富文本内容做严格清洗;
  • 表单写操作结合站点认证与 same-site cookie 策略;
  • 谨慎使用 dangerouslySetInnerHTML
  • 对上传内容做类型与大小限制。

Middleware 边界

在 Next.js 16.2.3 中,proxy.ts 是更推荐的网络边界入口;middleware.ts 仍可见于旧代码和部分 Edge 场景,但应视为过渡方案。

Middleware 适合做什么

  • 轻量认证判断;
  • 重定向;
  • locale 处理;
  • A/B 实验分流;
  • 基础请求预处理;
  • URL 规范化。

Middleware 不适合做什么

  • 重型数据库查询;
  • 复杂业务授权;
  • 长耗时逻辑;
  • 大量第三方 API 调用;
  • 需要完整业务上下文的判断。

原则

Middleware 应保持“轻、快、可预测”。它是请求入口层,不是业务编排层。

// proxy.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function proxy(request: NextRequest) {
  const session = request.cookies.get('session')

  if (!session && request.nextUrl.pathname.startsWith('/app')) {
    return NextResponse.redirect(new URL('/login', request.url))
  }

  return NextResponse.next()
}

export const config = {
  matcher: ['/app/:path*'],
}

建议

  • 只做粗粒度 gate;
  • 真正授权仍在服务端页面、Route Handler、Action、service 层;
  • 明确 middleware 的 matcher,避免全站无差别执行;
  • 注意 Edge 运行时限制。

错误、加载与 not-found

App Router 提供的错误与状态文件不是“补充功能”,而是完整用户体验的一部分。

loading.tsx

适用于路由级 loading UI。它应尽量:

  • 结构贴近真实页面;
  • 体现关键骨架;
  • 避免仅显示一个“Loading…”文本。

error.tsx

适合作为分段错误边界,防止某块失败拖垮整页。

'use client'

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <main>
      <h2>页面加载失败</h2>
      <p>{error.message}</p>
      <button onClick={() => reset()}>重试</button>
    </main>
  )
}

not-found.tsx

用于资源不存在时提供明确反馈,而不是返回空白 UI。

实践建议

  • notFound() 用于真正不存在,不要滥用于权限失败;
  • 权限失败更适合返回 403 语义或重定向;
  • 业务错误、系统错误、空状态要区分;
  • 关键异步区块可以局部错误隔离,而不是全页失败;
  • error 页面要具备最小可观测信息,如 digest 或 trace id 映射能力。

可观测性

很多 Next.js 项目在开发时体验很好,但上线后出了问题只能靠猜。生产系统必须建立最小可观测性基线。

至少要有的观测能力

  • 服务端错误日志;
  • Route Handler 请求日志;
  • Server Action 错误记录;
  • 外部 API 调用耗时;
  • 数据库慢查询;
  • Web Vitals;
  • 部署版本标识;
  • trace / request ID。

日志原则

  • 结构化日志优先;
  • 避免打印敏感数据;
  • 每个关键请求有 request id;
  • 错误日志中包含:
    • 场景;
    • 用户上下文(脱敏后);
    • 资源标识;
    • 失败阶段;
    • digest/trace。

Web Vitals

对用户感知最强的前端质量指标,至少应接入:

  • LCP
  • INP
  • CLS

并把结果关联到版本号、路由和设备类型。

生产建议

  • 对关键 mutation 加审计日志;
  • 对缓存失效行为做埋点;
  • 区分“用户输入错误”和“系统故障”;
  • 把第三方依赖故障识别出来,不要都记成通用 500。

测试与工程质量

测试分层建议

1. 单元测试

测试:

  • 纯函数;
  • schema;
  • DTO 映射;
  • service/policy;
  • 权限判断逻辑。

2. 集成测试

测试:

  • Route Handlers;
  • Server Actions;
  • 数据访问与缓存失效;
  • 鉴权流程。

3. 端到端测试

测试:

  • 登录;
  • 关键业务流程;
  • 主要提交路径;
  • 错误和空状态;
  • SEO 关键页面可访问性。

工程质量基线

建议在 CI 中至少强制:

  • TypeScript 类型检查;
  • ESLint;
  • 单元测试;
  • 构建检查;
  • 核心 E2E smoke test。

代码评审关注点

评审 Next.js 代码时,重点看这些问题:

  • 是否无必要地使用 'use client'
  • 是否把数据库模型直接传给组件;
  • 是否在页面层散落缓存策略;
  • 是否在 Server Action 中遗漏授权;
  • 是否出现串行请求导致瀑布;
  • 是否把 middleware 当业务层使用;
  • 是否误把敏感字段透出到客户端。

部署

优先选择与运行时模型匹配的平台

Vercel 对 Next.js 的支持最完整,但并非唯一选择。无论在哪部署,都要先确认这些运行时问题:

  • Node runtime 还是 Edge runtime;
  • 图片优化是否可用;
  • 缓存与 revalidation 行为是否符合预期;
  • Server Actions 是否受支持;
  • 文件系统是否只读;
  • 冷启动与区域部署策略。

构建与发布建议

  • 每次发布都带版本号或 commit SHA;
  • 构建产物不可变;
  • 环境变量分环境管理;
  • 生产、预发、开发环境配置隔离;
  • 发布后自动做基础健康检查。

Docker 示例

FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile

FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN corepack enable && pnpm build

FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]

发布检查项

  • 是否验证关键缓存命中/失效路径;
  • 是否验证回滚方案;
  • 是否确认数据库迁移先后顺序;
  • 是否确认监控、报警、日志已指向新版本;
  • 是否确认 sitemap、robots、metadata 无异常。

常见反模式

下面这些问题在 Next.js 团队里非常常见,而且往往不是“代码写错”,而是架构理解偏差。

1. 整个页面默认 'use client'

后果:

  • 包体积增大;
  • 首屏性能下降;
  • 失去服务端能力;
  • 数据请求回到浏览器侧。

2. 把 ORM 实体直接传给客户端

后果:

  • 字段泄漏;
  • UI 与数据库强耦合;
  • 敏感信息暴露风险变高。

3. 在任意地方随手写缓存策略

后果:

  • 同类资源行为不一致;
  • 调试极难;
  • 失效逻辑混乱。

4. 用 middleware 做复杂授权

后果:

  • 性能差;
  • 逻辑难测;
  • 与真正服务端边界重复甚至冲突。

5. 所有 mutation 都做成 API,再从客户端 fetch

后果:

  • 样板代码过多;
  • 页面刷新与失效策略分散;
  • 在纯站内表单场景中不必要地复杂。

6. 所有 mutation 都做成 Server Actions

后果:

  • 外部调用困难;
  • 接口边界不清;
  • 与第三方集成不友好。

7. 顺序 await 一串互不依赖的请求

后果:

  • TTFB 增大;
  • 页面被不必要阻塞。

8. 只做认证,不做授权

后果:

  • 用户已登录,但仍可能执行不该执行的操作;
  • 权限漏洞隐蔽且严重。

9. 错误状态只有一句 “Something went wrong”

后果:

  • 用户无法恢复;
  • 排障缺乏上下文;
  • 体验和运维都很差。

团队检查清单

下面这份清单很适合放进 PR 模板或架构评审模板。

路由与组件

  • 是否默认采用服务端组件?
  • 是否仅在确有需要时使用 'use client'
  • 客户端边界是否足够小?
  • 页面是否避免直接依赖数据库实体?

数据与缓存

  • 查询函数是否集中定义?
  • 缓存策略是否在模块层统一?
  • 是否明确使用 force-cacherevalidateno-store
  • 写操作后是否正确调用 revalidateTagrevalidatePath

性能

  • 是否避免了可并行请求的瀑布?
  • 是否用 Suspense 拆分慢区块?
  • 是否减少了客户端 JavaScript?
  • 图片、字体、重型模块是否做了正确优化?

安全

  • 所有输入是否校验?
  • 所有写操作是否授权?
  • 是否避免将敏感字段传给客户端?
  • middleware 是否只承担轻量边界职责?

质量

  • 是否有 loading/error/not-found 处理?
  • 是否具备最小日志与监控能力?
  • 是否有单元/集成/E2E 覆盖关键流程?
  • 是否通过类型检查、Lint、构建和核心测试?

发布

  • 是否验证环境变量与运行时兼容性?
  • 是否验证缓存与 revalidation 行为?
  • 是否具备回滚方案?
  • 是否可在上线后定位新版本问题?

相关资源


结语

真正高质量的 Next.js 15 项目,不是“把 App Router 用上了”就结束,而是能在默认服务端、缓存策略、权限边界、性能优化和团队协作之间建立一套稳定、可执行的工程约定。

如果你的团队只记住一句话,我建议是:

默认 Server First,显式定义边界,把缓存、安全和可观测性当成一等公民。