# サーバーアクションで Next.js のウェイトリストを作る方法

> Next.js App Router のウェイトリスト。シークレットキーで登録を転送するサーバーアクション、useActionState、楽観的なサンクス表示、訪問者ごとの制限を扱います。

公開日 2026-10-25 · https://ikiform.com/ja/guides/waitlist/nextjs

メールアドレスを確認し、訪問者ごとの制限をかけ、シークレットキーで登録をフォームバックエンドに転送するサーバーアクションを書きます。エラーが状態として返るよう `useActionState` から呼び出し、`useOptimistic` で包めば、ボタンを押した瞬間にサンクスメッセージが表示され、送信に失敗したら元に戻ります。

[メインのガイド](https://ikiform.com/ja/guides/waitlist.md#form)には、サーバーアクションと `useActionState` を使った短い Next.js のタブがあります。静かなランディングページならそれで十分です。このページでは、これから広く共有するウェイトリスト向けにもう一歩進めます。即座のサンクス表示、正直なエラーメッセージ、そしてサーバー側からの送信では失われてしまう訪問者ごとの制限です。

## サーバーアクションで変わること

サーバーアクションを使うと、シークレットキーを自分のサーバーに置いたまま、アプリから何かを送る前にメールアドレスを確認できます。また、すべての送信が自分のサーバーの IP アドレスから行われるようになり、それには 3 つの影響があります:

|   | ブラウザーから Ikiform に送信 | シークレットキーを使うサーバーアクション |
| --- | --- | --- |
| Ikiform から見える送信元 | 各訪問者の IP | すべての訪問者について自分のサーバーの IP |
| 訪問者ごとのレート制限 | Ikiform の制限、IP ごと | 自分で追加（下記参照） |
| 許可ドメインと人間確認 | 実行される | スキップされる。キーが自分の送信であることを証明するため |
| 呼び出せる人 | 許可ドメインからなら誰でも | ページに POST できる人なら誰でも |

最後の行が重要です。Next.js はサーバーアクションを公開エンドポイントとして扱います。自分のフォームだけでなく、細工した POST を送れば誰でも呼び出せます。シークレットキーで Ikiform の送信元チェックがスキップされるので、関門はアクションだけです。そのため、アクション独自の制限が必要です。

図: Next.js のサーバーアクションを通る登録の流れ

1. 訪問者がボタンを押します。useOptimistic がすぐにフォームをサンクスメッセージに置き換えます。
2. サーバーアクションがメールアドレスを確認し、その訪問者の最近の試行回数を IP ごとに数えます。
3. 制限内であれば、シークレットキーを付けて登録を Ikiform に送信します。
4. 結果がアクションの状態になります。成功ならサンクスメッセージがそのまま残り、エラーならメッセージと入力済みのメールアドレス付きでフォームが戻ります。
5. 制限を超えている場合は何も送信せず、すぐにエラーの状態を返します。

楽観的な表示はアクションの実行中だけ続きます。画面に何を残すかはアクションの状態が決めます。

## 作ってみる

### シークレットキー

ダッシュボードで **API keys** を開き、`submissions:write` スコープを持つシークレットキーを作成して、サーバー専用の変数に保存します。`NEXT_PUBLIC_` を先頭に付けてはいけません。

`.env.local`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
```

> サーバー側のバージョンはシークレットキーを付けて送信するので、Ikiform は **Allowed domains** とその人間確認をスキップします。その場合、すべての送信がサーバーの IP から届くため、訪問者ごとに 1 分あたり 5 件という Standard の制限が全訪問者の合計に適用されてしまいます。サーバー経由で送信するフォームには Relaxed か Custom の[レート制限](https://ikiform.com/ja/docs/rate-limits.md)を選び、`IKIFORM_SECRET_KEY` はサーバー専用の環境変数に置いてください。

### 訪問者ごとの上限

IP ごとに 1 分あたり 5 件の登録は、人には十分な量で、スクリプトには役に立たない量です。このカウンターはサーバーのメモリに置かれます。`server-only` を付けると、クライアントコンポーネントがインポートしたときにビルドが失敗します。

`lib/rate-limit.ts`:

```ts
import "server-only";

const WINDOW_MS = 60_000;
const LIMIT = 5;
const hits = new Map<string, number[]>();

export function allow(key: string, now = Date.now()) {
  const recent = (hits.get(key) ?? []).filter((time) => now - time < WINDOW_MS);
  const allowed = recent.length < LIMIT;
  if (allowed) recent.push(now);
  if (hits.size > 10_000) hits.clear();
  hits.set(key, recent);
  return allowed;
}
```

- メモリはサーバーのインスタンス間で共有されません。単一の Node サーバーやコンテナなら正確です。サーバーレスのホスティングでは各インスタンスが別々に数えるので、減速帯程度と考え、トラフィックが増えたら Redis などの共有ストアにカウンターを移してください。
- 信頼するのは、ホストが設定する IP ヘッダーだけにしてください。Vercel は `x-forwarded-for` を設定し、Cloudflare の背後では `cf-connecting-ip` を読みます。訪問者が自分で設定できるヘッダーでは、まったく制限になりません。

### サーバーアクション

`app/waitlist/actions.ts`:

```ts
"use server";

import { headers } from "next/headers";
import { allow } from "@/lib/rate-limit";

export interface JoinState {
  status: "idle" | "joined" | "error";
  message: string;
  email?: string;
}

const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

export async function joinWaitlist(_previous: JoinState, formData: FormData): Promise<JoinState> {
  const email = String(formData.get("email") ?? "").trim();
  if (!EMAIL.test(email)) return { status: "error", message: "Enter the email we should send your invite to.", email };

  const incoming = await headers();
  const visitor = incoming.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
  if (!allow(visitor)) return { status: "error", message: "Too many tries from this connection. Wait a minute.", email };

  let response: Response;
  try {
    response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
        Origin: incoming.get("origin") ?? "",
      },
      body: JSON.stringify({
        email,
        ref: String(formData.get("ref") ?? "homepage").slice(0, 40),
        _gotcha: String(formData.get("_gotcha") ?? ""),
      }),
      signal: AbortSignal.timeout(8000),
    });
  } catch {
    return { status: "error", message: "We couldn't reach the list. Try again.", email };
  }

  if (response.status === 429) {
    const wait = response.headers.get("Retry-After") ?? "60";
    return { status: "error", message: `The list is busy. Try again in ${wait} seconds.`, email };
  }
  const result = await response.json().catch(() => null);
  if (result?.ok) return { status: "joined", message: "You're on the list. We'll email you on launch day." };
  return {
    status: "error",
    message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "Couldn't join the list. Try again.",
    email,
  };
}
```

- コストの低いチェックを先に実行します。不正なメールアドレスでリクエストが発生することはありません。
- `AbortSignal.timeout` によって、遅いネットワークのせいでボタンが送信中のまま止まり続けることを防ぎます。
- Ikiform の `429` は `Retry-After` とあわせて別に扱います。すべての訪問者が 1 つの IP の背後にいるので、混み合っているのはこの人ではなくフォーム全体だからです。
- どのエラーでもメールアドレスを返すので、入力欄にもう一度表示できます。React はアクションのあとにフォームをリセットしますが、`defaultValue` でアドレスを戻します。
- Ikiform は無料のテスト送信をブラウザーの `Origin` ヘッダーで数えるので、それを引き継ぎます。サーバー自身の `fetch` は何も送りません。
- SDK の `submit()` は `Authorization` ヘッダーを送らないので、許可ドメインをスキップできません。ここではキーを付けて `fetch` を使います。

### 楽観的なお礼表示を備えたフォーム

`app/waitlist/waitlist-form.tsx`:

```tsx
"use client";

