# Turnstile を使った React Router フォームのスパム対策

> React Router のフォームをスパムから守る方法。Turnstile 付きのルート Action、cf-connecting-ip をキーにした Workers のレート制限、.server モジュール、CSP ヘッダー。

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

チェックはルートの `action` で行います。クライアント IP で制限し、siteverify で Turnstile のトークンを検証してから、ハニーポットと一緒にフィールドを転送します。ヘルパーは `.server.ts` モジュールに置いてブラウザーに届かないようにし、CSP はルートの `headers` エクスポートで設定します。Cloudflare Workers では、レート制限バインディングとシークレットは `cloudflare:workers` の `env` から取得します。

## React Router ならではの違い

フレームワークモードでは、`action` を持つすべてのルートが、自分のフォームからでも他人からでも POST を受け付けます。React Router には、スパムチェックの組み込み方を左右する 3 つの道具があります:

- **`.server` モジュール。** `spam.server.ts` という名前のファイルはクライアントのコードにバンドルできないので、コンポーネントが同じルートからインポートしても、シークレットと siteverify の呼び出しはサーバーにとどまります。
- **`headers` エクスポート。** Action が返すヘッダーは、ルートの `headers` 関数が引き渡さない限り、ドキュメントのレスポンスでは送られません。CSP もここに置きます。
- **`useFetcher`。** ナビゲーションなしで Action に送信するので、ページとウィジェットは試行の間もマウントされたままです。そのため、応答のたびに `key` でウィジェットをマウントし直します。

このページでは、レート制限バインディングが組み込まれていて React Router のデプロイ先としても一般的な Cloudflare Workers にデプロイします。Vercel と Netlify についてはデプロイの表で説明します。

図: JavaScript の有無で、送信が React Router の Action に届くまで

1. ハイドレーション後は、フォームが useFetcher で送信するので、ページは遷移しません。
2. Action が IP でレート制限し、Turnstile のトークンを検証して、フィールドを転送します。
3. 結果は fetcher.data として届きます。
4. ウィジェットの key が変わるので、次の試行に向けて新しいウィジェットと新しいトークンが用意されます。
5. ハイドレーション前は、同じフォームが Turnstile のトークンなしの通常のドキュメントリクエストとして送信されます。
6. Action は siteverify の段階でそれを拒否します。
7. headers エクスポートが、Retry-After と CSP をドキュメントのレスポンスに引き渡します。
8. コンポーネントは fetcher.data の代わりに actionData を読み、エラーを表示します。

人間確認には JavaScript が必要なので、JavaScript なしの経路は黙って通すのではなく、わかりやすいエラーで終わります。

## プロジェクトの準備

`@cloudflare/vite-plugin` を使う Cloudflare の React Router テンプレートから始めます。React Router 8 では `context` 引数は常に `RouterContextProvider` で、Worker のエントリーから素の `{ cloudflare: { env } }` オブジェクトを渡すと例外が発生します。そのため、エントリーはリクエストだけを渡し、ルートは `import { env } from "cloudflare:workers"` でバインディングを読みます。このページで編集するファイル:

`ファイル`:

```text
app/
  routes/
    contact.tsx
  components/
    turnstile.tsx
  lib/
    spam.server.ts
workers/
  app.ts
wrangler.jsonc
.dev.vars
```

`workers/app.ts`:

```ts
import { createRequestHandler } from "react-router";

const handler = createRequestHandler(() => import("virtual:react-router/server-build"), import.meta.env.MODE);

export default {
  fetch(request) {
    return handler(request);
  },
} satisfies ExportedHandler<Env>;
```

`wrangler.jsonc`:

```json
{
  "name": "acme-site",
  "main": "./workers/app.ts",
  "vars": { "TURNSTILE_SITE_KEY": "1x00000000000000000000AA" },
  "ratelimits": [
    {
      "name": "CONTACT_LIMIT",
      "namespace_id": "1001",
      "simple": { "limit": 5, "period": 60 }
    }
  ]
}
```

`.dev.vars`:

```bash
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
```

- サイトキーは公開情報なので `vars` に置きます。ローダーがコンポーネントに渡すので、クライアントのビルドに環境変数は一切要りません。
- シークレットはローカルでは `.dev.vars` に、本番では `wrangler secret put` で設定し、決して `wrangler.jsonc` には書かないでください。
- 設定を編集したら `wrangler types` を実行して、`env` 上の `CONTACT_LIMIT` とシークレットに型が付くようにします。

## サーバー専用のヘルパー

`app/lib/spam.server.ts`:

