Next.js Server Actionsをマスターする:APIルートなしでフォーム、データ変更、セキュリティを処理する

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

Server Actionsとは何か(そしてなぜ知っておくべきか)

私が最初にNext.jsでフルスタックアプリを作り始めた頃、フォームを送信するたびに別のAPIルートを作る必要がありました — /api/submit-formというエンドポイントを用意し、クライアント側からフェッチして、ローディング状態やエラーを処理して…という感じです。機能はしましたが、シンプルであるべきことに対してボイラープレートが多すぎると感じていました。

Server Actionsはそれを完全に変えます。Reactコンポーネントから直接実行されるサーバーサイド関数を書けるようになります — APIルートも、手動のfetch()呼び出しも不要です。フレームワークがネットワーク層を処理してくれます。

クイックスタート:5分で最初のServer Actionを作る

App RouterのあるNext.js 14以上が必要です。既にプロジェクトがあればそのまま使えます。なければ、以下で新しく作成しましょう:

npx create-next-app@latest my-app --typescript --app
cd my-app
npm run dev

Server Actionを作成する

関数の先頭に"use server"を追加するだけで、Server Actionとしてマークできます:

// app/actions.ts
"use server"

export async function createPost(formData: FormData) {
  const title = formData.get("title") as string;
  const content = formData.get("content") as string;

  // データベースに保存(Prismaの例)
  await db.post.create({
    data: { title, content }
  });
}

あとはフォームで直接使うだけです — onSubmitハンドラも、fetch()も不要:

// app/new-post/page.tsx
import { createPost } from "@/app/actions";

export default function NewPostPage() {
  return (
    <form action={createPost}>
      <input name="title" placeholder="投稿タイトル" required />
      <textarea name="content" placeholder="本文..." required />
      <button type="submit">投稿を作成</button>
    </form>
  );
}

これだけです。ユーザーが送信すると、Next.jsがフォームデータをシリアライズしてサーバー上のcreatePostを呼び出します。APIルートも、クライアントサイドのフェッチも不要 — これで動きます。

深掘り:Server Actionsの実際の仕組み

ネットワーク層は見えない

裏側では、Next.jsが各Server Actionに対して自動的にPOSTエンドポイントを作成します。フォームが送信されると、ブラウザがそのエンドポイントにmultipart/form-dataリクエストを送信します。レスポンスが返ってくると、Reactが必要に応じて再レンダリングします。このエンドポイントを手動で設定する必要はなく、予測可能な/api/create-post URLのように、ユーザーが簡単に列挙することもできません。

ローディング状態と戻り値の管理

useActionStateフック(Next.js 15+ / React 19で利用可能)を使って、保留状態とアクションのレスポンスを処理しましょう:

"use client"

import { useActionState } from "react";
import { createPost } from "@/app/actions";

const initialState = { message: "", error: "" };

export default function PostForm() {
  const [state, formAction, isPending] = useActionState(createPost, initialState);

  return (
    <form action={formAction}>
      <input name="title" placeholder="投稿タイトル" required />
      <textarea name="content" placeholder="本文..." required />
      <button type="submit" disabled={isPending}>
        {isPending ? "保存中..." : "投稿を作成"}
      </button>
      {state.error && <p className="error">{state.error}</p>}
      {state.message && <p className="success">{state.message}</p>}
    </form>
  );
}

アクションを更新して、状態を受け取り、返すようにします:

"use server"

export async function createPost(prevState: any, formData: FormData) {
  const title = formData.get("title") as string;
  const content = formData.get("content") as string;

  if (!title || title.length < 3) {
    return { error: "タイトルは3文字以上必要です", message: "" };
  }

  try {
    await db.post.create({ data: { title, content } });
    return { message: "投稿を作成しました!", error: "" };
  } catch {
    return { error: "投稿の保存に失敗しました", message: "" };
  }
}

データ変更後の再検証

データを作成または更新した後、キャッシュされたページに変更を反映させる必要があります。revalidatePathを使いましょう:

"use server"

import { revalidatePath } from "next/cache";

export async function createPost(prevState: any, formData: FormData) {
  const title = formData.get("title") as string;

  await db.post.create({ data: { title, content: "" } });

  revalidatePath("/posts");  // /postsから取得しているページが最新データを受け取る

  return { message: "投稿を作成しました!" };
}

高度な使い方:バリデーション、認証、Optimistic UI

Zodによる入力バリデーション

FormDataから来るデータは絶対に信頼してはいけません。データベースを操作する前に、サーバーですべてを検証しましょう:

"use server"

import { z } from "zod";

const PostSchema = z.object({
  title: z.string().min(3, "タイトルが短すぎます").max(100, "タイトルが長すぎます"),
  content: z.string().min(10, "本文が短すぎます"),
});