import { useActionState, useOptimistic } from "react";
import { type JoinState, joinWaitlist } from "./actions";

const initial: JoinState = { status: "idle", message: "" };

export function WaitlistForm({ source }: { source: string }) {
  const [state, dispatch, pending] = useActionState(joinWaitlist, initial);
  const [shown, showJoined] = useOptimistic(
    state,
    (_current, email: string): JoinState => ({ status: "joined", message: `Adding ${email} to the list` }),
  );

  function join(formData: FormData) {
    showJoined(String(formData.get("email") ?? ""));
    dispatch(formData);
  }

  if (shown.status === "joined") {
    return (
      <p role="status" aria-busy={pending}>
        {shown.message}
      </p>
    );
  }

  return (
    <form action={join}>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required defaultValue={state.email} />
      <input type="hidden" name="ref" value={source} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {shown.status === "error" ? <p role="alert">{shown.message}</p> : null}
      <button disabled={pending}>Join the waitlist</button>
    </form>
  );
}
```

- `useOptimistic` はアクションの中で設定する必要があります。`join` はフォームのアクションなので、React はそれをトランジションの中で実行し、楽観的な表示は `dispatch` が終わるまで残ります。
- 成功すると、アクションの状態が `joined` になり、表示は最終的なメッセージに変わって残ります。エラーのときは楽観的な値が消え、`state.email` とメッセージ付きでフォームが戻ります。
- `aria-busy` は、表示がまだ確認中であることをスクリーンリーダーに伝えます。

> アクションを `join` で包むとクライアント関数になるので、このフォームには JavaScript が必要です。即座のお礼表示よりハイドレーション前に送信できることが大事なら、`dispatch` を直接 `action` に渡して `useOptimistic` を外してください。残りのコードは同じです。

### ページ

`app/waitlist/page.tsx`:

```tsx
import { WaitlistForm } from "./waitlist-form";