```ts
export async function isHuman(secret: string, token: string, ip: string, host: string) {
  if (!token || token.length > 2048) return false;
  const body = new URLSearchParams({ secret, response: token, remoteip: ip });
  try {
    const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { method: "POST", body });
    const verdict = (await response.json()) as { success: boolean; hostname?: string; action?: string };
    return verdict.success && verdict.action === "contact" && (import.meta.env.DEV || verdict.hostname === host);
  } catch {
    return false;
  }
}

export async function forward(secretKey: string, form: FormData, origin: string | null) {
  const headers = new Headers({ Accept: "application/json", Authorization: `Bearer ${secretKey}` });
  if (origin) headers.set("Origin", origin);
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", { method: "POST", headers, body: form }).catch(() => null);
  return response?.ok ?? false;
}
```

- `.server` の接尾辞により、クライアントのコードがこのモジュールを含むことになるとビルドが失敗します。
- siteverify はフォームエンコードのボディを受け取ります。ホスト名のチェックは訪問者が送信したホストと比較し、開発中はスキップします。テスト用のトークンにはダミーのホスト名が入っているためです。
- `forward` は元の `FormData` を送るので、ハニーポットを含むすべてのフィールドが送信されたとおりに Ikiform に届きます。
- 訪問者の `Origin` も渡します。サーバー側の fetch はこれを送らず、Ikiform は無料の `localhost` テストを判定するためにこれを読むからです。

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

## ウィジェット

`app/components/turnstile.tsx`:

```tsx
import { useEffect, useRef } from "react";

const SCRIPT = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";

interface TurnstileApi {
  render(container: HTMLElement, options: { sitekey: string; action: string }): string;
  remove(widgetId: string): void;
}

declare global {
  interface Window {
    turnstile?: TurnstileApi;
  }
}

export function Turnstile({ siteKey }: { siteKey: string }) {
  const box = useRef<HTMLDivElement>(null);

  useEffect(() => {
    let widgetId: string | undefined;
    const mount = () => {
      if (box.current && window.turnstile) widgetId = window.turnstile.render(box.current, { sitekey: siteKey, action: "contact" });
    };
    if (window.turnstile) mount();
    else document.querySelector(`script[src="${SCRIPT}"]`)?.addEventListener("load", mount, { once: true });
    return () => {
      if (widgetId) window.turnstile?.remove(widgetId);
    };
  }, [siteKey]);

  return (
    <>
      <script src={SCRIPT} async />
      <div ref={box} />
    </>
  );
}
```

React 19 は `async` の `<script>` を head に移動し、コンポーネントが何度レンダリングされても 1 回だけ読み込みます。エフェクトでは、Cloudflare がシングルページアプリに推奨する明示的なレンダリングでウィジェットを描画し、アンマウント時に削除します。

## ルート

`app/routes/contact.tsx`:

```tsx
import { useEffect, useState } from "react";
import { data, useFetcher } from "react-router";
import { env } from "cloudflare:workers";
import { Turnstile } from "~/components/turnstile";
import { forward, isHuman } from "~/lib/spam.server";
import type { Route } from "./+types/contact";

const POLICY = [
  "default-src 'self'",
  "script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com",
  "frame-src https://challenges.cloudflare.com",
  "object-src 'none'",
  "base-uri 'self'",
].join("; ");

export function loader() {
  return { siteKey: env.TURNSTILE_SITE_KEY };
}

export async function action({ request }: Route.ActionArgs) {
  const ip = request.headers.get("cf-connecting-ip") ?? "unknown";

  const { success } = await env.CONTACT_LIMIT.limit({ key: `contact:${ip}` });
  if (!success) {
    return data({ ok: false, error: "Too many messages. Try again in a minute." }, { status: 429, headers: { "Retry-After": "60" } });
  }

  const form = await request.formData();
  const token = String(form.get("cf-turnstile-response") ?? "");
  if (!(await isHuman(env.TURNSTILE_SECRET_KEY, token, ip, new URL(request.url).hostname))) {
    return data({ ok: false, error: "We couldn't confirm you're human. Try again." }, { status: 403 });
  }

  form.delete("cf-turnstile-response");
  const sent = await forward(env.IKIFORM_SECRET_KEY, form, request.headers.get("origin"));
  return sent ? { ok: true, error: "" } : data({ ok: false, error: "Your message didn't send. Try again." }, { status: 502 });
}

export function headers({ actionHeaders, parentHeaders }: Route.HeadersArgs) {
  parentHeaders.set("Content-Security-Policy", POLICY);
  const retry = actionHeaders.get("Retry-After");
  if (retry) parentHeaders.set("Retry-After", retry);
  return parentHeaders;
}

export default function Contact({ loaderData, actionData }: Route.ComponentProps) {
  const fetcher = useFetcher<typeof action>();
  const result = fetcher.data ?? actionData;
  const [round, setRound] = useState(0);

  useEffect(() => {
    if (fetcher.state === "idle" && fetcher.data) setRound((count) => count + 1);
  }, [fetcher.state, fetcher.data]);

  if (result?.ok) return <p role="status">Thanks. We'll reply by email.</p>;

  return (
    <fetcher.Form method="post">
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required maxLength={120} />
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={5} required minLength={10} />
      <div className="hp" aria-hidden="true">
        <label htmlFor="hp-field">Leave this empty</label>
        <input id="hp-field" name="_gotcha" type="text" tabIndex={-1} autoComplete="off" />
      </div>
      <Turnstile key={round} siteKey={loaderData.siteKey} />
      {result?.error ? <p role="alert">{result.error}</p> : null}
      <button disabled={fetcher.state !== "idle"}>{fetcher.state === "idle" ? "Send message" : "Sending"}</button>
    </fetcher.Form>
  );
}
```

