告别 API 路由:掌握 Next.js 14 Server Actions
在 Next.js 的演进史上,13.4 版本(App Router 稳定版)和 14 版本无疑是分水岭。在所有新特性中,最具革命性——也是让后端开发者最感到“被冒犯”的特性——非 Server Actions 莫属。
在 Server Actions 出现之前,在 Next.js 中处理一个简单的表单提交(例如“添加待办事项”),流程繁琐得令人头秃:
- 定义 API:在
pages/api/todo.ts中编写处理 POST 请求的 Handler。 - 前端胶水代码:在组件中编写
onSubmit,充斥着fetch('/api/todo')。 - 状态管理:手动维护一堆
isLoading、isError、data的useState。 - 类型同步:在前端和后端之间共享类型定义,以确保类型安全。
这不仅繁琐,而且割裂。虽然前后端逻辑在同一个项目中,但在心智模型上却是分离的。
随着 Server Actions 的稳定,一切都变了。我们可以像调用本地函数一样直接调用后端逻辑。Next.js 为我们处理了底层的 HTTP 通信、序列化和反序列化。这本质上是 RPC(远程过程调用) 模式的回归,但这一次,它是类型安全的,并且与 React 组件深度集成。
1. 什么是 Server Actions?
简单来说,Server Actions 就是在服务端运行的异步函数。你可以直接在服务端组件(Server Components)中定义它们,或者将它们导入到客户端组件(Client Components)中使用。

核心魔法在于 'use server' 指令。当 Next.js 编译器看到这个指令时,它会自动为你创建一个隐藏的 API 端点,并在客户端调用时通过 POST 请求触发它。
2. 实战:构建一个生产级的 Todo 表单
我们想要实现的功能:
- 数据变更:将数据写入数据库。
- 输入验证:使用 Zod 确保数据有效性。
- 状态反馈:处理成功或失败的消息。
- 页面刷新:写入成功后自动更新列表。
第一步:定义 Action (actions.ts)
不要将所有逻辑写在组件文件里。最佳实践是将 Actions 放在单独的文件中,以保持关注点分离。
'use server'
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { db } from '@/lib/db'; // 假设这是你的 Prisma 实例
// 1. 定义验证 Schema
const schema = z.object({
todo: z.string().min(3, "内容至少需要 3 个字符").max(100, "太长了"),
});
// 定义返回状态类型
export type FormState = {
message: string;
errors?: {
todo?: string[];
};
success?: boolean;
}
export async function createTodo(prevState: FormState, formData: FormData): Promise<FormState> {
// 模拟网络延迟(以便在开发时看到 Loading 效果)
await new Promise(resolve => setTimeout(resolve, 500));
// 2. 数据解析与验证
const rawData = {
todo: formData.get('todo'),
};
const parse = schema.safeParse(rawData);
if (!parse.success) {
return {
message: '输入验证失败',
errors: parse.error.flatten().fieldErrors,
success: false
};
}
// 3. 数据库操作
try {
await db.todo.create({
data: {
title: parse.data.todo,
completed: false
}
});
// 4. 重新验证(关键!)
// 这告诉 Next.js 清除该路径的服务端缓存并重新获取最新数据
revalidatePath('/');
return { message: '添加成功', success: true };
} catch (e) {
console.error(e);
return { message: '数据库内部错误', success: false };
}
}
第二步:构建客户端组件 (AddTodo.tsx)
在客户端组件中,我们需要使用 react-dom 提供的 useFormState Hook 来处理服务端返回的结果。这是一个非常强大的 Hook,是渐进式增强的基础——即使 JS 尚未加载完成,表单也能提交。
'use client'
import { useFormState } from 'react-dom';
import { createTodo } from '@/app/actions';
import { SubmitButton } from './SubmitButton';
import { useEffect, useRef } from 'react';
import toast from 'react-hot-toast';
const initialState = {
message: '',
};
export function AddTodo() {
// useFormState 接收 action 和初始 state
const [state, formAction] = useFormState(createTodo, initialState);
const formRef = useRef<HTMLFormElement>(null);
// 监听 state 变化以显示 Toast 和重置表单
useEffect(() => {
if (state.success) {
toast.success(state.message);
formRef.current?.reset();
} else if (state.message && !state.errors) {
toast.error(state.message);
}
}, [state]);
return (
<form ref={formRef} action={formAction} className="flex flex-col gap-2 max-w-md mx-auto mt-10">
<div className="flex gap-2">
<input
type="text"
name="todo"
className="border p-2 rounded flex-1 text-black"
placeholder="还有什么待办事项?"
aria-describedby="todo-error"
/>
<SubmitButton />
</div>
{/* 验证错误显示 */}
{state?.errors?.todo && (
<p id="todo-error" className="text-red-500 text-sm">
{state.errors.todo[0]}
</p>
)}
</form>
);
}
第三步:优雅的 Loading 状态 (SubmitButton.tsx)
不要手动维护 isLoading 状态!请使用 useFormStatus Hook。注意,此 Hook 必须在 <form> 的子组件中使用才能生效。
'use client'
import { useFormStatus } from 'react-dom';
import { Loader2 } from 'lucide-react'; // 假设你使用 lucide-react
export function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="bg-blue-600 text-white p-2 rounded disabled:opacity-50 flex items-center justify-center min-w-[80px]"
>
{pending ? <Loader2 className="animate-spin h-5 w-5" /> : '添加'}
</button>
);
}
3. 进阶:乐观 UI (Optimistic UI)
现代 Web 应用与传统网页最大的区别在于反馈速度。用户讨厌等待转圈圈。即使服务器响应只需要 100ms,我们也可以让界面感觉像是 0ms 延迟。
Next.js 结合 useOptimistic Hook 让我们能轻松实现这种高级体验。

