🕌

Server Actions を 使って Next.js で フォーム処理を実装。

2025/01/14に公開

はじめに

こんにちは、クラウドエースの第3開発部に所属している金です。

今回は Next.js で Server Actions を使ってフォームの送信処理を行う方法について紹介します。
フォームの処理については、これまでの Route Handler を使用したカスタムハンドラーの実装方法でも対応できますが、
Server Actions を利用することで、よりシンプルかつ効率的にサーバーサイドの処理を実装することができます。

対象読者

  • Next.js の基礎知識がある方
  • Route Handlers の使用経験がある方
  • Server Functions に興味がある方
  • Next.js でフォーム実装の簡略化を検討している方

事前準備

概要

Server Actions とは?

2024年9月までは、全ての Server Functions を Server Actions と呼んでいましたが、
現在は Server Functions のうち、action プロパティが渡されるか、action 内部で呼び出される Server Functions のみを Server Actions と呼ぶようになっています。

Server Actions と Server Functions は、簡単に言うと以下のような関係になります。

  • Server Functions: サーバーサイドで実行される関数の総称。
  • Server Actions: Server Functions のうち、クライアントサイドとのインタラクション-(action を介したデータのやり取り)を担う特殊な関数。

詳細については、以下のリンクをご参考ください。
ご参考: React 公式ドキュメント - Server Actions

まずは、Route Handlers で 簡単な ログイン form 処理を作成します。 次に Route Handler で作成した form 処理を Server Actions に変更し、Server Actions と Route Handler を比較して、どのような違いがあるかを解説します。

Route Handlers

Route Handlers は、Next.js の Web Request API として提供されており、特定のルートに対するカスタムリクエストハンドラを作成できる機能です。
GET, POST, PATCH, PUT, DELETE などの HTTP Method 処理を行うことが出来ます。
App ディレクトリ内に route.ts というファイルがあると Route Handlers として認識されます。

多くのサービスでは、Backend Framework ( Spring Boot, FastAPI, Gin など ) を使用して、RESTful API を作成するため、Route Handlers の活用性は高くないですが、
Next Auth を使いたい場合や小規模プロジェクトで Backend を別途実装する時間がない場合に使用されることが多いと思います。

※ Route Handlers は App ディレクトリ配下のみ使用できます。API Routes と同じ機能を持つため、同時に使用することはできません。
※ ご参考:Next.js 公式ドキュメント - Route Handlers

作成する サンプルコードは、以下のような構成になります。
簡単なログインフォームを作成し、Route Handler でフォームの処理を行います。


├── src
│   ├── app
│   │   ├── api
│   │   │   └── userAuth
│   │   │       └── route.ts
|   |   ├── components
|   |   |   └── inputForm.tsx
|   |   |   └── Button.tsx
│   │   ├── login
│   │   │   └── page.tsx
│   │   └── layout.tsx
│   │   └── page.tsx

Route Handlers で GET 処理を作成

まずは、Route Handlers で GET処理を作成します。
route.ts ファイルを作成し、以下のようなコードを記述します。
HTTP Method 名を関数名にすることで、ブラウザでアクセスすると、API からレスポンスが返ってくるようになります。

// src/app/api/route.ts

// HTTP Request を受け取る処理をここで行う
// 関数名をHTTP Method 名にする( GET, POST, PATCH, PUT, DELETE)
// - 関数名が HTTP Method 名にない場合、エラーが発生します。
export async function GET(request: NextRequest) {
  console.log("GET:",request);
  return Response.json({ message: "GET ok!" });
}

上記のコードをブラウザでアクセスすると、以下のようなレスポンスが返ってきます。

http://localhost:3000/api/userAuth

{"message":"GET ok!"}

ターミナルには、以下のようなログが出力されます。

 GET /api/userAuth 200 in 108ms
 GET /favicon.ico 200 in 27ms
 ✓ Compiled in 24ms
