デザインについての学習メモブログ

Next.js 入門 #6:フォームとServer Actions — サーバー側で直接データを処理する

記事内に広告が含まれています。

Next.js 入門 #6:フォームとServer Actions — サーバー側で直接データを処理する

前回の記事ではRoute Handlerを使ってAPIエンドポイントを作り、フロントエンドから fetch() で呼び出しました。

処理の流れを振り返ると、こうなっていました。

Bash
フォーム(Client Component)
   fetch('/api/posts', { method: 'POST', ... })
     Route Handler(app/api/posts/route.ts)
       データを処理して返す

これはこれで正しい実装です。

ただ、

Next.jsにはもう一つのアプローチがあります。

Server Actions を使うと、APIエンドポイントを経由せずに、フォームからサーバー側の関数を直接呼び出せます。

Bash
フォーム(Client Component)
   Server Action(サーバー上の関数)を直接呼び出す
     データを処理する

中間のAPIが不要になるぶん、コードがすっきりします。

今回はこのServer Actionsをフォームと組み合わせて学びます。

Server Actionsの基本

Server Actionは "use server" ディレクティブを付けた非同期関数です。

定義の仕方は2通りあります。

方法①:ファイルの先頭に "use server" を書く

TypeScript
// actions/post.ts
"use server";

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

  console.log('サーバーで受け取ったデータ:', { title, body });

  // ここにデータベースへの保存処理などを書く
}

ファイル全体がServer Actionになります。

複数のServer Actionをまとめて管理したいときに便利です。

方法②:関数の中に "use server" を書く

TSX
// Server Component の中でインラインで定義する

export default function Page() {
  async function handleSubmit(formData: FormData) {
    "use server";
    const title = formData.get('title') as string;
    console.log('タイトル:', title);
  }

  return (
    <form action={handleSubmit}>
      <input name="title" />
      <button type="submit">送信</button>
    </form>
  );
}

小さなコンポーネントで使い捨てのServer Actionを書くときに向いています。

どちらの書き方でも動作は同じです。

この記事では管理しやすい方法①(専用ファイル)をメインで使っていきます。

フォームとServer Actionを繋げる

<form> タグの action 属性にServer Actionを渡すのが基本の繋ぎ方です。

Bash
my-first-nextjs/
├── actions/            app/ と同列のプロジェクトルート直下
   └── post.ts         Server Actionをまとめるファイル
├── app/
   └── posts/
       └── new/
           └── page.tsx   フォームページ
└── components/

TypeScript
// actions/post.ts
"use server";

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

  if (!title || !body) {
    return { error: 'タイトルと本文は必須です' };
  }

  // 実際のアプリではここでDBに保存する
  console.log('新しい記事:', { title, body });

  return { success: true };
}

⚠️ サーバーサイドで動くのでconsole.log()の結果は、ターミナルに表示されます。

TSX
// app/posts/new/page.tsx

import { createPost } from '@/actions/post';