假设我们有一个 Todo 列表组件:
'use client'
import { useOptimistic } from 'react';
import { deleteTodo } from '@/app/actions'; // 假设你有这个 action
export function TodoList({ todos }: { todos: Todo[] }) {
// 定义乐观状态
// useOptimistic<DataType, ActionType>(initialState, updateFn)
const [optimisticTodos, addOptimisticUpdate] = useOptimistic(
todos,
(state, idToRemove: string) => state.filter(t => t.id !== idToRemove)
);
return (
<ul className="space-y-2">
{optimisticTodos.map((t) => (
<li
key={t.id}
className="flex justify-between items-center bg-gray-100 p-2 rounded"
>
<span>{t.title}</span>
<form action={async () => {
// 1. 立即触发 UI 更新(同步)
addOptimisticUpdate(t.id);
// 2. 触发真实请求(异步)
await deleteTodo(t.id);
}}>
<button type="submit" className="text-red-500 text-sm hover:underline">
删除
</button>
</form>
</li>
))}
</ul>
);
}
在这个例子中,当你点击删除按钮的瞬间,addOptimisticUpdate 会立即执行,条目会瞬间从屏幕上消失。
- 如果服务器稍后返回成功,界面保持不变(因为
revalidatePath会带来最新的数据,其中也应该没有该条目)。 - 如果服务器返回失败,Next.js 会自动回滚状态,条目会重新出现。
整个过程丝般顺滑,用户毫无感知。
4. 常见误区 & FAQ
Q1: Server Actions 安全吗?
非常安全,但需要防范。 因为 Server Actions 是公开的 API 端点,任何人都可以获取 URL 并发送请求。
- 必须鉴权:一定要在 Action 函数的第一行检查用户权限(例如
await getUser())。不要依赖前端传递的 userId,因为那是可以伪造的。始终从 Session/Token 中获取当前用户 ID。 - 输入清洗:如上所示,使用 Zod 验证所有输入。
Q2: 我可以把所有代码写在一个文件里吗?
虽然可以在组件文件中定义 Action,但由于 Server Actions 需要 'use server' 指令,将客户端组件和服务端 Actions 混在一个文件里容易导致错误或打包体积异常。
最佳实践:始终将 Actions 放在单独的 actions.ts 文件或 actions/ 目录中。
Q3: Server Actions vs API Routes,怎么选?
- Server Actions:处理数据变更,如表单提交、点赞、删除。逻辑与 UI 紧密耦合。
- API Routes (Route Handlers):处理需要对外暴露的接口(如 Webhook 回调、给移动端用的 REST API)或复杂的 GET 请求(如流式传输文件)。
总结
Server Actions 是 Next.js 生态中最重要的一块拼图。它消灭了“胶水代码”,让前端工程师能以前所未有的效率编写全栈应用。结合 revalidatePath 和 useOptimistic,我们可以用极少的代码构建出拥有原生级体验的 Web 应用。