Next.js Server Actions 與表單

Server Actions 完整指南 — 定義方式、useActionState、useFormStatus、useOptimistic、表單驗證、Progressive Enhancement、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(不變)

相關閱讀


參考資源


建立日期:2026-05-16 最後更新:2026-05-16

🔗相關文章