Next.js Server Actions 教學:表單處理最佳實踐完全指南
為什麼 Server Actions 改變了我的開發方式
還記得以前寫 Next.js 表單的日子嗎?每次都要建一個 API route、處理 CORS、寫 fetch 呼叫、再處理各種錯誤狀態……光想就累。自從 Next.js Server Actions 在 16.1 版本(2026 年 1 月)正式穩定,加上 React 19 的全新功能,這一切都變得簡單多了。
我第一次用 Server Actions 處理登入表單時,真的有點不敢相信——不用 API route、不用 useState 管理 loading、表單甚至在沒有 JavaScript 的情況下還能運作。這篇文章就是要把我踩過的坑和學到的最佳實踐全部分享給你。
Server Actions 基本概念
Server Actions 讓你可以直接在元件裡定義伺服器端函式,並從表單的 action 屬性或事件處理器直接呼叫。最關鍵的一點:這些函式跑在伺服器上,安全又快速。
組織方式上,建議把 Server Actions 放在獨立的檔案,而不是混在元件裡。這樣更好維護,也更容易測試:
// app/actions/auth.ts
'use server'
import { z } from 'zod'
import { revalidatePath } from 'next/cache'
const LoginSchema = z.object({
email: z.string().email('請輸入有效的電子郵件'),
password: z.string().min(8, '密碼至少需要 8 個字元'),
})
export async function loginAction(prevState: unknown, formData: FormData) {
const rawData = {
email: formData.get('email'),
password: formData.get('password'),
}
const result = LoginSchema.safeParse(rawData)
if (!result.success) {
return {
errors: result.error.flatten().fieldErrors,
message: '表單驗證失敗,請檢查輸入內容',
}
}
// 執行登入邏輯...
revalidatePath('/dashboard')
return { success: true }
}
注意到 'use server' 放在檔案最頂端了嗎?這樣整個檔案裡的所有 export 函式都會變成 Server Actions,比在每個函式內部加更乾淨。
useActionState:取代舊版 useFormState
如果你之前用過 useFormState,從現在開始要改用 useActionState 了——這是 React 19 帶來的更新,React 19 新功能指南有更詳細的說明。用法幾乎一樣,但語意更清晰:
// app/login/LoginForm.tsx
'use client'
import { useActionState } from 'react'
import { loginAction } from '@/app/actions/auth'
export function LoginForm() {
const [state, formAction, isPending] = useActionState(
loginAction,
null
)
return (
<form action={formAction}>
<div>
<label htmlFor="email">電子郵件</label>
<input
id="email"
name="email"
type="email"
required
/>
{state?.errors?.email && (
<p className="text-red-500">{state.errors.email[0]}</p>
)}
</div>
<div>
<label htmlFor="password">密碼</label>
<input
id="password"
name="password"
type="password"
required
/>
{state?.errors?.password && (
<p className="text-red-500">{state.errors.password[0]}</p>
)}
</div>
<SubmitButton />
{state?.message && (
<p className="text-red-500">{state.message}</p>
)}
</form>
)
}
useActionState 回傳三個值:目前的狀態、綁定好的 action 函式、還有 isPending 布林值。這個 isPending 超好用,馬上就會看到怎麼用它來做 loading UI。
useFormStatus:優雅的 Pending 狀態
useFormStatus 是另一個好用的 hook,專門用來讀取最近一個父層 <form> 的提交狀態。最適合拿來做提交按鈕:
// app/components/SubmitButton.tsx
'use client'
import { useFormStatus } from 'react-dom'
export function SubmitButton({ label = '送出' }: { label?: string }) {
const { pending } = useFormStatus()
return (
<button
type="submit"
disabled={pending}
className={`px-4 py-2 rounded ${
pending
? 'bg-gray-400 cursor-not-allowed'
: 'bg-blue-600 hover:bg-blue-700'
} text-white transition-colors`}
>
{pending ? '處理中...' : label}
</button>
)
}
這個元件可以在任何表單裡重複使用,完全不需要手動傳遞 pending 狀態。把 SubmitButton 放在 <form> 裡,它自動就知道表單有沒有在提交中。
Zod 驗證:一定要做的輸入保護
每個 Server Action 都應該做輸入驗證,Zod 是目前最流行的選擇。重要原則是:回傳錯誤,不要拋出例外。拋出例外的話,你需要額外的 error boundary 來處理,而且使用者體驗也比較差:
// 不推薦這樣做
export async function badAction(formData: FormData) {
'use server'
const email = formData.get('email')
if (!email) throw new Error('Email is required') // 避免這樣
}
// 推薦這樣做
export async function goodAction(prevState: unknown, formData: FormData) {
'use server'
const result = ContactSchema.safeParse({
email: formData.get('email'),
message: formData.get('message'),
})
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors,
}
}
// 成功處理...
return { success: true }
}
傳遞額外參數:.bind() 的妙用
有時候你需要傳額外的資料給 Server Action,例如編輯某筆記錄的 ID。這時候用 .bind() 是最乾淨的方式:
// app/actions/posts.ts
'use server'
export async function updatePost(
postId: string,
prevState: unknown,
formData: FormData
) {
// postId 會透過 bind 傳入
const title = formData.get('title')
// 更新邏輯...
revalidatePath('/posts')
revalidateTag(`post-${postId}`)
return { success: true }
}
// app/posts/[id]/edit/EditForm.tsx
'use client'
import { updatePost } from '@/app/actions/posts'
import { useActionState } from 'react'
export function EditForm({ postId }: { postId: string }) {
const updatePostWithId = updatePost.bind(null, postId)
const [state, formAction] = useActionState(updatePostWithId, null)
return (
<form action={formAction}>
{/* 表單內容 */}
</form>
)
}
用 .bind() 的好處是資料在伺服器端傳遞,不會暴露在 URL 或請求 body 裡,比用隱藏的 input 欄位安全多了。
快取更新:revalidatePath 和 revalidateTag
Server Actions 執行完之後,通常需要更新快取讓頁面顯示最新資料。Next.js 提供兩種方式:
- revalidatePath:讓指定路徑的所有快取失效,適合更新整個頁面的資料
- revalidateTag:針對特定的快取標籤,更精準,效能更好
import { revalidatePath, revalidateTag } from 'next/cache'
export async function createComment(
postId: string,
prevState: unknown,
formData: FormData
) {
'use server'
// 建立留言...
// 方法一:讓整個文章頁面重新抓資料
revalidatePath(`/posts/${postId}`)
// 方法二:只讓留言相關的快取失效(更精準)
revalidateTag(`post-${postId}-comments`)
return { success: true }
}
漸進式增強:沒有 JS 也能運作的表單
這是 Server Actions 我最喜歡的特性之一。因為表單的 action 屬性直接指向伺服器函式,即使使用者沒有啟用 JavaScript,表單還是可以正常提交!這叫做漸進式增強(Progressive Enhancement)。
要做到這點,記住幾個原則:只在需要客戶端互動時才加 'use client'、避免在表單裡依賴 JavaScript 才能運作的功能、用原生 HTML 表單元素而不是完全自定義的元件。如果你想加上 prefetching 功能,可以用 next/form 的 <Form> 元件取代原生 <form>,它會在使用者 hover 連結時預先載入目標頁面。
整合 App Router 的完整流程
如果你還在用 Pages Router,強烈建議先看看如何從 Pages Router 遷移到 App Router,因為 Server Actions 是 App Router 的功能,在 Pages Router 裡無法使用。
整個 App Router + Server Actions 的開發流程大概是這樣:Server Components 負責讀取資料、Client Components 負責互動 UI、Server Actions 負責所有資料變更操作。這三者分工清楚,程式碼自然就乾淨好維護。
常見問題排解
最後分享幾個我常遇到的問題:
Action 沒有執行? 確認函式有加 'use server',而且是在 async 函式上。
狀態沒有更新? 記得呼叫 revalidatePath 或 revalidateTag,不然 Next.js 會繼續用快取的資料。
useFormStatus 讀不到 pending? 確認 SubmitButton 元件是在 <form> 標籤的內部渲染,不是外部。
Server Actions 讓全端開發的體驗提升了一個層次,結合 Zod 驗證、React 19 的新 hooks,你的表單處理會變得既安全又優雅。趕快在下一個專案裡試試看吧!
繼續閱讀
Next.js App Router 遷移教學:從 Pages Router 無痛升級完整指南
相關文章
你可能也喜歡
探索其他領域的精選好文