- 制限は `request.formData()` より前に実行されるので、大量送信でボディの解析や siteverify の呼び出しのコストがかかることはありません。
- `cf-connecting-ip` は Worker へのすべてのリクエストで Cloudflare が設定し、訪問者が設定することはできません。
- `data()` はステータスと `Retry-After` を設定します。fetcher はボディを直接読みますが、それらのヘッダーをドキュメントのレスポンスに載せるのは `headers` エクスポートです。
- `result` は `actionData` にフォールバックするので、ハイドレーション前にフォームが送信された場合でもエラーが表示されます。
- `.hp` クラスは、[ハニーポットのガイド](https://ikiform.com/ja/guides/spam-protection/honeypot.md) と同じようにハニーポットを画面外に隠します。

React Router はハイドレーション用にインラインスクリプトを出力するので、このポリシーでは `script-src` に `'unsafe-inline'` を残しています。外すには `entry.server.tsx` で nonce を生成し、`<Scripts>` と Turnstile のスクリプトに渡します。Cloudflare によると、Turnstile は読み込むものに nonce を引き継ぎます。

## ローカルでテストする

Cloudflare のドキュメントによると、Vite プラグインは開発中もサーバーのコードを Workers ランタイムで実行するので、`cloudflare:workers` の `env`、`.dev.vars`、レート制限バインディングは本番と同じように動きます。ローカルでは `cf-connecting-ip` を設定する Cloudflare のエッジがないので、すべてのリクエストが `unknown` として 1 つの制限を共有します。これは想定どおりです。本物のヘッダーを確認するには、`wrangler versions upload` でプレビューをデプロイし、2 つのネットワークからテストしてください。

## 他の場所にデプロイする

| ホスト | クライアント IP | 制限の仕組み | シークレット |
| --- | --- | --- | --- |
| Cloudflare Workers | `cf-connecting-ip` | レート制限バインディング | `wrangler secret put` |
| Vercel | Vercel が上書きする `x-forwarded-for` | Redis | `process.env` |
| Netlify | `x-nf-client-connection-ip` | Redis | `process.env` |

Cloudflare はこのバインディングを、概算でロケーションごとにカウントするものと説明しています。お問い合わせフォームならそれで十分です。Workers 以外には `cloudflare:workers` モジュールがないので、シークレットは `process.env` から読み、バインディングは[レート制限のページ](https://ikiform.com/ja/guides/spam-protection/rate-limiting.md)にあるような Redis のカウンターに置き換えてください。

## チェックを Ikiform に任せる

1 つのフォームのためにキー、バインディング、CSP を運用するのが大げさなら、通常のフォームの送信先を Ikiform のエンドポイントにして、Action を削除します:

`app/routes/contact.tsx`:

```tsx
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <label htmlFor="email">Email</label>
  <input id="email" name="email" type="email" autoComplete="email" required />
  <label htmlFor="message">Message</label>
  <textarea id="message" name="message" rows={5} required />
  <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
  <button>Send message</button>
</form>
```

React Router の `<Form>` ではなく小文字の `<form>` を使うと、ブラウザーが直接 Ikiform に送信します。**Spam check** を **Ikiform** に設定すると、トークンのない送信には ikiform.com 上で Turnstile を実行してフィールドを送り届ける短い確認ページが表示されます。**Rate limit** で訪問者ごとに 10 分あたり 2 回の Strict を選び、**Allowed domains** にドメインを登録します。捕まえたスパムには本物のメッセージと同じ応答が返り、30 日間保持されます。自分の Action を残す方法は[サーバー設定ガイド](https://ikiform.com/ja/docs/guides/server.md)にあります。

## 動作を確認する

1. `npm run dev` を実行し、テスト用のキーでメッセージを送ります。**Submissions** に表示されます。プランのないワークスペースでも、`localhost` からのテストを月 50 件まで受け付けます。
2. 再読み込みせずに 2 通目のメッセージを送ります。1 通目の応答の後に新しいウィジェットが表示され、2 回目の送信も成功します。
3. JavaScript を無効にして送信します。ページが再読み込みされ、人間確認のエラーが表示されます。
4. デプロイ後に 1 分間で 6 回送信します。6 回目には `Retry-After: 60` 付きの `429` が返ります。
5. ハニーポットを `_gotcha=bot` と埋めて送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。

## ハマりどころ

| 問題 | 対処 |
| --- | --- |
| `Server-only module referenced by client` | コンポーネントが、描画するものを `spam.server.ts` からインポートしています。これを使えるのは `loader` と `action` だけです。 |
| 2 回目の試行が必ず siteverify で失敗する | ウィジェットが使用済みのトークンを保持しています。fetcher が応答するたびに `key` が変わるようにしてください。 |
| `Invalid context value provided to handleRequest` | Worker のエントリーが、React Router 7 のテンプレートのように `{ cloudflare: { env, ctx } }` をまだ渡しています。リクエストだけでハンドラーを呼び、バインディングは `cloudflare:workers` から読んでください。 |
| JavaScript なしのレスポンスに `Retry-After` がない | Action のヘッダーは、`headers` エクスポートが返さない限り破棄されます。例のように `actionHeaders` からコピーしてください。 |

## よくある質問

### React Router の Action に Turnstile を追加するには？

ルートのコンポーネントでウィジェットを描画し、Action でフォームデータから cf-turnstile-response を読み、シークレットキーと一緒に siteverify に送ります。応答が成功で、ウィジェットに設定した action と一致する場合にだけ処理を続けてください。

### Cloudflare Workers で React Router の Action にレート制限をかけるには？

wrangler.jsonc でレート制限バインディングを宣言し、Action の冒頭で cf-connecting-ip ヘッダーから作ったキーで limit を呼びます。失敗したら data() で 429 を返します。

### Action のヘッダーがブラウザーに届かないのはなぜですか？

ドキュメントリクエストでは、React Router はルートの headers エクスポートが返すヘッダーだけを送ります。そこで actionHeaders を読み、Retry-After など必要なものをコピーしてください。

### Ikiform は無料ですか？

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

## その他のガイド

- [お問い合わせフォームのスパムを止める](https://ikiform.com/ja/guides/spam-protection.md): 本物の訪問者に負担をかけない層でお問い合わせフォームのスパムを止めます。ハニーポット、時間、内容のスコア、レート制限、必要なときだけの人間確認です。
- [Cloudflare Turnstile](https://ikiform.com/ja/guides/spam-protection/cloudflare-turnstile.md): プレーンな HTML フォームに Cloudflare Turnstile を追加します。ウィジェット、サーバー側の siteverify、テスト用キー、自前のキーやサーバーなしで動かす方法まで。
- [レート制限](https://ikiform.com/ja/guides/spam-protection/rate-limiting.md): 共有ストアで訪問者ごとにフォーム送信のレート制限をかけ、Retry-After 付きの 429 を返し、1 つの IP を共有するオフィスを締め出さないようにします。
- [ハニーポットフィールド](https://ikiform.com/ja/guides/spam-protection/honeypot.md): スクリーンリーダー、キーボード、自動入力の邪魔をせずにボットを捕まえるハニーポットを、各スタックのサーバー側チェックとあわせてどんなフォームにも追加します。
- [CAPTCHA なしで](https://ikiform.com/ja/guides/spam-protection/without-captcha.md): CAPTCHA をまったく使わずにフォームスパムの大半を防ぐ方法。ハニーポット、署名付きの表示時刻、内容のスコア、レート制限を単独ではなく合算して使います。
- [reCAPTCHA の代替](https://ikiform.com/ja/guides/spam-protection/recaptcha-alternatives.md): Turnstile や hCaptcha から Proof of Work、CAPTCHA なしまで、フォーム向け reCAPTCHA の代替を比較し、小さく安全な変更で乗り換えます。
