Next.js App Router 边界设计:用 Server Actions 不出事的全栈开发
梳理 Next.js App Router 的服务端/客户端边界、Server Actions、Route Handler 与认证。用可复制的代码模板,防止密钥泄露到浏览器和滥用 use client。
“用 Next.js 帮我快速做个任务管理后台。”
我这么跟 Claude Code 说了一句,五分钟后就拿到一个能跑的界面。表单、列表都齐了。我当时挺兴奋,评审也没怎么细看,就直接合并了。
那天晚上我突然意识到不对劲。渲染列表的那个组件,文件头上写着 "use client",而它从一个读取 DATABASE_URL 的辅助模块里 import 了东西。换句话说,本该只待在服务器上的连接字符串,被打进了发往浏览器的 bundle 里——一条泄露路径就这么悄悄成型了。
我后背一凉。
一个挺聪明的 AI,为什么会犯这种低级错误?原因很简单:App Router 允许你在不分清「哪段代码在哪里运行」的情况下就把功能写出来。只要你不先把服务器和浏览器之间的边界划好,不管是人还是 AI,都会掉进同一个坑。今天我就把这条边界先定下来,整理一套能安全交给 Claude Code 的模板,颗粒度细到可以直接复制。
题材就用「已登录用户创建任务的小型后台」。
本文要点
- App Router 只要把代码分成 Server Component(服务端渲染)、Client Component(浏览器运行)、Server Action(界面内的变更)、Route Handler(对外 API)这四种角色,生成结果就会一下子变得好评审。
- 最危险的事故是密钥泄露到浏览器。坚持用
server-only,并牢记「只有带NEXT_PUBLIC_前缀的环境变量才是给浏览器的」,就能防住。 - 认证靠 UI 隐藏是守不住的。Server Action 和 Route Handler 都要在函数内部每次校验登录和权限(因为 Server Function 直接 POST 也能调用)。
- 给 Claude Code 先递上边界表和文件结构。指令含糊,表单处理和 API 处理就会混在一起,返工变多。
- 渲染方式(SSR/SSG/ISR)怎么选放在另一篇讲。本文只聚焦「服务器与浏览器的职责拆分」。
本文以 2026 年 6 月时点的 Next.js App Router(16 系)为前提。官方一手资料:整体看 Next.js App Router docs,边界看 Server and Client Components,数据变更看 Mutating Data。渲染方式本身怎么选,我在CSR/SSR/SSG/ISR 的区别与选型里单独写了,所以这篇就专心讲职责拆分。
该先划的是「位置」线,不是「功能」线
刚上手 App Router 的人卡住的地方,往往不是 Server Action、Route Handler 这些名字本身。真正的障碍是脑子里没有分清「这段代码到底跑在服务器,还是跑在浏览器?」
只要把这一点理顺,剩下的放哪儿就自然有答案了。下面这张表,是我每次都贴给 Claude Code 的:
| 区域 | 使用场景 | 可以放什么 | 给 Claude Code 的提醒 |
|---|---|---|---|
| Server Component | 初始显示、读数据库、需要 SEO 的页面 | DB 访问、认证校验、调用非公开 API | 默认让它放在这里 |
| Client Component | 输入表单、弹窗、标签页、乐观 UI | useState、useActionState、点击处理 | 不要放密钥或 DB 客户端 |
| Server Action | 表单提交、创建、更新、删除 | 验证、权限校验、revalidate | 不要拿来当公开 API |
| Route Handler | 外部对接、Webhook、移动端 API | JSON 响应、状态码、签名校验 | 必须写输入校验和认证 |
把术语粗略翻译一下:Server Component 是在服务器上画好再发出去的部件,Client Component 是在浏览器里运行的部件。Server Action 是从表单或按钮触发的服务器处理,Route Handler 是从外部用 HTTP 来敲的窗口。还会冒出 BFF 这个词,你就理解成「专门为某个界面做的一层薄薄的后端 API」就行。
先把这张表递过去,Claude Code 的生成会稳定很多。尤其「不要在 Client Component 里放 process.env、DB、认证密钥」这句话,千万别漏。开头那次事故,正是因为我忘了说这句。
flowchart TD
Browser[浏览器表单] --> Client[Client Component]
Client --> Action[Server Action]
External[外部服务] --> Route[Route Handler]
Page[Server Component] --> Auth[认证边界]
Action --> Auth
Route --> Auth
Auth --> Data[DB 或服务器专用逻辑]
Data --> Page
先把文件位置指定好
App Router 里文件名直接就是 URL。所以位置一含糊,处理逻辑就会散落在 components 和 app 之间,过几天就开始「咦,这个更新我到底写哪儿了」。给 Claude Code 也是,先把这个形状递过去。
src/
app/
dashboard/
tasks/
page.tsx # 列表(Server Component)
new/
page.tsx # 创建页
actions.ts # Server Action
api/
tasks/
route.ts # 对外 API(Route Handler)
components/
task-create-form.tsx # 输入 UI(Client Component)
lib/
auth.ts # 认证(服务器专用)
env.ts # 环境变量校验(服务器专用)
tasks.ts # 数据操作(服务器专用)
看着小,但这个模板在 SaaS 设置页、公司内部审批工具、博客 CMS、客户管理面板上都能照搬。说白了就是分成「页面」「页面内的变更」「对外 API」「服务器专用逻辑」这四块而已。
把服务器专用逻辑物理隔离
接下来是数据操作的本体。因为是演示,我用了内存数组(保存在内存里)来存,生产环境请换成 Prisma、Drizzle、Supabase 等等。换了之后,边界的思路也不变。
这里最重要的是第一行 import "server-only";。只要加上它,万一被 Client Component 不小心 import 了,构建就会失败并提醒你。开头我那次事故,如果当时写了这一行,在上线前就会被拦下。
// src/lib/tasks.ts
import "server-only";
export type TaskPriority = "low" | "normal" | "high";
export type Task = {
id: string;
ownerId: string;
title: string;
priority: TaskPriority;
dueDate: string | null;
createdAt: string;
};
const tasks: Task[] = [];
export async function listTasks(options: {
ownerId: string;
priority?: TaskPriority;
}) {
return tasks
.filter((task) => task.ownerId === options.ownerId)
.filter((task) => !options.priority || task.priority === options.priority)
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
export async function createTask(input: {
ownerId: string;
title: string;
priority: TaskPriority;
dueDate?: string | null;
}) {
const task: Task = {
id: crypto.randomUUID(),
ownerId: input.ownerId,
title: input.title,
priority: input.priority,
dueDate: input.dueDate ?? null,
createdAt: new Date().toISOString(),
};
tasks.push(task);
return task;
}
认证同样关进 server-only 这一侧。下面是用来跑通流程的简化版:只要浏览器 Cookie 里有 demo_user_id,就视为已登录。生产环境请换成 Auth.js、Clerk 或自家认证。认证本身怎么设计(用 Session 还是 JWT、密码怎么存),我拆到用 Session、bcrypt、Cookie 把 Web 认证做扎实那篇里写了。
// src/lib/auth.ts
import "server-only";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
export type CurrentUser = {
id: string;
name: string;
role: "member" | "admin";
};
export async function getCurrentUser(): Promise<CurrentUser | null> {
const cookieStore = await cookies();
const userId = cookieStore.get("demo_user_id")?.value;
if (!userId) {
return null;
}
return {
id: userId,
name: "Demo User",
role: "member",
};
}
export async function requireUser() {
const user = await getCurrentUser();
if (!user) {
redirect("/login");
}
return user;
}
export async function requireApiUser() {
return getCurrentUser();
}
环境变量也别在页面各处直接读 process.env,改成在一个地方集中校验。「不要从 Client Component import DATABASE_URL 或 APP_SECRET」这条原则,就在这里守住。
// src/lib/env.ts
import "server-only";
import { z } from "zod";
const EnvSchema = z.object({
DATABASE_URL: z.string().url(),
APP_SECRET: z.string().min(32),
});
export const env = EnvSchema.parse(process.env);
用 Server Component 做初始显示
任务列表用 Server Component。读数据库、认证、初始显示先在服务器上搞定,既简单又快。给 Claude Code 加一句「这个文件不要加 use client」,就能防止它做不必要的客户端化。
// src/app/dashboard/tasks/page.tsx
import Link from "next/link";
import { requireUser } from "@/lib/auth";
import { listTasks } from "@/lib/tasks";
export default async function TasksPage() {
const user = await requireUser();
const tasks = await listTasks({ ownerId: user.id });
return (
<main className="mx-auto max-w-3xl space-y-6 p-6">
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold">任务管理</h1>
<p className="text-sm text-gray-600">{user.name} 的任务列表</p>
</div>
<Link className="rounded bg-black px-4 py-2 text-white" href="/dashboard/tasks/new">
新建
</Link>
</div>
<ul className="divide-y rounded border">
{tasks.map((task) => (
<li className="flex items-center justify-between p-4" key={task.id}>
<div>
<p className="font-medium">{task.title}</p>
<p className="text-sm text-gray-500">
优先级:{task.priority} / 截止:{task.dueDate ?? "未设置"}
</p>
</div>
</li>
))}
</ul>
</main>
);
}
注意这里直接用 async function 并 await listTasks(...)。正因为是 Server Component,才能这样在靠近数据库的地方取数据、然后直接画出来。不需要用 useEffect 去取。
用 Server Action 把变更处理收到一处
这里是全栈的核心。表单发起的变更统统收进 Server Action。顺序很关键,认证 → 输入校验 → 保存 → revalidate,要在同一个函数里从上往下依次做。
Zod 是用来检查「输入数据的形状是否符合预期」的库。给 Claude Code 指定「这里一定要 safeParse」,没校验就直接流到保存的危险写法就会减少。
正如官方文档反复强调的那样,Server Action(Server Function)即使不经过应用的 UI,也能被直接 POST 调用。所以「只有能点到按钮的人才会调用」这个前提是站不住脚的。在函数入口必须先过一遍 requireUser(),就是为了这个。
// src/app/dashboard/tasks/new/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { requireUser } from "@/lib/auth";
import { createTask } from "@/lib/tasks";
const CreateTaskSchema = z.object({
title: z.string().trim().min(1, "标题为必填项").max(80),
priority: z.enum(["low", "normal", "high"]),
dueDate: z
.string()
.trim()
.optional()
.transform((value) => (value ? value : null)),
});
export type TaskFormState = {
ok: boolean;
message?: string;
fieldErrors?: Record<string, string[]>;
};
export async function createTaskAction(
previousState: TaskFormState,
formData: FormData
): Promise<TaskFormState> {
// 1. 先认证。光靠 UI 隐藏守不住
const user = await requireUser();
// 2. 输入校验。不要信任 request 里的内容
const parsed = CreateTaskSchema.safeParse({
title: formData.get("title"),
priority: formData.get("priority"),
dueDate: formData.get("dueDate"),
});
if (!parsed.success) {
return {
ok: false,
fieldErrors: parsed.error.flatten().fieldErrors,
message: "请检查填写的内容。",
};
}
// 3. 保存
await createTask({
ownerId: user.id,
...parsed.data,
});
// 4. 刷新列表缓存,让最新数据显示出来
revalidatePath("/dashboard/tasks");
return {
ok: true,
message: "任务已创建。",
};
}
Client Component 这边,只放浏览器里真正需要的状态。这里不能 import DB、密钥、服务器专用的 env。表单提交中的状态显示,用 useActionState 和 useFormStatus。
// src/components/task-create-form.tsx
"use client";
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
import {
createTaskAction,
type TaskFormState,
} from "@/app/dashboard/tasks/new/actions";
const initialState: TaskFormState = {
ok: false,
};
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
className="rounded bg-black px-4 py-2 text-white disabled:opacity-50"
disabled={pending}
type="submit"
>
{pending ? "创建中..." : "创建"}
</button>
);
}
export function TaskCreateForm() {
const [state, formAction] = useActionState(createTaskAction, initialState);
return (
<form action={formAction} className="space-y-4 rounded border p-4">
<div>
<label className="block text-sm font-medium" htmlFor="title">
标题
</label>
<input
className="mt-1 w-full rounded border px-3 py-2"
id="title"
name="title"
type="text"
/>
{state.fieldErrors?.title?.map((error) => (
<p className="mt-1 text-sm text-red-600" key={error}>
{error}
</p>
))}
</div>
<div>
<label className="block text-sm font-medium" htmlFor="priority">
优先级
</label>
<select className="mt-1 w-full rounded border px-3 py-2" id="priority" name="priority">
<option value="normal">普通</option>
<option value="high">高</option>
<option value="low">低</option>
</select>
</div>
<div>
<label className="block text-sm font-medium" htmlFor="dueDate">
截止日期
</label>
<input className="mt-1 w-full rounded border px-3 py-2" id="dueDate" name="dueDate" type="date" />
</div>
{state.message ? <p className="text-sm text-gray-700">{state.message}</p> : null}
<SubmitButton />
</form>
);
}
创建页本身,就是一个把这个表单放进去的 Server Component。
// src/app/dashboard/tasks/new/page.tsx
import { TaskCreateForm } from "@/components/task-create-form";
export default function NewTaskPage() {
return (
<main className="mx-auto max-w-xl p-6">
<h1 className="mb-4 text-2xl font-bold">创建任务</h1>
<TaskCreateForm />
</main>
);
}
到这里,从表单提交、写入数据库、到列表自动刷新,就走完了一圈。<form action={formAction}> 这一行,把浏览器的表单和服务器的处理连了起来,这是关键。
对外 API 拆到 Route Handler
「那变更是不是全用 Server Action 就好?」——并不是。被移动端 App、第三方服务、Webhook 调用的 API,要用 Route Handler。Server Action 是为界面内部的变更优化的,拿它当对外公开 API 用,状态码和 JSON 形式的错误就容易变得含糊。
在 Route Handler 里,输入校验和认证要自己显式地写。下面的代码:未登录返回 401,优先级不对返回 400,成功返回 201。
// src/app/api/tasks/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { requireApiUser } from "@/lib/auth";
import { createTask, listTasks } from "@/lib/tasks";
export const runtime = "nodejs";
const PrioritySchema = z.enum(["low", "normal", "high"]);
const CreateTaskApiSchema = z.object({
title: z.string().trim().min(1).max(80),
priority: PrioritySchema.default("normal"),
dueDate: z.string().date().nullable().optional(),
});
export async function GET(request: NextRequest) {
const user = await requireApiUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const priority = request.nextUrl.searchParams.get("priority");
const parsedPriority = priority ? PrioritySchema.safeParse(priority) : null;
if (parsedPriority && !parsedPriority.success) {
return NextResponse.json({ error: "Invalid priority" }, { status: 400 });
}
const tasks = await listTasks({
ownerId: user.id,
priority: parsedPriority?.data,
});
return NextResponse.json({ data: tasks });
}
export async function POST(request: NextRequest) {
const user = await requireApiUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json().catch(() => null);
const parsed = CreateTaskApiSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid request body", details: parsed.error.flatten().fieldErrors },
{ status: 400 }
);
}
const task = await createTask({
ownerId: user.id,
...parsed.data,
});
return NextResponse.json({ data: task }, { status: 201 });
}
注意 request.json() 用 .catch(() => null) 兜住之后,再过 safeParse。这样即使收到坏掉的 JSON 或空 body,也不会崩,而是返回 400。想深挖 REST API 本身怎么搭(路由、统一错误、分页),可以再看用 Node 实现 REST API。
实战中真正好用的四种拆分
这套四分法在哪些场景能发挥作用,我具体说几个。
第一个是公司内部审批面板。审批列表用 Server Component,审批创建用 Server Action,发往 Slack、企业微信等的通知用 Route Handler。这样拆,页面和外部对接的职责就不会混。
第二个是SaaS 设置页。账单设置、邀请成员、签发 API key 这类操作,Client Component 里只放输入 UI,真正的变更放到 Server Action 里、校验权限之后再执行。诀窍是别把「能点到按钮 = 有权限」当成默认。
第三个是博客 CMS 或商品管理。初始列表用 Server Component 快速画出来,图片上传、公开 Webhook 收到 Route Handler。如果还牵扯到给数据库加列、删列,先读一下防止数据库迁移运维事故里的分阶段应用会更稳。
第四个是BFF 结构。前端不要直接去敲多个外部 API,统一汇总到 Next.js 这边的 Route Handler。照搬官方 Backend for Frontend guide 的思路,就能不把密钥暴露到浏览器。
我自己踩过的三个坑
老实说,最初几个项目,我基本都掉进了同一些坑。
第一个就是开头那个密钥泄露。我图初始显示方便,把逻辑往 Client Component 里堆,结果连下游那个读 DATABASE_URL 的辅助模块,也一起被拽到浏览器侧了。改法很简单:在服务器专用文件头上加一行 import "server-only";。之后再想犯这种错,构建会直接停下来。
第二个是明明是初始显示,却全用 useEffect 去拿 API。只跟 Claude Code 说「做个能用的列表」,它有时会加上 use client、在挂载后才去取。能跑是能跑,但首屏慢、对 SEO 也弱。我一开始就指定「列表的初始显示用 Server Component 去 await 取」,它就老老实实改对了。
第三个是以为靠 UI 就把认证守住了。我本以为「删除按钮不给管理员以外的人显示,就不会被删」,结果官方白纸黑字写着 Server Action 能被直接 POST 调用。也就是说,UI 藏了没用,只要函数内部没做 requireUser() 和所有者 ID 的校验,照样能溜进来。想想都后怕。
递给 Claude Code 的评审提示词
实现完之后,别急着让它改,先让它只给出指摘。这一招最能减少事故。
You are reviewing a Next.js App Router full-stack change.
Scope:
- src/app/dashboard/tasks
- src/app/api/tasks/route.ts
- src/components/task-create-form.tsx
- src/lib/auth.ts
- src/lib/tasks.ts
- src/lib/env.ts
Check:
1. No secrets, DB clients, or server-only modules are imported by Client Components.
2. Server Components are not converted to Client Components without a real interaction need.
3. Server Actions validate input, check auth, mutate data, and revalidate the affected path.
4. Route Handlers return correct HTTP status codes and validate JSON bodies.
5. Auth is enforced on the server, not only hidden in the UI.
6. Tests or manual verification steps are listed for each risk.
Do not edit files yet. Return findings by severity with file paths and concrete fixes.
指摘出来后,一次只小改一处。验证至少要做到:npm run lint、npm run typecheck、手动提交一次表单、确认未登录时 Route Handler 返回 401。测试该从哪儿写起拿不定主意的话,可以参考用 Claude Code 定测试策略的优先级。
常见问题
问:Server Action 和 Route Handler,到底用哪个?
界面里的表单变更用 Server Action,从外部(移动端、第三方、Webhook)来敲就用 Route Handler。同样是保存处理,按入口是「自家界面」还是「外部」来分,就不会纠结。两边都要用的保存逻辑,在 lib 里放一份,让两者都来调。
问:use client 该加在哪?
只加在需要状态(useState)、点击等事件、localStorage 或 window 这类浏览器专用 API 的部件上。加了 use client 的文件所 import 的东西,会一并进到浏览器 bundle 里。所以诀窍不是「整页都加」,而是收窄到需要交互的小部件上。
问:怎么确认密钥没泄露到浏览器?
首先在服务器专用文件头上加 import "server-only";,这样一旦被 Client Component import,构建就会失败。另外记住:能发到浏览器的环境变量只有带 NEXT_PUBLIC_ 前缀的;没带前缀的变量在客户端会是空的。
问:保存了,列表却还是旧的。
检查 Server Action 里有没有调用 revalidatePath("/对应路径")。光保存不会刷新缓存。如果是按 tag 管理的,就用 revalidateTag。
问:一开始给 Claude Code 递什么才稳?
就递本文这张边界表、文件结构,外加「不要在 Client Component 里放密钥、DB、env」和「列表的初始显示用 Server Component 取」这两条禁令/原则。光是先把这些递过去,后续的返工就会明显变少。
实际试下来的结果
经历了开头那次「密钥差点泄露到浏览器」的事件之后,我改了做法。让 Claude Code 动手之前,必须先贴边界表和文件结构;服务器专用文件里,二话不说先放 server-only。就这么两件事。
结果,我提的修改请求,内容性质都变了。以前多是「DB 客户端跑到客户端去了」「初始显示全堆在 useEffect 里」这种结构性事故。把边界先用语言固定下来之后,返工大半变成了「按钮文案」「间距微调」这种 UI 上的小事。自从放了 server-only,只要一想写危险路径,构建当场就停,半夜里再没那种心头一紧的时刻了。
反过来,赶时间、第一条指令图省事写成「看着做个差不多的」那几回,果然表单处理和 API 处理就混在一起,评审返工的时间反而胀了起来。教训很清楚:做 Next.js 全栈开发,比起实现速度,先把「哪里是服务器、哪里是浏览器」用话说清楚,才是最快的近路。
如果想在团队里做成标准,ClaudeCodeLab 的 Claude Code 教材与模板集 里,备好了把这套边界规则和评审要点整理好的模板;要按自家仓库规约去调,也可以从这里入手。想把后台、CMS、SaaS 设置页和评审流程变成团队可复用的规范,欢迎看看团队导入咨询与培训。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
让 Claude Code 真正进入可验证的工作流
先用免费 PDF 固定基础,再用 Gumroad 教材复用工作流;如果涉及团队导入、权限或收入路径,可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。