# React Router でウェイトリストを作る

> 全ページのフッターに置く React Router のウェイトリスト。fetcher が 1 つのルート Action に送信し、登録ごとにページを記録し、JavaScript なしでも動きます。

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

ウェイトリスト専用のルートを作り、メールアドレスを検証してシークレットキーでフォームバックエンドに転送する `action` を持たせます。フォームは `useFetcher` でレイアウトに描画するので、どのページから登録してもそのページにとどまり、パスが記録されます。JavaScript がなければ同じフォームがウェイトリストのルートに送信され、そのページに結果が表示されます。

[メインガイド](https://ikiform.com/ja/guides/waitlist.md#form)の React Router のタブでは、フォームとその `action` をホームのルートに置いています。1 ページのサイトならそれで十分です。ただ、ローンチ前の SaaS サイトの多くは料金、ブログ記事、ホームなどいくつかのページを持ち、そのすべてに登録フォームを置いて、どのページが効いたかを知りたいものです。このページでは、フォームをレイアウトに置き、すべてのコピーを 1 つの Action に送信します。

## React Router ならではの違い

フレームワークモードでは、Action はルートに属し、`<Form>` は自分が置かれたルートに送信します。レイアウトにあるフォームはすべてのルートに置かれているので、どのコンポーネントを選ぶかで送信時の動きが決まります:

|   | `<Form method="post">` | `<fetcher.Form action="/waitlist">` |
| --- | --- | --- |
| 送信先 | `action` を指定しなければ現在のルート | `/waitlist` ルートの Action |
| 送信後 | ナビゲーション。URL と履歴が変わる | ナビゲーションなし。訪問者はページにとどまる |
| 結果 | 実行されたルートの `actionData` | `fetcher.data`。同じ `key` のすべての fetcher で共有 |
| JavaScript なし | 通常の送信 | `/waitlist` への通常の送信。そのページが結果を表示 |

フッターの登録には fetcher が合います。リストに登録するために料金ページを離れたい人はいません。劣化も素直です。ハイドレーション前の `fetcher.Form` は `/waitlist` に送信する普通の `<form>` なので、そのルートには結果を表示する専用のページが必要です。

図: React Router の全ページのフッターからの登録は 1 つのルート Action を通る

1. レイアウトがすべてのページにウェイトリストフォームを描画し、ページのパスから作った hidden の ref を付けます。
2. JavaScript があれば、fetcher.Form がページを離れずに /waitlist に送信します。fetcher の key は共有されるので、ヘッダーの表示も同じ結果を受け取ります。
3. JavaScript がなければ、同じフォームが通常のナビゲーションとして /waitlist に送信します。
4. どちらの場合も、/waitlist の Action がメールアドレスを確認し、シークレットキーで登録を Ikiform に転送します。
5. fetcher は fetcher.data から結果を読みます。通常の送信は /waitlist のページに移動し、そこで actionData から結果を表示します。

Action は 1 つ、フォームはいくつでも。パスを見れば、どのページで登録されたかがわかります。

## プロジェクトを準備する

フォームとシークレットキーの作成は[サーバーガイド](https://ikiform.com/ja/docs/guides/server.md) で説明しています。このページで使うファイル:

`プロジェクト`:

```text
app/
  root.tsx
  routes.ts
  routes/home.tsx
  routes/pricing.tsx
  routes/waitlist.tsx
  components/waitlist-form.tsx
react-router.config.ts
.env
```

キーは `.env` に入れ、Action の中で `process.env` から読みます。ルートモジュールはブラウザー向けに分割されますが、`action` はサーバーでしか実行されないので、キーがクライアントに送られることはありません。コンポーネントには決してインポートしないでください。

`.env`:

```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` はサーバー専用の環境変数に置いてください。

Action は `email` と `ref` を送ります。フォームの **Fields** タブでフィールドを定義している場合は、両方を含める必要があります:

`フィールド`:

```json
{
  "version": 1,
  "title": "Waitlist",
  "fields": [
    { "id": "email", "type": "email", "label": "Email", "required": true, "placeholder": "you@company.com" },
    { "id": "ref", "type": "text", "label": "Source", "validation": { "max_length": 40 } }
  ]
}
```

## 作ってみる

### ルートを登録する

`app/routes.ts`:

```ts
import { index, type RouteConfig, route } from "@react-router/dev/routes";

export default [
  index("routes/home.tsx"),
  route("pricing", "routes/pricing.tsx"),
  route("waitlist", "routes/waitlist.tsx"),
] satisfies RouteConfig;
```

### ウェイトリストのルート

`app/routes/waitlist.tsx`:

```tsx
import { data } from "react-router";
import { WaitlistForm } from "../components/waitlist-form";
import type { Route } from "./+types/waitlist";

const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const SOURCE = /^[a-z0-9_-]{1,40}$/;

export async function action({ request }: Route.ActionArgs) {
  const form = await request.formData();
  const email = String(form.get("email") ?? "").trim();
  if (!EMAIL.test(email)) {
    return data({ ok: false, message: "Enter the email we should send your invite to.", email }, { status: 400 });
  }
  const ref = String(form.get("ref") ?? "");

  const 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: request.headers.get("origin") ?? "",
    },
    body: JSON.stringify({
      email,
      ref: SOURCE.test(ref) ? ref : "direct",
      _gotcha: String(form.get("_gotcha") ?? ""),
    }),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);

  const result = await response?.json().catch(() => null);
  if (result?.ok) return { ok: true, message: "You're on the list. We'll email you on launch day.", email };
  return data(
    {
      ok: false,
      message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "Couldn't join the list. Try again.",
      email,
    },
    { status: response?.status === 429 ? 429 : 400 },
  );
}

export default function Waitlist({ actionData }: Route.ComponentProps) {
  return (
    <main>
      <h1>Join the Acme waitlist</h1>
      <WaitlistForm initial={actionData} />
    </main>
  );
}
```

- `data()` はエラーのステータスを設定しつつ、例外として投げるレスポンスにはしないので、コンポーネントは引き続きメッセージを描画できます。
- どの応答にもメールアドレスを含めて返すので、エラーの後も入力欄に再表示できます。
- ブラウザーの `Origin` ヘッダーを引き継いで渡します。Ikiform は無料の `localhost` テスト送信をこのヘッダーで判定しますが、Action 自身の `fetch` はこれを送らないからです。
- `ref` は英小文字、数字、`-`、`_` からなる 40 文字以内で、定義の `max_length` と同じです。それ以外は `direct` として保存されます。
- デフォルトエクスポートは JavaScript なしの場合のフォールバックです。Action の結果とともに同じフォームを描画します。

### 共有のフォーム

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

```tsx
import { useFetcher, useLocation } from "react-router";

interface Outcome {
  ok: boolean;
  message: string;
  email: string;
}

export function sourceFor(pathname: string) {
  const slug = pathname.replace(/^\/+|\/+$/g, "").replace(/\//g, "-").toLowerCase();
  return (slug || "home").slice(0, 40);
}

export function WaitlistForm({ initial }: { initial?: Outcome }) {
  const fetcher = useFetcher<Outcome>({ key: "waitlist" });
  const { pathname } = useLocation();
  const outcome = fetcher.data ?? initial;
  const pending = fetcher.state !== "idle";

  if (outcome?.ok) return <p role="status">{outcome.message}</p>;

  return (
    <fetcher.Form method="post" action="/waitlist">
      <label>
        Email
        <input name="email" type="email" autoComplete="email" required defaultValue={outcome?.email} />
      </label>
      <input type="hidden" name="ref" value={sourceFor(pathname)} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      <p role="alert">{outcome?.message}</p>
      <button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
    </fetcher.Form>
  );
}

export function JoinedNote() {
  const fetcher = useFetcher<Outcome>({ key: "waitlist" });
  return fetcher.data?.ok ? <span>You're on the list</span> : <a href="#waitlist">Join the waitlist</a>;
}
```

- `key: "waitlist"` により、そのキーを持つ fetcher はすべて 1 つの共有 fetcher になります。ヘッダーの `JoinedNote` は同じ `fetcher.data` を読むので、フッターのフォームが成功した瞬間に表示が変わります。
- `sourceFor` は `/blog/launch` を `blog-launch` に、`/` を `home` に変換します。パスはサーバーでわかるので、プリレンダーしたページでも動きます。
- `fetcher.state` は、送信中は `submitting`、その後 React Router が再検証する間は `loading` になるので、ボタンは両方が終わるまで無効のままです。
- ラベルが入力欄を包んでいるので、同じページのヒーローとフッターのコピーが `id` を共有することはありません。

`app/root.tsx` の `Layout` で、`id="waitlist"` を付けたフッターの中に `<WaitlistForm />` を、ヘッダーに `<JoinedNote />` を描画します。ハニーポットも一緒に送られ、そのまま Ikiform に届きます。シークレットキー付きの送信は Ikiform の人間確認をスキップするので、このフォームでは **Spam check** をオンにしても意味はありません。

### すべての loader の再読み込みを省く

Action の後、React Router はページ上のすべてのルートのデータを再読み込みします。登録はそのどれも変えません。loader が遅い、またはコストのかかるルートには次を追加してください:

`app/routes/pricing.tsx`:

```ts
import type { ShouldRevalidateFunctionArgs } from "react-router";

export function shouldRevalidate({ formAction, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
  if (formAction === "/waitlist") return false;
  return defaultShouldRevalidate;
}
```

## レンダリングとデプロイ

マーケティングページは訪問者ごとに変わらないので、プリレンダーし、サーバーは Action のために残します:

`react-router.config.ts`:

```ts
import type { Config } from "@react-router/dev/config";

export default {
  ssr: true,
  prerender: ["/", "/pricing"],
} satisfies Config;
```

`ssr: true` のままにしてください。`ssr: false` では実行時のサーバーがなく、`action` をエクスポートするルートがあると React Router はビルドを失敗させます。`ssr: true` なら、`/` と `/pricing` はビルド時に HTML として書き出され、`/waitlist` は引き続きサーバーで動きます。

| ホスト | 追加するもの | キーの読み方 |
| --- | --- | --- |
| Vercel | `@vercel/react-router`。`react-router.config.ts` に `presets: [vercelPreset()]` を指定 | `process.env`。**Environment Variables** で設定 |
| Netlify | `@netlify/vite-plugin-react-router`。Vite のプラグインに `netlifyReactRouter()` として追加 | `process.env`。**Environment variables** で設定 |
| Cloudflare Workers | `@cloudflare/vite-plugin`。`reactRouter()` の前に `cloudflare()` として追加 | `cloudflare:workers` の `env.IKIFORM_SECRET_KEY`。`wrangler secret put` で設定 |

Cloudflare では `import { env } from "cloudflare:workers"` とし、`process.env` ではなくそこからキーを読みます。React Router 8 では Action の `context` は `RouterContextProvider` なので、v7 の `context.cloudflare.env` のパターンはもう使えません。Action の残りは同じです。

## 動作を確認する

1. `react-router dev` を実行し、`http://localhost:5173/pricing` を開いてフッターから登録します。フォームがお礼の一文に置き換わり、ヘッダーの表示も一緒に変わります。
2. ダッシュボードで **Submissions** を開きます。登録の `ref` は `pricing` になっています。
3. JavaScript をオフにしてホームページから登録します。お礼の一文がある `/waitlist` に移動し、登録の `ref` は `home` になっています。
4. フッターから `a@b` を送信します。入力欄の下にエラーが表示され、アドレスは入力されたまま残ります。
5. ハニーポットに値を入れて `_gotcha=bot` で送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。
6. `react-router dev` からの送信は `localhost` のオリジンを持つので、テスト送信として数えられます。Ikiform に無料プランはありません。テストは無料で、プランのないワークスペースは `localhost` からのテスト送信を月 50 件まで受け付けます。本番運用するには、月 $6 から 500 件の Pro を選ぶか、買い切りで 10,000 件 $49 または 50,000 件 $149 のパックを購入してください。パックのクレジットに有効期限はありません。[料金](https://ikiform.com/ja/pricing)を参照してください。

## ハマりどころ

| 問題 | 対処法 |
| --- | --- |
| JavaScript がオンなのに、フッターから登録すると `/waitlist` に移動する | フォームが `fetcher.Form` ではなく `<Form>` か素の `<form>` になっています。fetcher を使い、Action から `redirect()` を返さないでください。fetcher の Action からのリダイレクトはナビゲーションを起こします。 |
| `action` のエクスポートに関するエラーでビルドが失敗する | `react-router.config.ts` で `ssr` が `false` になっています。`true` にしてください。そうしないとウェイトリストを動かすサーバーがありません。 |
| すべての登録の `ref` が `waitlist` になる | 送信が `/waitlist` のページ自体から来ています。それはフォールバック用のページです。リンクは実際のページに向けてください。 |
| `unknown_field` 付きの `422` | フィールド定義に `ref` がありません。上の定義のように追加してください。 |
| `402 plan_required` | ワークスペースにプランがなく、送信が本番のサイトから来ています。送信データは失われず、保留されています。**Settings › Plan and usage** でプランを選ぶかパックを購入すると、保留中の送信データが届きます。 |

## よくある質問

### React Router のフォームをナビゲーションなしで送信するには？

useFetcher を使い、処理するルートの action を指定して fetcher.Form を描画します。訪問者はページにとどまり、結果は fetcher.data に届きます。ハイドレーション前は通常のフォームとして送信されます。

### プリレンダーした React Router のページでフォームは動きますか？

ssr が true なら動きます。プリレンダーしたページは HTML として配信され、そのフォームはサーバーで動く Action に送信されます。ssr を false にすると Action は使えません。

### React Router の登録がどのページから来たかを知るには？

上の sourceFor のように、ページのパスを hidden の ref 入力欄に入れます。パスはサーバーでもクライアントでもわかるので、プリレンダーしたページでもハイドレーション前でも動きます。

### Ikiform は無料ですか？

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

## その他のガイド

- [新しい SaaS のウェイトリスト](https://ikiform.com/ja/guides/waitlist.md): 自分のランディングページにローンチ前のウェイトリストを作ります。フォーム、スパム対策、重複、確認メール、ローンチ日のきれいなエクスポートまで扱います。
- [Next.js のウェイトリスト](https://ikiform.com/ja/guides/waitlist/nextjs.md): Next.js App Router のウェイトリスト。シークレットキーで登録を転送するサーバーアクション、useActionState、楽観的なサンクス表示、訪問者ごとの制限を扱います。
- [TanStack Start のウェイトリスト](https://ikiform.com/ja/guides/waitlist/tanstack-start.md): TanStack Start のウェイトリスト。1 つのサーバールートが通常のフォーム送信と fetch の両方に応え、validateSearch が流入元と結果を URL で運びます。
- [プレーンな HTML のウェイトリスト](https://ikiform.com/ja/guides/waitlist/html.md): ビルド不要でプレーンな HTML だけのウェイトリストページ。JavaScript がオフでも動き、読み込めばその場でお礼を表示し、必要なときはフォールバックします。
- [Product Hunt ローンチリスト](https://ikiform.com/ja/guides/waitlist/product-hunt.md): Product Hunt の数週間前からローンチリストを集め、登録をチャネルごとにタグ付けし、太平洋時間のリセットに合わせてローンチ日のメールを 2 回に分けて送ります。
- [選考付きのベータ登録](https://ikiform.com/ja/guides/waitlist/beta-signup.md): サーバーが必須を強制する選考質問付きのベータ応募フォームと、応募者を採点してリストから各コホートを選ぶスクリプトを紹介します。
