⚡ Server Actions 完全指南:告別傳統 API
如果你是從傳統的 React (Create React App) 或是 Vue 轉移過來的開發者,你一定很習慣一種開發模式:
「在前端寫一個 Button,點擊後觸發 fetch 呼叫一支後端 API (例如 /api/users/update),後端處理完再回傳 JSON 給前端。」
這套流程我們寫了十幾年,直到 Next.js App Router 推出了一項核彈級的新功能:Server Actions。 它徹底顛覆了全端開發的邏輯,讓你不需要再寫任何 API Route,前端的按鈕可以直接「呼叫」後端的函數!
1. 什麼是 Server Actions?
Server Actions 允許你直接在 Server Components 或 Client Components 中,定義並呼叫在伺服器端執行的非同步函數。
傳統 API 寫法的痛點:
- 你要維護兩個檔案:一個前端元件檔 (
page.tsx),一個後端 API 檔 (route.ts)。 - 型別容易丟失:前端傳送 JSON,後端接收,中間很容易導致 TypeScript 型別對不上。
- 狀態管理複雜:你需要自己寫
isLoading,isError,data等一堆 React State 來管理 API 請求狀態。
Server Actions 的優勢:
- 在同一個檔案搞定:前後端邏輯寫在同一個檔案(甚至同一個元件)裡。
- 完美型別安全:前端呼叫後端函數,TypeScript 完美推導參數型別。
- 與原生 HTML 表單完美結合:就算 JavaScript 載入失敗或被禁用,表單依然能提交!
2. 第一個 Server Action:Hello World
讓我們先來看一個最簡單的例子。這是一個 Server Component,我們要在裡面寫一個修改資料庫的動作。
// src/app/profile/page.tsx
import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db'; // 假設你的資料庫實例
export default async function ProfilePage() {
// ⭐️ 這就是一個 Server Action!
// 只要加上 'use server',Next.js 就知道這個函數只能在伺服器機房執行!
async function updateName(formData: FormData) {
'use server'; // 魔法指令
// 從表單中取出輸入的值
const newName = formData.get('name') as string;
// 直接寫入資料庫!沒有 fetch,沒有 API 路徑!
await db.user.update({ name: newName });
// 告訴 Next.js 清除快取,重新整理畫面顯示最新資料
revalidatePath('/profile');
}
return (
<div className="p-8">
<h1 className="text-2xl">修改你的名字</h1>
{/* 直接把 Server Action 綁定到表單的 action 屬性上 */}
<form action={updateName} className="mt-4 flex gap-4">
<input
type="text"
name="name"
className="border p-2 rounded"
placeholder="輸入新名字"
required
/>
<button type="submit" className="bg-blue-500 text-white px-4 py-2 rounded">
儲存
</button>
</form>
</div>
);
}
這段程式碼的衝擊力在哪?「沒有 API 路由」!
當使用者按下「儲存」時,Next.js 會在底層自動幫你發送一個特製的 POST 請求給伺服器,伺服器執行 updateName,然後立刻把更新後的 HTML 畫面傳回來。整個過程絲滑無比。
3. 在 Client Component 中使用 Server Actions
上面的例子是 Server Component,但如果我們想要用 useState,或是有炫酷的點擊特效(必須是 Client Component),還能用 Server Actions 嗎?
答案是:可以,但要把 Server Action 抽離到獨立的檔案。
步驟 1:建立獨立的 Actions 檔案
建立一個 actions.ts 檔案,在檔案最頂端加上 'use server'。這代表這個檔案裡所有的 function 都是 Server Actions。
// src/app/actions/userActions.ts
'use server'; // 宣告整個檔案都是伺服器專用
import { db } from '@/lib/db';
import { revalidatePath } from 'next/cache';
// 這次我們不吃 FormData,我們直接吃一般參數
export async function updateUsername(userId: string, newName: string) {
try {
// 你可以在這裡做權限驗證
// if (!isLoggedIn) throw new Error('未登入');
await db.user.update({
where: { id: userId },
data: { name: newName }
});
revalidatePath('/profile');
return { success: true, message: '更新成功!' };
} catch (error: any) {
return { success: false, message: error.message };
}
}
步驟 2:在 Client Component 中呼叫
// src/app/profile/ClientProfile.tsx
'use client'; // 這是 Client Component
import { useState, useTransition } from 'react';
import { updateUsername } from '../actions/userActions'; // 直接 import 後端函數!
export default function ClientProfile({ userId, initialName }: { userId: string, initialName: string }) {
const [name, setName] = useState(initialName);
const [message, setMessage] = useState('');
// useTransition 是 React 18 的酷東西,用來管理非同步狀態
const [isPending, startTransition] = useTransition();
const handleSave = () => {
startTransition(async () => {
// 就像呼叫一般函數一樣呼叫 Server Action!
// TypeScript 會完美幫你檢查參數型別!
const result = await updateUsername(userId, name);
if (result.success) {
setMessage(result.message);
} else {
setMessage(`錯誤:${result.message}`);
}
});
};
return (
<div className="flex flex-col gap-4 max-w-sm">
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="border p-2"
/>
<button
onClick={handleSave}
disabled={isPending}
className="bg-indigo-500 text-white p-2 rounded disabled:bg-gray-400"
>
{isPending ? '儲存中...' : '儲存'}
</button>
{message && <p className="text-sm text-green-600">{message}</p>}
</div>
);
}
4. 進階表單處理:useFormState 與 useFormStatus
如果你堅持要用原生的 <form> 標籤(為了支援無 JS 環境),React 提供了兩個超強的 Hook 來輔助 Server Actions。
useFormStatus:獲取表單送出狀態
不需要再寫 isLoading 變數了,把你的按鈕抽成一個小元件:
'use client';
import { useFormStatus } from 'react-dom';
export function SubmitButton() {
// 這個 Hook 會自動去抓取外層 <form> 的狀態!
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="bg-blue-600 text-white px-4 py-2"
>
{pending ? '處理中 (Loading...)' : '確認送出'}
</button>
);
}
useFormState:獲取表單回傳的錯誤訊息
'use client';
import { useFormState } from 'react-dom';
import { createPost } from './actions';
import { SubmitButton } from './SubmitButton';
const initialState = {
message: '',
errors: null,
};
export default function PostForm() {
// 第一個參數是 Server Action,第二個是初始狀態
const [state, formAction] = useFormState(createPost, initialState);
return (
// 注意這裡的 action 變成 formAction 了
<form action={formAction} className="flex flex-col gap-4">
<input type="text" name="title" required />
<textarea name="content" required />
{/* 顯示從伺服器傳回來的錯誤訊息 */}
{state?.message && <p className="text-red-500">{state.message}</p>}
<SubmitButton />
</form>
);
}
5. 資安警告:不要把 Server Actions 當塑膠!
Server Actions 雖然寫法很像一般函數,但它本質上就是一支對外公開的 API。 駭客完全可以繞過你的前端 UI,直接拿參數去敲你的 Server Action!
❌ 致命錯誤示範:
'use server';
export async function deletePost(postId: string) {
// 完蛋了!駭客只要知道別人的 postId 傳進來,就能把別人的文章刪掉!
await db.post.delete({ where: { id: postId }});
}
✅ 堅不可摧的正確示範:
'use server';
import { getSession } from '@/lib/auth'; // 你的驗證邏輯
export async function deletePost(postId: string) {
// 1. 永遠要在函數第一行檢查登入狀態!
const session = await getSession();
if (!session?.user) throw new Error('Unauthorized');
// 2. 永遠要檢查這筆資料是不是屬於這個使用者的!
const post = await db.post.findUnique({ where: { id: postId }});
if (post.authorId !== session.user.id) {
throw new Error('Forbidden: 你只能刪除自己的文章');
}
// 3. 通過所有安檢,才執行動作
await db.post.delete({ where: { id: postId }});
}
擁抱 Server Actions 吧!只要掌握好權限控管,你的開發速度將會達到前所未有的境界!🚀