Server Actions:APIなしでフォーム処理
1. 概要
以前は、Next.jsでフォームを処理したい場合、APIルート(app/api/.../route.ts)を書いて、クライアントからfetchを使ってデータを送信する必要がありました。Server Actionsはその中間ステップを排除します。サーバーで実行される関数を書き、<form>のaction属性に直接付与すればOKです。
この記事を読むと、Server Actionの宣言方法、バリデーション付きのフォーム処理、useFormStateとuseFormStatusを使ったpending/エラー状態の処理、送信後のデータrevalidate、そしてAPIルートからの移行時によくあるエラーの対策が分かります。
注意: Server ActionsはApp Router(Next.js 13.4以上)の機能で、Next.js 14以降で安定しています。Pages Routerを使っている場合、この記事は該当しません。
2. 最初のServer Action
Server Actionは"use server"ディレクティブを持つasync関数で、別ファイルに置くか、Server Component内に直接記述できます。
// app/actions/customer-actions.ts
"use server";
export async function createCustomer(formData: FormData) {
const name = formData.get("name") as string;
const email = formData.get("email") as string;
console.log("顧客を作成:", name, email);
// DBへの保存をシミュレート
}
// app/customers/new/page.tsx
import { createCustomer } from "@/app/actions/customer-actions";
export default function NewCustomerPage() {
return (
<form action={createCustomer}>
<input name="name" placeholder="顧客名" />
<input name="email" placeholder="メール" />
<button type="submit">作成</button>
</form>
);
}
- ファイルの先頭にある
"use server"は、Next.jsにこのファイル内のすべてのexportがServer Actionで、サーバーでのみ実行され、クライアントに送信されるJSにバンドルされないことを伝えます。 action={createCustomer}は関数をフォームに直接付与します。Next.jsは自動的にフォームをFormDataにシリアライズし、送信時にサーバーで関数を呼び出します。onSubmitは不要、preventDefaultも不要、fetchも不要です。JavaScriptの読み込みが完了していなくても、フォームは動作します(プログレッシブエンハンスメント)。
送信すると、ログはサーバーターミナルに表示されます(ブラウザのコンソールではなく)。関数がサーバーで実行されているからです。
3. useFormStateで結果をクライアントに返す
実際には、バリデーションエラーや成功メッセージをクライアントに表示する必要があります。useFormState(react-domから)を使えば、Server Actionがstateをクライアントに返せます。
// app/actions/customer-actions.ts
"use server";
type ActionResult = { success: boolean; message: string };
export async function createCustomer(
_prevState: ActionResult,
formData: FormData
): Promise<ActionResult> {
const name = formData.get("name") as string;
const email = formData.get("email") as string;
if (!name || !email) {
return { success: false, message: "名前またはメールが不足しています。" };
}
if (!email.includes("@")) {
return { success: false, message: "メールアドレスが無効です。" };
}
// DBへの保存をシミュレート
return { success: true, message: `顧客${name}を作成しました。` };
}
// app/customers/new/page.tsx
"use client";
import { useFormState } from "react-dom";
import { createCustomer } from "@/app/actions/customer-actions";
const initialState = { success: false, message: "" };
export default function NewCustomerForm() {
const [state, formAction] = useFormState(createCustomer, initialState);
return (
<form action={formAction}>
<input name="name" placeholder="顧客名" />
<input name="email" placeholder="メール" />
<button type="submit">作成</button>
{state.message && (
<p style={{ color: state.success ? "green" : "red" }}>
{state.message}
</p>
)}
</form>
);
}
useFormState(action, initialState)はServer Actionをラップし、[state, formAction]を返します。stateはactionの最新の戻り値で、formActionは<form action={...}>に実際に付与される関数です。- Server Actionは最初のパラメータとして
prevStateを受け取ります。useFormStateで使う場合は必須ですが、使わなくても記述する必要があります。 useFormState(Reactフック)があるため、このファイルはClient Componentで、"use client"が必須です。
# メール不足で送信
> 名前またはメールが不足しています。
# 有効なデータで送信
> 顧客Nguyễn Văn Aを作成しました。
4. useFormStatusでpending状態を管理
送信時に、ボタンをdisableして読み込み中を表示したいでしょう。useFormStatusは最も近い親フォームのpending状態を読みますが、別のコンポーネント内に置く必要があります。
// app/customers/new/submit-button.tsx
"use client";
import { useFormStatus } from "react-dom";
export function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "作成中..." : "作成"}
</button>
);
}
useFormStatusは**<form>の内側に**レンダリングされたコンポーネント内でのみ動作します。<SubmitButton />を<form>を含むコンポーネント内に直接置いても、Reactが DOM ツリーで最も近い親フォームを探すため、動作は同じです。pendingは Server Action がサーバーで実行されている間はtrueで、ボタンを無効にしてダブルサブミットを防ぎます。
注意:
<form>を含むコンポーネント内で直接useFormStatusを呼び出さないでください。実は DOM ツリーを探すため動作しますが、別のコンポーネントに分けるのがベストプラクティスで、同じページに複数のフォームがある場合の混乱を避けられます。
5. Zodでデータを検証
if/elseで手動検証するのは、すぐに混乱します。Zodを使ってスキーマを明確に定義しましょう。
<!-- 適用されません — これはnpmの依存関係です。下を参照してください -->
npm install zod
// app/actions/customer-actions.ts
"use server";
import { z } from "zod";
const customerSchema = z.object({
name: z.string().min(2, "名前は最低2文字必要です。"),
email: z.string().email("メールアドレスが無効です。"),
});
type ActionResult = {
success: boolean;
message: string;
errors?: Record<string, string[]>;
};
export async function createCustomer(
_prevState: ActionResult,
formData: FormData
): Promise<ActionResult> {
const parsed = customerSchema.safeParse({
name: formData.get("name"),
email: formData.get("email"),
});
if (!parsed.success) {
return {
success: false,
message: "データが無効です。",
errors: parsed.error.flatten().fieldErrors,
};
}
return { success: true, message: `顧客${parsed.data.name}を作成しました。` };
}
safeParseはsuccessとdata/errorを持つオブジェクトを返し、例外をスローしません。クライアントにエラーを返すのに適しており、actionを崩壊させません。flatten().fieldErrorsは{ name: ["名前は最低..."], email: [...] }の形式のオブジェクトを返し、各入力の直下にエラーを表示するのに便利です。- スキーマは一度定義するだけで再利用でき、バリデーションロジックの重複を避けられます。
6. 送信後にデータをrevalidate
顧客を作成した後、顧客リスト(Next.jsでキャッシュされている)を更新する必要があります。revalidatePathまたはrevalidateTagを使いましょう。
// app/actions/customer-actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
export async function createCustomer(formData: FormData) {
const name = formData.get("name") as string;
// ... DBに保存
revalidatePath("/customers");
redirect("/customers");
}
revalidatePath("/customers")はそのページのキャッシュを削除し、次のアクセス時に Next.js は新しいデータで再レンダリングします。redirectは Next.js がキャッチする特殊な例外をスローします。revalidatePathの後に呼び出し、try/catchで捕捉しないでください。リダイレクトが機能しなくなります。- どちらの関数もServer ActionまたはServer Component内でのみ呼び出せます。クライアントからは呼び出せません。
7. Client ComponentからServer Actionを呼び出す(フォームを使わない場合)
Server Actionは必ず<form>に付与する必要はありません。onClickなど、クライアント内の他の場所から直接async関数として呼び出せます。
// app/customers/[id]/delete-button.tsx
"use client";
import { useState } from "react";
import { deleteCustomer } from "@/app/actions/customer-actions";
export function DeleteButton({ customerId }: { customerId: string }) {
const [isDeleting, setIsDeleting] = useState(false);
async function handleClick() {
setIsDeleting(true);
await deleteCustomer(customerId);
setIsDeleting(false);
}
return (
<button onClick={handleClick} disabled={isDeleting}>
{isDeleting ? "削除中..." : "削除"}
</button>
);
}
// app/actions/customer-actions.ts
"use server";
import { revalidatePath } from "next/cache";
export async function deleteCustomer(id: string) {
console.log("顧客IDを削除:", id);
revalidatePath("/customers");
}
- Server Actionは実際には Next.js が隠しPOST エンドポイントにビルドするもので、クライアントが自動生成されたネットワークリクエストで呼び出します。
fetchコードは見えませんが、実在しており、ネットワークタブで確認できます。 - この方法は削除ボタン、いいねボタン、ステータストグルなど、完全な
<form>が不要な場所で便利です。
8. Server Actionのテストを書く
Server Actionは単なるasync関数がFormDataを受け取るため、普通のユニット関数と同じようにテストできます。HTTP をモックする必要はありません。
npm install -D vitest
// app/actions/customer-actions.test.ts
import { describe, it, expect } from "vitest";
import { createCustomer } from "./customer-actions";
function buildFormData(data: Record<string, string>): FormData {
const fd = new FormData();
for (const [key, value] of Object.entries(data)) {
fd.append(key, value);
}
return fd;
}
describe("createCustomer", () => {
it("メールアドレスが無効な場合にエラーを返す", async () => {
const formData = buildFormData({ name: "An", email: "khong-hop-le" });
const result = await createCustomer({ success: false, message: "" }, formData);
expect(result.success).toBe(false);
expect(result.errors?.email).toContain("メールアドレスが無効です。");
});
it("有効なデータで成功する", async () => {
const formData = buildFormData({ name: "An", email: "an@example.com" });
const result = await createCustomer({ success: false, message: "" }, formData);
expect(result.success).toBe(true);
expect(result.message).toContain("An");
});
});
buildFormDataはブラウザが送信するFormDataをシミュレートするため、コンポーネントをレンダリングしたりサーバーをモックしたりする必要はありません。- Server Actionはpure関数(入力を受け取って出力を返す)なので、テストは高速で、Next.js dev server を起動する必要もありません。
注意: Server Action が
redirectやrevalidatePathを呼び出す場合、Next.js の実際のリクエストコンテキスト内でのみ動作するため、テストはエラーをスローします。ビジネスロジック(バリデーション、計算)をredirect/revalidatePathの呼び出しと分離して、テストしやすくしましょう。
9. よくあるエラー
"use server"を忘れるか、位置を誤る。 ディレクティブはファイルの先頭行(Server Component内で定義する場合は関数の先頭)に置く必要があります。なければ、Next.js は関数を通常のコードとみなし、クライアントにバンドルして、実行時にエラーが発生します。
Server Component 内でuseFormState/useFormStatusを使う。 これら2つのフックはreact-domに属し、Client Component でのみ実行されます。"use client"を忘れると、ビルドエラーが起きます。
Server Actionから複雑なオブジェクト(クラスインスタンス、シリアライズされていないDate)を返す。 戻り値はネットワーク経由でシリアライズ可能である必要があり、JSON のようにする必要があります。生のDateを返すと、微妙なエラーが起きることがあります。事前に文字列に変換してください。
すべてのServer Actionを使う。DB に触れない簡単なバリデーションなら、クライアントで処理する方が高速で、不要なラウンドトリップを避けられます。
JavaScript が失敗した場合のフォールバックがない。 Server Actions はプログレッシブエンハンスメントをサポートしていますが、<form action={...}>を正しく使っている場合だけです。preventDefault()してから手動でaction を呼び出すと、このメリットが失われます。
10. Server Actionsを使うべきでない場合
Server Actions は Next.js アプリ内部のCRUDフォームに最適です。ただし、以下の場合は:
- モバイルアプリやサードパーティが呼び出すpublic API が必要 — 本物のRoute Handler(
route.ts)を書き、バージョニングと認証を明確に。 - ストリーミングレスポンスまたは大きなファイルアップロード処理で細かい制御が必要 — Route Handler はより柔軟です。
- Next.js コンテキスト外で独立して実行する必要があるロジック(worker、cronジョブなど) — ロジックを別のサービスに分離し、Server Action がそれを呼び出すようにしてください。
11. まとめ
Server Actions を使うと、別のAPIルートを建てずにサーバーでデータを処理するフォームが書けます。ボイラープレートfetch/JSON が大幅に減ります。useFormStateでエラーを表示し、useFormStatusでpending状態を報告し、revalidatePathでキャッシュを更新すれば、クリーンな CRUD フローができます。JavaScript の読み込みが完了していなくても動作します。Zod でバリデーションを明確にし、外部向けの public API が必要な場合は本物の Route Handler に切り替えることを忘れずに。