GET: NextRequest [Request] {
  [Symbol(realm)]: {
    settingsObject: { baseUrl: undefined, origin: [Getter], policyContainer: [Object] }
  },
  [Symbol(state)]: {
    method: 'GET',
    localURLsOnly: false,
    unsafeRequest: false,
    body: null,
    client: { baseUrl: undefined, origin: [Getter], policyContainer: [Object] },
    reservedClient: null,
    replacesClientId: '',
    window: 'client',
    keepalive: false,
    serviceWorkers: 'all',
    initiator: '',
    destination: '',
    priority: null,
    origin: 'client',
    policyContainer: 'client',
    referrer: 'client',
    referrerPolicy: '',
    mode: 'cors',
    useCORSPreflightFlag: false,
    credentials: 'same-origin',
    useCredentials: false,
    cache: 'default',
    redirect: 'follow',
    integrity: '',
    cryptoGraphicsNonceMetadata: '',
    parserMetadata: '',
    reloadNavigation: false,
    historyNavigation: false,
    userActivation: false,
    taintedOrigin: false,
    redirectCount: 0,
    responseTainting: 'basic',
    preventNoCacheCacheControlHeaderModification: false,
    done: false,
    timingAllowFailed: false,
    headersList: HeadersList {
      cookies: null,
      [Symbol(headers map)]: [Map],
      [Symbol(headers map sorted)]: [Array]
    },
    urlList: [ [URL] ],
    url: URL {
      href: 'http://localhost:3000/api/userAuth',
      origin: 'http://localhost:3000',
      protocol: 'http:',
      username: '',
      password: '',
      host: 'localhost:3000',
      hostname: 'localhost',
      port: '3000',
      pathname: '/api/userAuth',
      search: '',
      searchParams: URLSearchParams {},
      hash: ''
    }
  },
  ...省略

Route Handlers で POST 処理を作成

次に、Route Handlers で POST Method を作成し、ログインボタンでデータを送信するように実装します。

// src/app/api/route.ts

export async function POST(request: NextRequest) {
  const reqData = await request.json();
  console.log("POST api/userAuth:",reqData);
  return Response.json(reqData);
}
// login/page.tsx

"use client";

import { useState } from "react";
import Button from "../components/Button";
import InputForm from "../components/InputForm";

export default function Login() {

  const [formData, setFormData] = useState({
        email: "",
        password: ""
      });
  const handleClick = async () => {
    const res = await fetch("/api/userAuth", {
      method: "POST",
      body: JSON.stringify({
        email: formData.email,
        password: formData.password,
      }),
    });
    console.log(await res.json());
  };

  return (
    <div className="flex flex-col w-full  py-8 px-6">
      <div>
        <h2 className="text-2xl">Route Handlers Test</h2>
      </div>
      <form className="flex flex-col w-full gap-2">
        <InputForm type="email" placeholder="Email" value={formData.email}
        onChange={(e) => setFormData(prev => ({...prev, email: e.target.value}))} errors={[]} />
        <InputForm type="password" placeholder="Password" value={formData.password}
        onChange={(e) => setFormData(prev => ({...prev, password: e.target.value}))}errors={[]} />
        <span onClick={handleClick}>
          <Button text="Login" />
        </span>
      </form>
    </div>
  );
}
// components/Button.tsx

interface ButtonProps {
    text: string;
    type?: "button" | "submit" | "reset";
    onClick?: () => void;
  }
  
  export default function Button({ text, type = "submit", onClick}: ButtonProps) {
    return (
      <button
        type={type}
        onClick={onClick}
        className="bg-blue-500 w-full text-white h-10 disabled:bg-gray-500 disabled:text-neutral-300 disabled:cursor-not-allowed"
      >
        {text}
      </button>
    );
  }
// components/InputForm.tsx

interface FormInputProps {
  type: string;
  placeholder: string;
  value: string;
  onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}

export default function InputForm({
  type,
  placeholder,
  value,
  onChange,
}: FormInputProps) {
  return (
    <div className="flex flex-col gap-2">
      <input
        className="rounded-md w-full h-10 focus:outline-none border-none placeholder:text-gray-500 p-2 text-black"
        type={type}
        placeholder={placeholder}
        value={value}
        onChange={onChange}
      />
    </div>
  );
}

handleClick 関数で、fetch を使用して、POST メソッドでデータを送信します。
POST 関数で、リクエストデータを受け取り、以下のようにサーバー側でレスポンが返ってくるようになります。

POST api/userAuth: { email: 'test@test.com', password: 'test' }
POST /api/userAuth 200 in 68ms
 ✓ Compiled /login in 66ms

ここまでの処理は、Next.js で API を使ってデータの送受信を行う際によく用いられる方法です。
クライアント側からバックエンド(POST 関数)にリクエストを送信し、バックエンドではログインしたユーザーの cookie などの検証を行い、
ログインの可否を判断してクライアント側にレスポンスを返すという流れになります。
シンプルな実装例として紹介しましたが、実際の開発では useEffect の使用や、fetch 後の state 管理など、より複雑な処理が求められることが一般的です。

Server Actions でのフォーム処理は Route Handlers と違って fetch などのクライアント側の処理がよりシンプルになります。

Server Actions

Server Actions は、サーバー上で実行される非同期関数です。
Next.js アプリケーションにおいて、フォーム送信やデータの更新を処理する際に、サーバーコンポーネントとクライアントコンポーネントの両方から呼び出すことができます。
※ ご参考: Next.js 公式ドキュメント - Server Actions and Mutations

Route Handlers を作成する必要はなく、「use server」を付けるだけで、
サーバー側で処理を行う関数をクライアント側で利用することが可能です。

Route Handlers で作成したサンプルコードを以下のように変更します。

// src/app/login/page.tsx

import Button from "../components/Button";
import InputForm from "../components/InputForm";

export default function Login() {
  // handleSubmit: サーバーサイドで処理を行う関数。
  // "use server" を記述することで、サーバーサイドで処理を行うことができます。
  // FormData の値を取得する際はformData.get("name属性値")を使用します。
  async function handleSubmit(formData: FormData) {
    "use server"; 
    console.log("Email:",formData.get("email"), "Password:",formData.get("password"));
    console.log("run in server!");
  }

  // ※ form の action に Server Actions の関数を指定します。
  return (
    <div className="flex flex-col w-full  py-8 px-6">
      <div>
        <h2 className="text-2xl">Route Handlers Test</h2>
      </div>
      <form className="flex flex-col w-full gap-2" action={handleSubmit}>
        <InputForm type="email" placeholder="Email" name="email" />
        <InputForm type="password" placeholder="Password" name="password" />
        <Button text="Login" type="submit" />
      </form>
    </div>
  );
}
// src/app/components/InputForm.tsx

interface FormInputProps {
  type: string;
  placeholder: string;
  name?: string;
}

export default function InputForm({ type, placeholder, name }: FormInputProps) {
  return (
    <div className="flex flex-col gap-2">
      <input
        className="rounded-md w-full h-10 focus:outline-none border-none placeholder:text-gray-500 p-2 text-black"
        type={type}
        name={name}
        placeholder={placeholder}
      />
    </div>
  );
}

Route Handlers と違って、fetch などのクライアント側の処理が不要になります。
フォームの送信処理を行う際に、action プロパティに Server Actions の関数を指定するだけで、サーバーサイドで処理を行うことができます。
Network タブで確認すると、サーバーサイドで処理が行われていることが確認できます。

image

ログインボタンをクリックすると、POST リクエストが発生します。
つまり、Next.js の内部で POST Method に対応するために、Route Handlers が自動的に作成されるということです。

以下の Payload を確認すると、email と password がサーバーサイドで処理されていることが分かります。

image

※ Server Actions で Input を使用する際には、name 属性が必須です。
※ Input に name を指定しない場合、Payload に値が含まれないため注意してください。

Server Actions で Loading 処理

ユーザーがログインボタンをクリックした際、上記のような場合、リクエストが送信されたかどうかが分かりにくいかもしれません。
その結果、何度もクリックしてしまい、複数回リクエストが送信されることがあります。
これを防ぐために、useFormStatusフックを使用してフォームアクションの状態を確認し、ローディング処理を行います。

useFormStatus は、フォーム送信に関するステータス情報を提供する React フックです。

const { pending } = useFormStatus();
// pending: フォームアクションが実行中かどうかを示す真偽値。

詳細について以下をご参考ください。

ご参考: React 公式ドキュメント - useFormStatus

※ useFormStatus フォームに直接使用することはできませんのでご注意ください。
※ フォーム内部のコンポーネント(子コンポーネント)のみで使用可能です。

// src/app/login/page.tsx

export default function Login() {
  async function handleSubmit(formData: FormData) {
    "use server";
    // loading 検証のため、3秒待機
    await new Promise((resolve) => setTimeout(resolve, 3000));
    console.log(formData.get("email"), formData.get("password"));
    console.log("run in server!");
  }

  // useFormStatus :フォームの action に渡された関数(handleSubmit)が pending 状態かどうかを確認
  // const { pending } = useFormStatus();

  return (
    <div className="flex flex-col w-full  py-8 px-6">
      <div>
        <h2 className="text-2xl">Server Actions Test</h2>
      </div>
      <form className="flex flex-col w-full gap-2" action={handleSubmit}>
        <InputForm type="email" placeholder="Email" name="email" />
        <InputForm type="password" placeholder="Password" name="password" />
        <Button text="Login" type="submit" />
      </form>
    </div>
  );
}

実際に useFormStatus は form がある login/page.tsx ではなく form 内部の Button.tsx コンポネントのみで使用可能です。
以下のように Button.tsx に useFormStatus を使用することで、親のコンポネント(フォーム)の送信状態を確認することができます。

// components/Button.tsx

"use client"; 
// クライアントサイドのインタラクティブな機能を使用するために必要なディレクティブです。
// このディレクティブがないと、useFormStatus などのクライアントサイドフックが使用できません。

import { useFormStatus } from "react-dom";

interface ButtonProps {
  text: string;
}

export default function Button({ text }: ButtonProps) {
// form component に渡された関数が pending 状態かどうかを確認
  const { pending } = useFormStatus();
  return (
    <button
      disabled={pending} // ボタンがクリックされた際に、pending が true になり、ボタンが無効になる。
      className="bg-blue-500 w-full text-white h-10 disabled:bg-blue-900 disabled:text-neutral-300 disabled:cursor-not-allowed"
    >
      {pending ? "loading.." : text}
    </button>
  );
}

image

上記の方法を採用することで、リクエストが完了するまでボタンが無効になり、複数回リクエストが送信されるのを防ぐことができます。

従来の方法では、useMutation を実装したり、fetch やstate の管理を行う必要がありましたが、Server ActionsとuseFormStatusを使用することで、よりシンプルにフォーム処理を実装することが可能になります。

Server Actions のレスポンスを UI に渡す

最後に、Server Actions で処理した結果を UI に渡す方法を紹介します。
サンプルコードのように、ログイン処理後に redirect を使ってホームページに遷移させることができますが、エラーが発生した場合には、そのエラーを UI に表示する必要があります。

Server Actions の関数(handleSubmit)の処理結果を UI に渡すには、useActionState フックを使用します。

ご参考:React 公式ドキュメント - useActionState

async function handleSubmit(formData: FormData) {
    "use server";
    // loading 検証のため、3秒待機
    await new Promise((resolve) => setTimeout(resolve, 3000));
    console.log(formData.get("email"), formData.get("password"));
    redirect("/home");
  }

useActionState はクライアント側でのみ使用可能ですので、Server Actions の関数を別のファイルに分離する必要があります。

以下のように、Server Actions の関数を別のファイルで作成します。

// src/app/login/action.ts

"use server";

// prev : useActinStatus から 既存に存在する state を取得するので必須です。
// ※ パスワードが正しくないことを想定したサンプルコードになっています。
export async function handleSubmit(prev: any, formData: FormData) {
  await new Promise((resolve) => setTimeout(resolve, 3000));
  return {
    errors: ["not valid password"],
  };
}

次は login/page.tsx を以下のように変更します。

"use client";

import { useActionState } from "react";
import Button from "../components/Button";
import InputForm from "../components/InputForm";
import { handleSubmit } from "./actions";

export default function Login() {
  // useActionState: 第1引数に Server Actions の関数を指定し、第2引数に初期値を指定します。useState と同じような使い方です。

  // state: 初期 state は { errors: [] } としています。action によって返される値は、state に格納されます。
  // action: Server Actions の関数を指定します。form の action に渡すことで、Server Actions の関数が実行されます。
  const [state, action] = useActionState(handleSubmit, { errors: [] });

  return (
    <div className="flex flex-col w-full  py-8 px-6">
      <div>
        <h2 className="text-2xl">Server Actions Test</h2>
      </div>
      <form className="flex flex-col w-full gap-2" action={action}>
        <InputForm type="email" placeholder="Email" name="email" />
        <InputForm type="password" placeholder="Password" name="password" errors={state.errors}/>
        <Button text="Login"/>
      </form>
    </div>
  );
}
// src/app/components/InputForm.tsx

// エラーメッセージを表示するために、errors プロパティを追加しました。
interface FormInputProps {
  type: string;
  placeholder: string;
  name?: string;
  errors?: string[];
}

export default function InputForm({
  type,
  placeholder,
  name,
  errors,
}: FormInputProps) {
  return (
    <div className="flex flex-col gap-2">
      <input
        className="rounded-md w-full h-10 focus:outline-none border-none placeholder:text-gray-500 p-2 text-black"
        type={type}
        name={name}
        placeholder={placeholder}
      />
      {errors?.map((error, index) => (
        <span key={index} className="text-orange-500 font-medium">
          {error}
        </span>
      ))}
    </div>
  );
}

上記を実行すると以下のようにエラーメッセージが表示されます。

image

まとめ

今回は、Next.js で Server Actions を使ってフォームの処理を行う方法について紹介しました。

従来のフォーム実装方法と比較すると、以下のような利点があります:

実装方法 Route Handler Server Actions
クライアント側の実装 fetchuseStateuseEffect などが必要 フォームの action 属性のみ
ローディング処理 独自実装が必要 useFormStatus で簡単に実装可能
エラーハンドリング 独自実装が必要 useActionState で状態管理が容易
フォームのバリデーション クライアント・サーバー両方で実装が必要 サーバーサイドで一元管理可能

Server Actions を使用することで、従来の実装方法と比べて、より少ないコードでフォーム処理を実現することができます。このような利点により、React Hook Form などの外部ライブラリへの依存度が低減できるのではないかと考えられます。

最後までお読みいただき、ありがとうございました。

Discussion