⚡ 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 寫法的痛點:

  1. 你要維護兩個檔案:一個前端元件檔 (page.tsx),一個後端 API 檔 (route.ts)。
  2. 型別容易丟失:前端傳送 JSON,後端接收,中間很容易導致 TypeScript 型別對不上。
  3. 狀態管理複雜:你需要自己寫 isLoading, isError, data 等一堆 React State 來管理 API 請求狀態。

Server Actions 的優勢:

  1. 在同一個檔案搞定:前後端邏輯寫在同一個檔案(甚至同一個元件)裡。
  2. 完美型別安全:前端呼叫後端函數,TypeScript 完美推導參數型別。
  3. 與原生 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. 進階表單處理:useFormStateuseFormStatus

如果你堅持要用原生的 <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 吧!只要掌握好權限控管,你的開發速度將會達到前所未有的境界!🚀

解鎖完整教學內容

本章為付費內容。加入專案即可解鎖超過 5000 字的深度解析,包含 10 個以上神級 Prompt 與真實 Source Code 範例!