export async function createPost(prevState: any, formData: FormData) {
  const result = PostSchema.safeParse({
    title: formData.get("title"),
    content: formData.get("content"),
  });

  if (!result.success) {
    return { error: result.error.errors[0].message };
  }

  await db.post.create({ data: result.data });
  return { message: "投稿を保存しました!" };
}

アクション内での認証チェック

ここでServer Actionsがセキュリティモデルにおいて真価を発揮します。認証チェックはサーバーで実行されるため、ブラウザのDevToolsを持つユーザーでもスキップできません:

"use server"

import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { revalidatePath } from "next/cache";

export async function deletePost(postId: string) {
  const session = await getServerSession(authOptions);

  if (!session?.user) {
    throw new Error("認証が必要です");
  }

  const post = await db.post.findUnique({ where: { id: postId } });

  if (post?.authorId !== session.user.id) {
    throw new Error("アクセス禁止:この投稿の所有者ではありません");
  }

  await db.post.delete({ where: { id: postId } });
  revalidatePath("/posts");
}

イベントハンドラからアクションを呼び出す

Server Actionsは<form action={...}>に限定されません。クリックハンドラからも呼び出せます:

"use client"

import { deletePost } from "@/app/actions";
import { startTransition } from "react";

export function DeleteButton({ postId }: { postId: string }) {
  function handleDelete() {
    startTransition(async () => {
      await deletePost(postId);
    });
  }

  return (
    <button onClick={handleDelete} className="btn-danger">
      削除
    </button>
  );
}

startTransitionでラップすることで、サーバーがリクエストを処理している間もUIの応答性を維持できます。

即時フィードバックのためのOptimistic Updates

useOptimisticを使ってサーバーが応答する前にUIを更新しましょう — いいねボタン、トグル、その他の素早いインタラクションに最適です:

"use client"

import { useOptimistic, startTransition } from "react";
import { toggleLike } from "@/app/actions";

export function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
  const [optimisticLikes, addOptimisticLike] = useOptimistic(
    initialLikes,
    (current, delta: number) => current + delta
  );

  async function handleLike() {
    startTransition(async () => {
      addOptimisticLike(1);       // UIが即座に更新される
      await toggleLike(postId);   // サーバーが追いつく
    });
  }

  return (
    <button onClick={handleLike}>
      ♥ {optimisticLikes}
    </button>
  );
}

本番環境で実装してきた経験からの実践的なヒント

実際の開発経験から言うと、Next.jsでフルスタックアプリを構築するなら、これはマスターすべき必須スキルの一つです。複数のプロジェクトでServer Actionsを使ってきた経験をもとに、もっと早く知りたかったことをお伝えします。

アクションは専用ファイルにまとめる

Server ComponentのインラインでServer Actionsを定義することもできますが、その方法では再利用やテストが難しくなります。app/actions.tsファイルを作成し(または機能ごとに整理してapp/posts/actions.tsなど)、必要な場所でインポートしましょう。これでコンポーネントがクリーンに保たれ、アクションを簡単に見つけられます。

クライアントバリデーションはUX向け、サーバーバリデーションはセキュリティ向け

ZodスキーマやHTML5属性でブラウザ側バリデーションをしていても、Server Action内で再バリデーションを行いましょう。DevToolsがあれば誰でもアクションエンドポイントに任意のPOSTリクエストを送れます。重要なのはサーバー側のチェックだけです。

UIに必要なものだけを返す

アクションから何を返すかは慎重に考えましょう。UIが成功メッセージだけを必要としているなら、内部ID、タイムスタンプ、センシティブなフィールドを含む完全なデータベースレコードを返す必要はありません。アクションのレスポンスはパブリックAPIのレスポンスと同じように扱いましょう。

エラーバウンダリを設定する

Server Actionがハンドルされていないエラーをスローすると、Next.jsは最も近いerror.tsxをレンダリングします。セーフティネットとして、アプリのレイアウトレベルに追加しておきましょう:

// app/error.tsx
"use client"

export default function Error({ error, reset }: { error: Error; reset: () => void }) {
  return (
    <div>
      <h2>エラーが発生しました</h2>
      <p>{error.message}</p>
      <button onClick={reset}>もう一度試す</button>
    </div>
  );
}

シンプルに始めて、少しずつ複雑さを加える

認証、バリデーション、Optimistic UI、エラーハンドリングをすべて一度に追加しようとしないでください。データを保存するだけのシンプルな"use server"関数から始めましょう。それが動いたら、Zodバリデーションを追加し、次に認証を追加します。各ステップは圧倒されるような一括設定ではなく、小さくテスト可能な積み重ねです。

Server Actionsを使うと、Next.jsでのフルスタック開発が劇的にシンプルになります — ファイル数が減り、ボイラープレートが少なくなり、APIルート認証を個別に管理するより理解しやすいセキュリティモデルが得られます。「APIを作って、フェッチする」から「関数を呼び出すだけ」へのメンタルシフトは、一度理解すると本当に解放感があります。

Share: