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。
只有在确实需要以下能力时,才引入客户端组件:
- 浏览器事件处理;
- 本地交互状态;
useEffect、useState、useReducer;- 浏览器 API,如
localStorage、IntersectionObserver; - 只能在浏览器运行的第三方 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.tsxlayout.tsxloading.tsxerror.tsxnot-found.tsxroute.tstemplate.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 命名清晰,如:
UserListItemDtoUserProfileDtoInvoiceSummaryDto
这会显著改善可维护性与安全性。
数据获取与缓存
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; - 不要让同一类资源在不同地方使用不同缓存语义;
- 明确哪些页面是“允许短暂陈旧”的。
revalidateTag、updateTag 与 revalidatePath
在 Next.js 16.2.3 中,缓存与失效策略应当是显式设计。对于可接受最终一致性的内容,优先使用 Cache Components、use cache、revalidateTag(..., '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 强耦合;
- 希望更少样板代码;
- 想直接配合
revalidateTag、revalidatePath、redirect。
注意事项
- 不要把所有 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-cache、revalidate或no-store? - 写操作后是否正确调用
revalidateTag或revalidatePath?
性能
- 是否避免了可并行请求的瀑布?
- 是否用 Suspense 拆分慢区块?
- 是否减少了客户端 JavaScript?
- 图片、字体、重型模块是否做了正确优化?
安全
- 所有输入是否校验?
- 所有写操作是否授权?
- 是否避免将敏感字段传给客户端?
- middleware 是否只承担轻量边界职责?
质量
- 是否有 loading/error/not-found 处理?
- 是否具备最小日志与监控能力?
- 是否有单元/集成/E2E 覆盖关键流程?
- 是否通过类型检查、Lint、构建和核心测试?
发布
- 是否验证环境变量与运行时兼容性?
- 是否验证缓存与 revalidation 行为?
- 是否具备回滚方案?
- 是否可在上线后定位新版本问题?
相关资源
- Next.js Documentation
- App Router Documentation
- Data Fetching, Caching, and Revalidating
- Server Actions
- Metadata API
- Next.js Learn
- Vercel Architecture Guides
结语
真正高质量的 Next.js 15 项目,不是“把 App Router 用上了”就结束,而是能在默认服务端、缓存策略、权限边界、性能优化和团队协作之间建立一套稳定、可执行的工程约定。
如果你的团队只记住一句话,我建议是:
默认 Server First,显式定义边界,把缓存、安全和可观测性当成一等公民。