Next.js App Router 是当下最主流的 React 全栈框架方案。它用 React Server Components(RSC)把组件默认放到服务端渲染,用 Server Actions 让函数直接跑在服务器上处理表单与数据写入,前后端第一次被统一进同一个工程。本文从目录约定、数据获取、Server Actions 到缓存与部署,手把手带你落地一个可上线的 Next.js 全栈应用。
一、为什么是 App Router:RSC 带来的范式变化
在 Pages Router 时代,一个页面就是一次客户端请求加一次数据预取,前端要写 getServerSideProps,再在组件里用 useEffect 拉数据,前后端边界很清晰但也很割裂。App Router 的核心改变有三点:默认服务端渲染、组件即路由、服务端可以直接写数据。
- 零客户端包体:RSC 在服务端执行,不会被打包进浏览器 JS,首屏体积显著下降。
- 流式渲染:Suspense 边界可以把慢数据拆成独立的加载片段,用户先看到骨架再看到内容。
- 渐进增强:Server Actions 让普通
<form>不依赖 JS 也能提交,SEO 与弱网都更稳。
二、项目初始化与目录约定
2.1 用 app 目录承载文件路由
App Router 用 app/ 目录下的文件路径直接映射 URL。新建项目后,目录结构大致如下:
# 初始化(Node 18.18+)
npx create-next-app@latest my-app --ts --app --tailwind --eslint
cd my-app
# 典型 app 目录结构
app/
layout.tsx # 根布局,包裹所有页面
page.tsx # 对应路由 /
loading.tsx # 路由级 Suspense 兜底 UI
error.tsx # 路由级错误边界
posts/
page.tsx # 对应路由 /posts
[id]/
page.tsx # 动态路由 /posts/123
2.2 layout 与模板:共享外壳
layout.tsx 在服务端渲染一次并缓存,适合放全局导航、字体与元数据;template.tsx 则会在每次导航重新挂载,适合需要动画或重置状态的场景。两者都接收 children 作为插槽。
// app/layout.tsx
export const metadata = {
title: "我的全栈应用",
description: "基于 Next.js App Router 构建",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-CN">
<body>{children}</body>
</html>
);
}
三、数据获取:在服务端直接读库
3.1 async Server Component 直连数据库
Server Component 可以是 async 函数,直接在组件里 await 数据库或接口,无需额外 API 层。下面以 PostgreSQL 为例:
// app/posts/page.tsx(服务端组件)
import { db } from "@/lib/db";
export default async function PostsPage() {
const posts = await db.query(
"SELECT id, title, created_at FROM posts ORDER BY created_at DESC LIMIT 20"
);
return (
<ul>
{posts.map((p) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
);
}
3.2 流式渲染与 loading.tsx
把某个区块用 Suspense 包起来,再配合 loading.tsx,Next.js 会先返回外壳 HTML,数据就绪后通过流式补充。对慢查询页面体验提升明显。
四、Server Actions:告别手写 API
4.1 定义与调用
在文件顶部加 "use server",函数就能被客户端直接调用,省去定义路由、写 fetch、处理 CORS 的全过程。
// app/actions.ts
"use server";
export async function createPost(formData: FormData) {
const title = formData.get("title");
if (!title) throw new Error("标题不能为空");
await db.query("INSERT INTO posts(title) VALUES($1)", [title]);
// 重新验证列表路由的缓存
revalidatePath("/posts");
}
4.2 表单与渐进增强
把 Action 直接传给 <form action={...}>,即使浏览器禁用 JS,表单也能正常提交,这就是渐进增强。配合 useFormStatus 还能拿到提交态。
// app/posts/new/page.tsx
import { createPost } from "@/app/actions";
export default function NewPost() {
return (
<form action={createPost}>
<input name="title" placeholder="文章标题" />
<button type="submit">发布</button>
</form>
);
}
4.3 错误处理:useFormState 与 error.tsx
用 useFormState 捕获 Action 返回值做字段级提示,配合路由级 error.tsx 兜底意外异常,整体容错就完整了。
五、缓存策略:四种粒度怎么选
Next.js 的缓存是新手最容易踩坑的地方。下表总结了常见缓存手段的适用场景,建议先理解再使用,避免”改了数据页面不更新”的尴尬。
| 手段 | 作用范围 | 何时失效 | 典型用途 |
|---|---|---|---|
fetch 的 cache 选项 | 单个请求 | 按 next: { revalidate } | 列表/详情数据 |
revalidatePath | 某个路由 | Action 内主动调用 | 写操作后刷新页面 |
revalidateTag | 带 tag 的请求 | 按 tag 失效 | 多页面共享的数据源 |
Segment 配置 dynamic | 整个路由段 | 每次请求/构建 | 强实时页面 |
六、与现有后端和基础设施协作
Next.js 不是要取代你已有的后端,而是补齐”前端 + BFF”这一层。真实项目里它通常这样落位:
- 用 Nginx 反向代理 把
/指向 Next.js 服务、/api指向原后端,平滑灰度。 - 把构建产物做成镜像,配合 Docker Compose 一键编排 拉起 Postgres 与 Redis 依赖。
- 接 Redis 缓存 做热点数据兜底,缓解 RSC 直连数据库的压力。
- 用 GitHub Actions 搭建 CI/CD 流水线 自动构建、跑测试、推镜像。
- 最终由 Kubernetes 的 Pod/Deployment/Service 托管多副本与滚动发布。
下面是一个最小可用的多阶段 Dockerfile,先把依赖装好再构建,最后用轻量运行镜像:
# 构建阶段
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# 运行阶段
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
EXPOSE 3000
CMD ["npm", "start"]
七、常见坑与排障清单
- 客户端用了服务端变量:含
process.env.SECRET的组件若被标记为"use client",密钥会泄露,敏感逻辑务必留在服务端。 - 页面不更新:多半是缓存没失效,检查
revalidatePath/revalidateTag是否覆盖到对应路由。 - hydration 不匹配:服务端和客户端渲染的日期、随机数不同会报错,用
suppressHydrationWarning仅作临时止血,根因要统一数据源。 - 包体回涨:在 Server Component 里误导入了仅客户端可用的库,会被打进客户端包,留意控制台警告。
八、服务端组件与客户端组件的边界怎么划
8.1 何时必须声明 “use client”
只有用到浏览器 API、事件监听、useState/useEffect 等交互能力的组件才需要加 "use client"。一个常见误区是把整个页面都标成客户端组件,结果 RSC 的服务端优势全部丢失。正确做法是把交互收口在最小的叶子组件里,父级仍然保持服务端组件,这样既拿到状态能力,又不污染整棵树的包体。
8.2 避免服务端依赖被打进客户端
在客户端组件里 import 了一个只在服务端存在的库(比如数据库驱动、文件系统模块),打包器会尝试把它拉进浏览器包,轻则体积暴涨,重则构建失败。把这类逻辑下沉到 Server Actions 或独立的服务端函数,客户端只调用结果,是更稳妥的拆法。
九、Server Actions 进阶:事务、鉴权与限流
真正的写操作不能只做插入,还要考虑原子性、权限和防刷。下面这段把鉴权、事务与限流串起来,更接近生产可用的写法:
// app/actions.ts
"use server";
import { getSession } from "@/lib/auth";
import { ratelimit } from "@/lib/ratelimit";
export async function createPost(formData: FormData) {
const user = await getSession();
if (!user) throw new Error("未登录");
const ok = await ratelimit.check(user.id, "create-post");
if (!ok) throw new Error("操作过于频繁,请稍后再试");
const title = String(formData.get("title") ?? "").trim();
if (!title) throw new Error("标题不能为空");
await db.transaction(async (tx) => {
await tx.query("INSERT INTO posts(title, author) VALUES($1,$2)", [title, user.id]);
});
revalidatePath("/posts");
}
把限流和鉴权放在 Action 入口,好处是所有调用方(页面表单、其他 Server Component)都自动受益,不必在每个入口重复写一遍。
十、App Router 与 Pages Router 该怎么选
老项目不必急着迁移,但新项目建议直接选 App Router。下表给出决策参考:
| 维度 | App Router | Pages Router |
|---|---|---|
| 默认渲染 | 服务端组件 | 客户端组件 |
| 数据写入 | Server Actions 内联 | 手写 API 路由 |
| 包体体积 | 更小(按需客户端化) | 偏大 |
| 学习成本 | 较高(心智模型新) | 较低(熟悉) |
| 适合场景 | 新项目 / 重 SEO / 全栈 | 存量项目渐进维护 |
十一、小结与迁移建议
App Router 的学习曲线集中在”服务端心智模型”的转变:组件默认在服务端、数据在那里取、写入用 Server Actions。新项目建议直接上 App Router;老项目不必推倒重来,可先在边缘路由(如管理后台、表单页)试点,再逐步把 getServerSideProps 改写成 RSC。当你把”读”交给 Server Component、”写”交给 Server Action、”缓存”交给 revalidate 体系,一个干净的全栈应用骨架就立起来了。