export default function NewPostPage() {
  return (
    <main>
      <h1>新しい記事を投稿</h1>
      <form action={createPost} style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', maxWidth: '500px' }}>
        <input
          name="title"
          placeholder="タイトル"
          required
          style={{ padding: '0.5rem', border: '1px solid #ccc', borderRadius: '4px' }}
        />
        <textarea
          name="body"
          placeholder="本文"
          rows={4}
          required
          style={{ padding: '0.5rem', border: '1px solid #ccc', borderRadius: '4px' }}
        />
        <button
          type="submit"
          style={{ padding: '0.5rem 1rem', background: '#0070f3', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer' }}
        >
          投稿する
        </button>
      </form>
    </main>
  );
}

<form action={createPost}> と書くだけで、送信ボタンを押したときにServer Actionが呼ばれます。

前回のRoute Handler版と比べると、fetch() を書く必要がなく、コードがかなりスッキリしています。

useActionState でフィードバックを表示する

フォームを送信した後、「成功した」「エラーが起きた」という結果をユーザーに伝えたいですよね。

そのために使うのが useActionState フックです。

useActionState はReactのHooksなので、使うコンポーネントはClient Componentにする必要があります。

useActionStateは簡潔に「送信中かどうか」の真偽値を監視できたり、stateで通信結果の管理できます。

TSX
// components/CreatePostForm.tsx
"use client";

import { useActionState } from 'react';
import { createPost } from '@/actions/post';

type ActionResult = {
  error?: string;
  success?: boolean;
} | null;

export default function CreatePostForm() {
  const [state, formAction, isPending] = useActionState<ActionResult, FormData>(
    createPost,
    null
  );

  return (
    <form action={formAction} style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', maxWidth: '500px' }}>
      <h2>新しい記事を投稿</h2>

      {/* 成功メッセージ */}
      {state?.success && (
        <p style={{ color: 'green', padding: '0.5rem', background: '#f0fff4', borderRadius: '4px' }}>
          ✅ 記事を投稿しました!
        </p>
      )}

      {/* エラーメッセージ */}
      {state?.error && (
        <p style={{ color: 'red', padding: '0.5rem', background: '#fff5f5', borderRadius: '4px' }}>
{state.error}
        </p>
      )}

      <input
        name="title"
        placeholder="タイトル"
        style={{ padding: '0.5rem', border: '1px solid #ccc', borderRadius: '4px' }}
      />
      <textarea
        name="body"
        placeholder="本文"
        rows={4}
        style={{ padding: '0.5rem', border: '1px solid #ccc', borderRadius: '4px' }}
      />
      <button
        type="submit"
        disabled={isPending}
        style={{
          padding: '0.5rem 1rem',
          background: isPending ? '#aaa' : '#0070f3',
          color: '#fff',
          border: 'none',
          borderRadius: '4px',
          cursor: isPending ? 'not-allowed' : 'pointer',
        }}
      >
        {isPending ? '投稿中...' : '投稿する'}
      </button>
    </form>
  );
}

useActionState が返す3つの値を整理します。

変数内容
stateServer Actionが返した値(初期値は null
formAction<form action> に渡す関数
isPending送信中かどうかの真偽値

isPending を使ってボタンを disabled にすることで、二重送信も防げます。

Server Action側もuseActionStateに合わせて修正

useActionState を使う場合、Server Actionの引数に prevState(前回の状態)が加わります。

TypeScript
// actions/post.ts
"use server";

type ActionResult = {
  error?: string;
  success?: boolean;
} | null;

export async function createPost(
  prevState: ActionResult,
  formData: FormData
): Promise<ActionResult> {
  const title = formData.get('title') as string;
  const body = formData.get('body') as string;

  if (!title || !body) {
    return { error: 'タイトルと本文は必須です' };
  }

  // 実際のアプリではここでDBに保存する
  console.log('新しい記事:', { title, body });

  return { success: true };
}

第1引数に prevState を追加するのがポイントです。

今回は使いませんが、「前回のエラーをリセットする」といった処理で活用できます。

TSX
// app/posts/new/page.tsx
import CreatePostForm from "@/components/CreatePostForm";
export default function NewPostPage() {
  return (
    <main>
      <CreatePostForm />
    </main>
  );
}

useActionStateがよくわからなかった方は以下の記事を読んでみてください。

送信後にページを更新する — revalidatePath

Server Actionでデータを保存した後、ページに表示されているデータも最新の状態に更新したいことがあります。

そのために使うのが revalidatePath です。

TypeScript
// actions/post.ts
"use server";

import { revalidatePath } from 'next/cache';

type ActionResult = {
  error?: string;
  success?: boolean;
} | null;

export async function createPost(
  prevState: ActionResult,
  formData: FormData
): Promise<ActionResult> {
  const title = formData.get('title') as string;
  const body = formData.get('body') as string;

  if (!title || !body) {
    return { error: 'タイトルと本文は必須です' };
  }

  // DBへの保存処理(省略)
  console.log('新しい記事:', { title, body });

  // /posts ページのキャッシュを破棄して最新データを表示させる
  revalidatePath('/posts');

  return { success: true };
}

revalidatePath('/posts') を呼ぶと、/posts ページのサーバー側キャッシュが「古い」とマークされます。

ブラウザがリロードされるわけではなく、次に誰かが /posts にアクセスしたときに初めてサーバーが最新データを取得してHTMLを再生成します。

TypeScript
revalidatePath('/posts') を呼んだ瞬間
  → /posts のキャッシュが古いとマークされるブラウザは何も起きない

次に /posts にアクセスしたとき
キャッシュが古いのでサーバーが最新データを取得再生成
新しい内容が表示される

#4で学んだ revalidate: 60 の手動版だと考えるとわかりやすいです。

キャッシュを破棄するタイミング
next: { revalidate: 60 }60秒経過後、次のアクセス時
revalidatePath('/posts')呼び出した直後、次のアクセス時

実際の使用感としては、Server Actionの後に redirect('/posts') で一覧ページに飛ぶ流れが多いため、結果的に投稿直後に最新データが見えます。

「リロードされたように見える」のはそのためです。

送信後に別ページへリダイレクトする

投稿後に一覧ページへ飛ばしたい場合は redirect を使います。

TypeScript
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';

export async function createPost(
  prevState: ActionResult,
  formData: FormData
): Promise<ActionResult> {
  const title = formData.get('title') as string;
  const body = formData.get('body') as string;

  if (!title || !body) {
    return { error: 'タイトルと本文は必須です' };
  }

  // DBへの保存処理(省略)

  revalidatePath('/posts');
  redirect('/posts'); // 一覧ページへリダイレクト
}

Route Handler vs Server Actions — どちらを使う?

#5と今回の記事で2種類のアプローチを学びました。

使い分けの基準を整理しておきましょう。

状況使うべき方法
Next.jsアプリ内のフォームからサーバー処理を呼ぶServer Actions
外部サービス(モバイルアプリなど)からも使うAPIが必要Route Handler
ブラウザから直接URLでアクセスできるAPIが欲しいRoute Handler
ページのデータを更新・再取得したいServer ActionsrevalidatePath

シンプルに言うと、Next.jsの中だけで完結するならServer Actions、外部から呼ばれる可能性があるならRoute Handler、と覚えておけば大抵の判断ができます。

ここまでのフォルダ構成

Bash
my-first-nextjs/
├── actions/                       app/ と同列のルート直下
   └── post.ts                    Server Actions("use server")
├── app/
   └── posts/
       └── new/
           └── page.tsx           フォームページ
└── components/
    └── CreatePostForm.tsx          Client Component(useActionState)

まとめ

この記事で学んだこと:

  • Server Actions"use server" を付けた非同期関数。APIエンドポイントを経由せずサーバー処理を呼べる
  • <form action={serverAction}> で繋げるのが基本形
  • useActionState を使うと、成功・エラーのフィードバックや送信中の状態(isPending)を管理できる
  • revalidatePath でデータ更新後にページのキャッシュを破棄できる
  • redirect で送信後に別ページへ飛ばせる
  • Route HandlerとServer Actionsは「外部からも呼ぶか」で使い分ける

次の記事ではTailwind CSSでスタイルを整え、メタデータAPIでSEO対策をする方法を学びます。ここまで機能優先で進めてきたサイトを、見た目も整った完成形に近づけましょう。