export default async function Page({ searchParams }: PageProps<"/waitlist">) {
  const { ref } = await searchParams;
  const source = typeof ref === "string" ? ref.slice(0, 40) : "homepage";

  return (
    <main>
      <h1>Acme is almost here</h1>
      <p>Leave your email and we'll send you an invite the day it opens.</p>
      <WaitlistForm source={source} />
    </main>
  );
}
```

ページは URL から `?ref=` を読んでフォームに渡すので、`/waitlist?ref=newsletter` のようなリンクで登録ごとに流入元のタグが付きます。`PageProps` は Next.js が生成するルート対応の型で、`searchParams` を読むとページはリクエストごとにレンダリングされます。

## 動作を確認する

1. `next dev` を実行し、`/waitlist?ref=test` を開いて登録します。リクエストが終わる前にお礼の表示が出ます。
2. ダッシュボードで **Submissions** を開きます。`ref` が `test` に設定された登録が入っています。
3. 1 分以内に 6 回送信します。6 回目には上限のメッセージが表示され、保存される送信データは 5 件だけです。
4. `IKIFORM_SECRET_KEY` に間違った値を設定して送信します。楽観的な表示が元に戻り、メールアドレスが入ったままのフォームにエラーが表示されます。
5. 通知の受信者それぞれの受信箱を確認します。各自にフィールドを載せたメールが届き、フォームに `email` フィールドがあれば **Reply** は送信者宛てになります。
6. ハニーポットに値を入れて `_gotcha=bot` で送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。

## ハマりどころ

| 問題 | 対処法 |
| --- | --- |
| Ikiform から `403 forbidden` | キーが別のワークスペースのもの、失効済み、または `submissions:write` がありません。そのスコープを持つシークレットキーを作成してください。 |
| `429 rate_limited` | 1 人の訪問者が、フォームのレート制限を超えて送信しました。Standard では訪問者ごとに 1 分あたり 5 件までです。`Retry-After` まで待つか、共有の 1 つの IP から送信するフォームには Relaxed か Custom を選んでください。 |
| すべての訪問者が一斉に上限に達する | IP ヘッダーがないため、全員が `unknown` として数えられています。Cloudflare の背後なら `cf-connecting-ip` など、ホストが設定するヘッダーを読んでください。 |
| お礼が一瞬表示され、メッセージなしでフォームが戻る | アクションが状態を返さずに例外を投げています。サンプルのように、失敗する経路はすべて `JoinState` を返すようにしてください。 |
| 楽観的な更新がトランジションの外で行われたという警告 | `showJoined` がイベントハンドラーから呼ばれています。`join` のように、フォームのアクションの中で呼んでください。 |

## よくある質問

### Next.js のウェイトリストは、ブラウザーから送信すべきですか、サーバーアクションから送信すべきですか？

どちらでも動きます。ブラウザーからの送信なら、Ikiform の訪問者ごとの制限、許可ドメイン、人間確認がそのまま使えます。サーバーアクションはエンドポイントを隠し、先に入力をチェックでき、ハイドレーション前でも動きますが、すべての送信が自分のサーバーから来るので、訪問者ごとの制限は自分で追加します。

### useOptimistic は useActionState と一緒に使えますか？

はい。フォームの action prop など同じアクションの中で、楽観的な値を設定し、useActionState の dispatch 関数を呼びます。楽観的な値はアクションの実行中に表示され、終わるとアクションの状態に置き換わります。

### Next.js のウェイトリストにデータベースは必要ですか？

いいえ。サーバーアクションが各登録を Ikiform に転送し、Ikiform が保存、スパムの除外、メール通知、Webhook の送信を行います。アプリが持つ状態はレート制限のカウンターだけです。

### Ikiform は無料ですか？

いいえ、無料プランはありません。テストは無料で、支払う前に localhost からテスト送信を月 50 件まで送れます。本番運用には、500 件で月 $6 からの Pro か、買い切りで 10,000 件 $49、または 50,000 件 $149 を選べます。クレジットに有効期限はありません。

## その他のガイド

- [新しい SaaS のウェイトリスト](https://ikiform.com/ja/guides/waitlist.md): 自分のランディングページにローンチ前のウェイトリストを作ります。フォーム、スパム対策、重複、確認メール、ローンチ日のきれいなエクスポートまで扱います。
- [プレーンな HTML のウェイトリスト](https://ikiform.com/ja/guides/waitlist/html.md): ビルド不要でプレーンな HTML だけのウェイトリストページ。JavaScript がオフでも動き、読み込めばその場でお礼を表示し、必要なときはフォールバックします。
- [選考付きのベータ登録](https://ikiform.com/ja/guides/waitlist/beta-signup.md): サーバーが必須を強制する選考質問付きのベータ応募フォームと、応募者を採点してリストから各コホートを選ぶスクリプトを紹介します。
- [Product Hunt ローンチリスト](https://ikiform.com/ja/guides/waitlist/product-hunt.md): Product Hunt の数週間前からローンチリストを集め、登録をチャネルごとにタグ付けし、太平洋時間のリセットに合わせてローンチ日のメールを 2 回に分けて送ります。
- [モバイルアプリのウェイトリスト](https://ikiform.com/ja/guides/waitlist/mobile-app.md): 使っているスマホを尋ね、iPhone のテスターを TestFlight に送り、Play のクローズドテストを埋める、iOS・Android アプリ向けの公開前ウェイトリストです。
- [Framer のウェイトリスト](https://ikiform.com/ja/guides/waitlist/framer.md): Framer でウェイトリストの登録を集める 2 つの方法。Ikiform への Webhook を使う Framer のフォームか、訪問者ごとの制限とスパムチェックが残るコードコンポーネントです。
