目錄
- 什麼是 Server Actions?
- 定義方式:Inline / Separate File
- 從 Client 呼叫 vs Form Action 屬性
- useActionState
- useFormStatus 與 useOptimistic
- 表單驗證(zod)
- Progressive Enhancement
- revalidate 與重新導向
- 安全性與 CSRF 防護
- 實戰範例
- 常見問題
- 總結
- 參考資源
什麼是 Server Actions?
Server Actions 是 Next.js 提供的機制,讓你在 Client Component 內直接呼叫 Server 端函式,不用手寫 API 路由與 fetch。
為什麼需要 Server Actions?
傳統 Mutation 流程的痛點:
client form submit
→ fetch("/api/posts", { method: "POST", body: JSON })
→ 後端 route handler 解析 body
→ 處理業務邏輯
→ 回傳 JSON
→ client 解析 + 更新 UI + revalidate
每個步驟都要寫一堆 boilerplate:API 路由、序列化、錯誤處理、loading 狀態、UI 更新。
Server Actions 的解法:
client form submit
→ 直接呼叫 server function
→ 內部處理 + revalidate
→ React 自動更新
把 API 層蒸發掉,端對端型別安全,少寫一半 code。
核心特點
- 🎯 同檔案前後端:可以寫在 Server Component 內聯
- ⚡ 型別端對端:呼叫端跟實作端共用 TypeScript 型別
- 🔧 自動序列化:FormData / 物件參數都能直接傳
- 📦 無需 API 路由:Next.js 在背後自動建立 endpoint
- 🚀 Progressive Enhancement:JS 還沒載入也能用(搭配
<form action>)
定義方式:Inline / Separate File
方式 1:Inline 在 Server Component 內
// app/posts/new/page.tsx
export default function NewPostPage() {
async function createPost(formData: FormData) {
"use server";
const title = formData.get("title") as string;
const content = formData.get("content") as string;
await db.post.create({ data: { title, content } });
}
return (
<form action={createPost}>
<input name="title" required />
<textarea name="content" required />
<button type="submit">發布</button>
</form>
);
}
"use server" 寫在 function body 第一行,標記這個 function 是 Server Action。
方式 2:獨立檔案(推薦)
// app/actions.ts
"use server";
import { db } from "@/lib/db";
export async function createPost(formData: FormData) {
const title = formData.get("title") as string;
const content = formData.get("content") as string;
await db.post.create({ data: { title, content } });
}
export async function deletePost(id: string) {
await db.post.delete({ where: { id } });
}
// app/posts/new/page.tsx
import { createPost } from "@/app/actions";
export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" required />
<textarea name="content" required />
<button type="submit">發布</button>
</form>
);
}
"use server" 寫在檔案最上方,整個檔案 export 的 function 都是 Server Action。
兩種寫法比較
| 寫法 | 適合 |
|---|---|
| Inline | 一次性、簡單的表單處理 |
| 獨立檔案 | 多處共用、需要單元測試、複雜業務邏輯 |
推薦獨立檔案:好維護、好測試、好複用。
從 Client 呼叫 vs Form Action 屬性
Server Action 有兩種觸發方式:
1. <form action={...}> 屬性(推薦)
<form action={createPost}>
<input name="title" />
<button type="submit">送出</button>
</form>
特性:
- ✅ JS 沒載入也能用(Progressive Enhancement)
- ✅ React 自動處理 pending / error 狀態
- ✅ 自動傳入
FormData
2. 從 Client Component 直接呼叫
"use client";
import { deletePost } from "@/app/actions";
export default function DeleteButton({ id }: { id: string }) {
return (
<button
onClick={async () => {
await deletePost(id);
}}
>
刪除
</button>
);
}
特性:
- 適合非表單情境(button onClick、effect)
- 自己管 loading / error 狀態
- JS 沒載入不能用
3. useTransition 處理 pending 狀態
"use client";
import { useTransition } from "react";
import { deletePost } from "@/app/actions";
export default function DeleteButton({ id }: { id: string }) {
const [isPending, startTransition] = useTransition();
return (
<button
disabled={isPending}
onClick={() => {
startTransition(async () => {
await deletePost(id);
});
}}
>
{isPending ? "刪除中..." : "刪除"}
</button>
);
}
useActionState
useActionState(React 19+)把 Server Action 包成「state + dispatcher」的模式,方便處理錯誤與返回值。
在 React 18 時叫
useFormState,React 19 改名為useActionState。
基本用法
// app/actions.ts
"use server";
type State = {
message: string;
errors?: Record<string, string>;
};
export async function createPost(prevState: State, formData: FormData): Promise<State> {
const title = formData.get("title") as string;
if (!title || title.length < 3) {
return {
message: "失敗",
errors: { title: "標題至少 3 個字" },
};
}
await db.post.create({ data: { title } });
return { message: "成功!" };
}
// app/posts/new/page.tsx
"use client";
import { useActionState } from "react";
import { createPost } from "@/app/actions";
const initialState = { message: "" };
export default function NewPostForm() {
const [state, formAction, isPending] = useActionState(createPost, initialState);
return (
<form action={formAction}>
<input name="title" />
{state.errors?.title && <p className="error">{state.errors.title}</p>}
<button type="submit" disabled={isPending}>
{isPending ? "送出中..." : "發布"}
</button>
{state.message && <p>{state.message}</p>}
</form>
);
}
Return Value 模式
| 返回 | 用途 |
|---|---|
{ message: string } |
簡單訊息(成功/失敗) |
{ errors: Record<string, string> } |
欄位層級錯誤 |
{ data: T } |
操作結果資料 |
useFormStatus 與 useOptimistic
useFormStatus
在任何子元件內讀取最近的父層 <form> 提交狀態,不需 prop drill。
// app/components/SubmitButton.tsx
"use client";
import { useFormStatus } from "react-dom";
export default function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "處理中..." : "送出"}
</button>
);
}
// 父層 form
<form action={createPost}>
<input name="title" />
<SubmitButton /> {/* 自動偵測父層 form 狀態 */}
</form>
限制:useFormStatus 必須在 <form> 元素內的子元件呼叫,不能在 form 自己上面。
useOptimistic
讓 UI 先樂觀更新,等 server 真正回應後再修正。
"use client";
import { useOptimistic } from "react";
import { addTodo } from "@/app/actions";
type Todo = { id: string; text: string; pending?: boolean };
export default function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
const [optimisticTodos, addOptimistic] = useOptimistic(
initialTodos,
(current, newTodo: Todo) => [...current, { ...newTodo, pending: true }]
);
async function action(formData: FormData) {
const text = formData.get("text") as string;
addOptimistic({ id: crypto.randomUUID(), text }); // 立即更新 UI
await addTodo(text); // 背景送 server
}
return (
<>
<form action={action}>
<input name="text" />
<button>新增</button>
</form>
<ul>
{optimisticTodos.map((todo) => (
<li key={todo.id} style={{ opacity: todo.pending ? 0.5 : 1 }}>
{todo.text}
</li>
))}
</ul>
</>
);
}
表單驗證(zod)
Server Action 是 server-side code,所有驗證都應該在 server 重做(client 驗證只是 UX 加值)。
用 zod 驗證 FormData
// app/actions.ts
"use server";
import { z } from "zod";
const PostSchema = z.object({
title: z.string().min(3, "標題至少 3 個字").max(100, "標題不超過 100 字"),
content: z.string().min(10, "內容至少 10 個字"),
tags: z.array(z.string()).optional(),
});
type State = {
errors?: Record<string, string[]>;
message?: string;
};
export async function createPost(prevState: State, formData: FormData): Promise<State> {
const parsed = PostSchema.safeParse({
title: formData.get("title"),
content: formData.get("content"),
tags: formData.getAll("tags"),
});
if (!parsed.success) {
return {
errors: parsed.error.flatten().fieldErrors,
message: "驗證失敗",
};
}
await db.post.create({ data: parsed.data });
return { message: "建立成功" };
}
Client 端配合顯示錯誤
"use client";
import { useActionState } from "react";
import { createPost } from "@/app/actions";
export default function PostForm() {
const [state, formAction] = useActionState(createPost, {});
return (
<form action={formAction}>
<div>
<input name="title" />
{state.errors?.title?.map((e: string) => (
<p key={e} className="error">{e}</p>
))}
</div>
<div>
<textarea name="content" />
{state.errors?.content?.map((e: string) => (
<p key={e} className="error">{e}</p>
))}
</div>
<button type="submit">送出</button>
</form>
);
}
Progressive Enhancement
Progressive Enhancement (PE) = 即使 JS 未載入,核心功能依然可用。
Server Actions 天然支援 PE
// 這個表單在 JS 停用時也能 submit
<form action={createPost}>
<input name="title" />
<button>送出</button>
</form>
背後機制:Next.js 把 Server Action 包成一個 HTTP POST endpoint,當 JS 沒載入時,瀏覽器走標準 form submit → 該 endpoint → server 處理 → redirect 回頁面。
PE 失效的情境
- 使用
useOptimistic:需要 JS 才能樂觀更新 - 用
onClick觸發 action:button 點擊事件需要 JS - 用
useActionState:需要 hydration 才能讀 state
PE 友善的設計原則
// ✅ JS 沒載入也能用
<form action={createPost}>
<input name="title" required minLength={3} /> {/* HTML 原生驗證 */}
<button>送出</button>
</form>
// ❌ 必須 JS 才能用
<form action={createPost}>
<input name="title" />
<button
onClick={(e) => {
e.preventDefault();
// 純 client 邏輯
}}
>送出</button>
</form>
revalidate 與重新導向
Mutation 完成後通常要做兩件事:讓快取失效、導向其他頁。
revalidatePath / revalidateTag
"use server";
import { revalidatePath, revalidateTag } from "next/cache";
export async function createPost(formData: FormData) {
await db.post.create({ data: { /* ... */ } });
// 方式 1:失效特定路徑
revalidatePath("/posts");
// 方式 2:失效 tag
revalidateTag("posts");
}
完整快取機制見 rendering-and-cache.md
redirect
"use server";
import { redirect } from "next/navigation";
export async function createPost(formData: FormData) {
const post = await db.post.create({ data: { /* ... */ } });
redirect(`/posts/${post.id}`); // 自動丟出特殊錯誤觸發 navigation
}
注意:redirect 是丟 exception 觸發,不要包在 try-catch 內,否則會被攔截。
兩者結合
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
export async function createPost(formData: FormData) {
const post = await db.post.create({ data: { /* ... */ } });
revalidatePath("/posts");
redirect(`/posts/${post.id}`);
}
安全性與 CSRF 防護
Server Actions 內建 CSRF 防護
Next.js 對 Server Actions 套用以下保護:
- 只接受 same-origin POST
- 自動加密 action ID(避免列舉內部結構)
- 拒絕
Content-Type不正確的請求
但你還是要做的事
1. 驗證使用者身份
Server Action 公開可呼叫,任何登入或未登入的 client 都能觸發。一定要驗證身份:
"use server";
import { auth } from "@/lib/auth";
export async function deletePost(id: string) {
const session = await auth();
if (!session?.user) {
throw new Error("未登入");
}
const post = await db.post.findUnique({ where: { id } });
if (post?.userId !== session.user.id) {
throw new Error("無權限");
}
await db.post.delete({ where: { id } });
}
2. 驗證輸入
永遠 safeParse 所有輸入(見 表單驗證)。
3. 別把敏感資料當參數
// ❌ 不要把 userId 從 client 傳進來
export async function updateProfile(userId: string, data: ProfileData) {
await db.user.update({ where: { id: userId }, data });
// 攻擊者可以傳任意 userId
}
// ✅ 從 session 拿 userId
export async function updateProfile(data: ProfileData) {
const session = await auth();
if (!session) throw new Error("未登入");
await db.user.update({ where: { id: session.user.id }, data });
}
4. Rate Limiting
對寫入類 action 加流量控制:
"use server";
import { ratelimit } from "@/lib/ratelimit";
import { headers } from "next/headers";
export async function createComment(text: string) {
const ip = (await headers()).get("x-forwarded-for") ?? "anonymous";
const { success } = await ratelimit.limit(ip);
if (!success) throw new Error("請求太頻繁");
// ...
}
實戰範例
範例 1:完整 Todo 應用
// app/actions.ts
"use server";
import { z } from "zod";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";
import { auth } from "@/lib/auth";
const TodoSchema = z.object({
text: z.string().min(1).max(200),
});
export async function addTodo(formData: FormData) {
const session = await auth();
if (!session) throw new Error("未登入");
const parsed = TodoSchema.safeParse({
text: formData.get("text"),
});
if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors };
}
await db.todo.create({
data: { text: parsed.data.text, userId: session.user.id },
});
revalidatePath("/todos");
return { success: true };
}
export async function toggleTodo(id: string) {
const session = await auth();
if (!session) throw new Error("未登入");
const todo = await db.todo.findUnique({ where: { id } });
if (!todo || todo.userId !== session.user.id) throw new Error("無權限");
await db.todo.update({
where: { id },
data: { done: !todo.done },
});
revalidatePath("/todos");
}
export async function deleteTodo(id: string) {
const session = await auth();
if (!session) throw new Error("未登入");
await db.todo.delete({
where: { id, userId: session.user.id },
});
revalidatePath("/todos");
}
// app/todos/page.tsx (Server Component)
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import TodoForm from "./TodoForm";
import TodoItem from "./TodoItem";
export default async function TodosPage() {
const session = await auth();
if (!session) return <div>請先登入</div>;
const todos = await db.todo.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: "desc" },
});
return (
<div>
<TodoForm />
<ul>
{todos.map((todo) => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
</div>
);
}
// app/todos/TodoForm.tsx
"use client";
import { useActionState, useRef, useEffect } from "react";
import { addTodo } from "@/app/actions";
export default function TodoForm() {
const [state, formAction, isPending] = useActionState(addTodo, {});
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
if (state.success) {
formRef.current?.reset();
}
}, [state]);
return (
<form ref={formRef} action={formAction}>
<input name="text" placeholder="新增待辦..." />
{state.errors?.text && <p className="error">{state.errors.text[0]}</p>}
<button disabled={isPending}>{isPending ? "新增中..." : "新增"}</button>
</form>
);
}
範例 2:樂觀更新的 Like 按鈕
"use client";
import { useOptimistic, startTransition } from "react";
import { toggleLike } from "@/app/actions";
type Props = {
postId: string;
liked: boolean;
count: number;
};
export default function LikeButton({ postId, liked, count }: Props) {
const [optimistic, setOptimistic] = useOptimistic(
{ liked, count },
(current) => ({
liked: !current.liked,
count: current.liked ? current.count - 1 : current.count + 1,
})
);
return (
<button
onClick={() => {
startTransition(async () => {
setOptimistic(null);
await toggleLike(postId);
});
}}
>
{optimistic.liked ? "❤️" : "🤍"} {optimistic.count}
</button>
);
}
常見問題
問題 1:Server Action 在 useEffect 內呼叫卻噴錯
症狀:Server Actions cannot be called during render
原因:Server Action 不能在 render 階段或 effect 同步觸發
解決方案:用 useTransition 包起來
const [isPending, startTransition] = useTransition();
useEffect(() => {
startTransition(async () => {
await myAction();
});
}, []);
問題 2:FormData 拿不到值 / 拿到 null
原因:可能是 name 屬性沒寫對
檢查:
// ❌ 錯誤:input 沒 name
<input value={title} onChange={...} />
// ✅ 正確
<input name="title" value={title} onChange={...} />
問題 3:redirect 報錯說「Cannot read properties of null」
原因:redirect 包在 try-catch 內被攔截了
解決方案:把 redirect 寫在 catch 外
// ❌ 錯誤
try {
await createPost(data);
redirect("/posts"); // 被攔截
} catch (e) {
// ...
}
// ✅ 正確
try {
await createPost(data);
} catch (e) {
return { error: e.message };
}
redirect("/posts");
問題 4:表單送出後沒清空
原因:React 預設不會 reset
解決方案:用 useRef + form.reset(),或 key 重新 mount
const formRef = useRef<HTMLFormElement>(null);
// 在成功後呼叫
formRef.current?.reset();
問題 5:useFormStatus 一直回傳 pending: false
原因:呼叫位置不在 <form> 子層級
解決方案:確保 useFormStatus 在 <form> 內的元件呼叫,不能在 form 同層或父層
總結
核心要點
Server Actions = "use server" + 從 Client 直接呼叫 server 函式 + 自動 endpoint
關鍵優勢:
- ✅ 端對端型別安全
- ✅ Progressive Enhancement
- ✅ 自動 CSRF 防護
- ✅ 與 revalidate 機制無縫整合
表單模式速查
| 需求 | 用 |
|---|---|
| 基本表單送出 | <form action={serverAction}> |
| 顯示 server 回傳訊息 | useActionState |
| 按鈕 loading 狀態 | useFormStatus |
| 樂觀 UI 更新 | useOptimistic |
| Button onClick 觸發 | useTransition + 直接呼叫 |
| 表單驗證 | zod 在 server 側 safeParse |
| 完成後跳頁 | redirect() |
| 完成後刷新列表 | revalidatePath / revalidateTag |
Hook 名稱對照表
| React 18 | React 19 / Next.js 15+ |
|---|---|
useFormState |
useActionState |
useFormStatus |
useFormStatus(不變) |
useOptimistic |
useOptimistic(不變) |
相關閱讀
- Next.js 入門指南 — 先打底 Server/Client Components
- 渲染與快取 —
revalidatePath與revalidateTag完整機制 - Metadata 與 SEO — Mutation 後可能需要更新 OG image
- 進階路由 — Middleware 配合做 auth
- 部署與 Runtime — Edge runtime 對 Server Action 的限制
參考資源
- 官方 Server Actions 文件:https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations
- React useActionState:https://react.dev/reference/react/useActionState
- React useOptimistic:https://react.dev/reference/react/useOptimistic
- zod:https://zod.dev
建立日期:2026-05-16 最後更新:2026-05-16