Claude Code 与 Next.js 全栈开发:App Router 实战指南
用 Claude Code 构建 Next.js App Router 全栈功能,覆盖边界设计、Server Actions、API、认证与评审。
让 Claude Code 生成一个 Next.js 全栈功能很快,但真正决定质量的是边界。提示词如果只写“帮我做一个后台功能”,Claude Code 可能会把服务器专用逻辑放进浏览器组件,把页面全部改成 Client Component,或者把没有验证过的 JSON 直接写入数据库。
本文用一个“登录用户创建任务”的小型管理界面做例子,讲清楚 App Router 项目结构、Server Components 与 Client Components 的取舍、Route Handlers、Server Actions、输入验证、环境变量、认证边界,以及最后如何让 Claude Code 做架构评审。
建议同时打开官方资料:Next.js App Router docs、Server and Client Components、Route Handlers、Mutating Data、Backend for Frontend guide。Claude Code 的工作流可参考 Claude Code common workflows。
先确定运行边界
初学 App Router 时,最容易混淆的不是文件名,而是代码到底在哪里运行。Server Component 是在服务器端渲染的组件,Client Component 是在浏览器里运行的组件。Server Action 是通常由表单触发的服务器端变更函数。Route Handler 是 HTTP 入口,适合 JSON API、Webhook 和外部客户端。BFF 是 Backend for Frontend,意思是为某个前端界面定制的一层薄后端。
| 区域 | 适合场景 | 可以放什么 | 给 Claude Code 的要求 |
|---|---|---|---|
| Server Component | 初始页面、数据库读取、SEO 页面 | DB 访问、认证检查、私有 API 调用 | 默认保持服务端组件 |
| Client Component | 表单、弹窗、标签页、乐观 UI | useState、useActionState、事件处理 | 不要导入密钥、DB 客户端、server-only 模块 |
| Server Action | 从界面发起创建、更新、删除 | 验证、认证、写入、revalidate | 不要当作公开 API |
| Route Handler | 外部 API、Webhook、移动端客户端 | JSON、状态码、签名校验 | 必须验证输入并执行认证 |
把这张表先交给 Claude Code,生成结果会稳定很多。尤其要明确:Client Component 不能暴露秘密信息。只有带 NEXT_PUBLIC_ 前缀的环境变量才适合浏览器。数据库 URL、API key、认证 secret 都应该留在服务器专用文件里。
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 用文件系统表达路由,所以要先让 Claude Code 按固定结构创建文件,避免逻辑散落在各处。
src/
app/
dashboard/
tasks/
page.tsx
new/
page.tsx
actions.ts
api/
tasks/
route.ts
components/
task-create-form.tsx
lib/
auth.ts
env.ts
tasks.ts
这里的职责很清楚:page.tsx 负责初始显示,task-create-form.tsx 负责浏览器交互,actions.ts 负责界面触发的数据变更,route.ts 负责外部 HTTP API,lib 放服务器专用逻辑。
隔离服务器专用逻辑
下面是可直接复制到演示项目的内存存储。生产环境请换成 Prisma、Drizzle、Supabase 或你的数据库层。关键点是 server-only,它可以防止这个模块被 Client Component 误导入。
// 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;
}
认证边界也要放在服务器侧。下面的演示实现会把 demo_user_id Cookie 视为登录用户。真实产品中请替换为 Auth.js、Clerk、公司内部认证服务等。
// 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。
// 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。认证、数据库读取和初始 HTML 都在服务器完成,浏览器包更小,也不会出现不必要的客户端加载瀑布。
// 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">Tasks</h1>
<p className="text-sm text-gray-600">Work owned by {user.name}</p>
</div>
<Link className="rounded bg-black px-4 py-2 text-white" href="/dashboard/tasks/new">
New task
</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">
Priority: {task.priority} / Due: {task.dueDate ?? "none"}
</p>
</div>
</li>
))}
</ul>
</main>
);
}
提示 Claude Code 时可以写清楚:“除非有真实浏览器交互需求,否则不要给这个文件添加 use client。”
用 Server Action 处理变更
表单创建、更新、删除适合 Server Action。认证、验证、写入、重新验证路径应放在同一个函数中。Zod 是输入结构验证库,这里用 safeParse 避免脏数据进入数据层。
// 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, "Title is required").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> {
const user = await requireUser();
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: "Please check the form fields.",
};
}
await createTask({
ownerId: user.id,
...parsed.data,
});
revalidatePath("/dashboard/tasks");
return {
ok: true,
message: "Task created.",
};
}
Client Component 只负责浏览器状态和表单显示,不导入数据库、认证 secret 或环境变量模块。
// 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 ? "Creating..." : "Create"}
</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">
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">
Priority
</label>
<select className="mt-1 w-full rounded border px-3 py-2" id="priority" name="priority">
<option value="normal">Normal</option>
<option value="high">High</option>
<option value="low">Low</option>
</select>
</div>
<div>
<label className="block text-sm font-medium" htmlFor="dueDate">
Due date
</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>
);
}
// 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">Create a task</h1>
<TaskCreateForm />
</main>
);
}
用 Route Handler 暴露 API
外部服务、Webhook、移动端客户端和公开 JSON API 应使用 Route Handler。Server Action 适合界面内部变更,但不适合替代明确的 HTTP 契约。
// 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 });
}
真实使用场景
第一个场景是公司内部申请面板。列表用 Server Component,申请创建用 Server Action,Slack 或企业微信通知通过 Route Handler 发送,职责不会混在一起。
第二个场景是 SaaS 设置页。账单设置、成员邀请、API key 创建都需要权限检查。UI 可以很轻,真正的变更应该放在 Server Action。
第三个场景是博客 CMS 或商品管理。初始列表由服务器渲染,图片上传、Webhook、发布通知放到 Route Handler。如果还涉及数据库迁移,可以继续看数据库迁移自动化。
第四个场景是 BFF。前端不要直接调用多个第三方 API,而是通过 Next.js Route Handler 汇总。这样可以把 secret 留在服务器,也能给前端一个稳定的数据契约。
常见坑
最危险的是让 Claude Code 模糊边界。要特别检查:Client Component 是否导入了数据库 helper;初始数据是否被无意义地搬到 useEffect;Server Action 是否被当作公开 API;Route Handler 是否没有验证就保存 request.json()。
认证不能只靠 UI。隐藏按钮不是权限控制。Server Action 和 Route Handler 都要检查用户、资源所有者和角色。更多认证设计可参考认证功能实现指南。
还有一个坑是反复说“帮我修好”。App Router 的小改动也可能影响缓存、revalidate、表单状态和认证边界。每次都要给 Claude Code 明确的文件范围、禁止修改的内容和验证命令。
给 Claude Code 的评审提示词
实现之后,先让 Claude Code 做 review,不要马上让它改代码。
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.
之后一次只修一个问题。最低验证包括 lint、类型检查、相关单元测试、手动提交表单,以及未登录访问 API 返回 401。测试策略可以参考测试策略指南。
ClaudeCodeLab 的培训与模板
个人项目可以直接使用本文的结构和提示词。团队项目则应该把边界规则、review 清单、认证规则和环境变量策略整理成模板。
ClaudeCodeLab 提供Claude Code 教材与模板以及团队导入咨询与培训。如果你想把 Next.js 后台、CMS、SaaS 设置页和 review 流程变成团队可重复执行的规范,可以从这里开始。
总结
Claude Code 可以显著加速 Next.js 全栈开发,但前提是先定义服务器与浏览器的边界。Server Component 负责初始渲染,Client Component 负责交互,Server Action 负责界面内的数据变更,Route Handler 负责外部 API。
实际试用本文的方法后,最明显的收益不是生成了更多代码,而是 review 噪音变少了。先把边界表和文件结构交给 Claude Code 时,后续修改多是 UI 文案和小行为调整;如果第一条提示词很模糊,表单逻辑和 API 逻辑就容易混在一起,最终 review 时间反而更长。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
从Obsidian到CLAUDE.md的Claude Code流程:不再反复解释上下文
把 Obsidian 工作笔记整理成 CLAUDE.md 运行说明,让 Claude Code 每次都带着正确上下文开始。
Claude Code 收入 CTA 路由:从文章分流到 PDF、Gumroad 与咨询
用 Claude Code 按读者意图把文章流量分到免费 PDF、Gumroad 教材或咨询入口。
Claude Code 团队交接规则: 把审查证据、权限、回滚和收入路径一起交付
面向团队的 Claude Code 交接格式: 证据、权限、回滚、免费 PDF、Gumroad 与咨询路径都要可审查。