# React Router で見込み客を選別するリードフォームを作る

> React Router フレームワークモードのリードフォーム。署名付きのファーストタッチ Cookie を設定するルートのミドルウェア、検証するルートの action、日程調整ツールへのリダイレクトを組み合わせます。

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

最初のドキュメントリクエストで、ルート（root）のミドルウェアを使って UTM タグとリファラーを `createCookie` の署名付き Cookie に記録します。ルートの `action` でリードを検証し、流入元の情報と一緒にフォームバックエンドへ転送して、条件に合うリードには日程調整ツールへの `redirect()` を、それ以外にはフィールドエラー付きの `data()` を返します。`<Form>` は JavaScript があってもなくても送信できるので、同じ action で両方に対応できます。

[メインガイドの React Router タブ](https://ikiform.com/ja/guides/lead-capture.md#form)では、ルートの action からリードを転送し、お礼の一文を表示します。このページでは営業向けの版をフレームワークモードで作ります。サーバーでチェックする 3 つの選別用の質問、訪問者を連れてきた最初のキャンペーン、条件に合うリードのカレンダーへのリダイレクトです。

## React Router で変わること

React Router のデータ API は、素の HTML フォームを前提に設計されています。`<Form>` は JavaScript の有無にかかわらず同じようにルートの `action` へ送信し、action が返すもの（エラー付きの `data()` でも `redirect()` でも）はどちらの場合にも機能します。JavaScript がある場合、React Router は別オリジンへのリダイレクトをドキュメント全体の読み込みでたどります。サーバー側の処理は 1 回書くだけです。

この用途にはさらに 2 つの仕組みが合います。React Router 8 からデフォルトで有効なミドルウェアは、すべてのドキュメントリクエストで動くので、ルート（root）のルートでファーストタッチを記録できます。そして `createCookie` は値をシークレットで署名するので、訪問者が CRM に届く前に自分の流入元を書き換えることはできません。

図: 同じ React Router の action が、JavaScript の有無にかかわらずリードを処理する様子

1. 最初のドキュメントリクエストは、URL にキャンペーンのタグを、ヘッダーに外部のリファラーを含んでいます。
2. ルートのミドルウェアはページを描画させてから、流入元を入れた署名付きの Set-Cookie を追加します。
3. JavaScript があると、Form はデータリクエストで action に送信し、React Router はリダイレクトをページ全体の読み込みでたどります。
4. JavaScript がないと、ブラウザーがネイティブに送信し、303 を自分でたどります。
5. どちらの場合も、検証に失敗するとステータス 400 のデータが返り、ページは入力された値の横にエラーを表示します。

1 つの action に 2 つの送り方。ミドルウェアが動くのはドキュメントリクエストだけで、ファーストタッチが起きるのはそこです。

## プロジェクトの準備

React Router 8 のフレームワークモードのアプリから始めます。サーバー専用の変数を 2 つ追加します。Ikiform のシークレットキーと、Cookie の署名用のシークレットです。キーの作り方は[サーバーからの送信](https://ikiform.com/ja/docs/guides/server.md)にあります。

`ファイル`:

```text
app/
  root.tsx
  lead-source.server.ts
  routes.ts
  routes/
    contact-sales.tsx
    contact-sales.thanks.tsx
react-router.config.ts
.env
```

`.env`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
COOKIE_SECRET=a-long-random-string
```

次に、フォームの **Fields** タブでフィールドを追加します。Ikiform は同じチェックをもう一度行い、知らないキーは拒否します。

| フィールド ID | Ikiform での型 | 設定元 |
| --- | --- | --- |
| `name`, `email`, `company` | テキスト、メール、テキスト | 訪問者 |
| `company_size` | 選択：`1_10`、`11_50`、`51_200`、`201_1000`、`1000_plus` | 訪問者 |
| `budget` | 選択：`under_10k`、`10k_50k`、`50k_plus`、`unsure` | 訪問者 |
| `timeline` | 選択：`this_quarter`、`next_quarter`、`later`、`researching` | 訪問者 |
| `message` | テキストエリア、任意 | 訪問者 |
| `utm_source`, `utm_medium`, `utm_campaign`, `referrer`, `landing_page` | テキスト、任意 | サーバー、最初の訪問から |
| `fit` | 選択：`qualified`、`nurture` | サーバー、検証のあと |

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

## 署名付きのファーストタッチ Cookie

`app/lead-source.server.ts`:

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

export interface LeadSource {
  utm_source: string;
  utm_medium: string;
  utm_campaign: string;
  referrer: string;
  landing_page: string;
}

export const leadSource = createCookie("lead_source", {
  httpOnly: true,
  sameSite: "lax",
  path: "/",
  maxAge: 60 * 60 * 24 * 30,
  secrets: [process.env.COOKIE_SECRET ?? ""],
});

export async function firstTouch(request: Request): Promise<LeadSource | null> {
  const url = new URL(request.url);
  const referer = request.headers.get("referer") ?? "";
  const from = URL.canParse(referer) ? new URL(referer) : null;
  const tag = (key: string) => url.searchParams.get(key)?.slice(0, 100) ?? "";
  const source: LeadSource = {
    utm_source: tag("utm_source"),
    utm_medium: tag("utm_medium"),
    utm_campaign: tag("utm_campaign"),
    referrer: from && from.host !== url.host ? from.href.slice(0, 300) : "",
    landing_page: url.pathname.slice(0, 200),
  };
  const hasSignal = source.referrer || source.utm_source || source.utm_medium || source.utm_campaign;
  return hasSignal ? source : null;
}
```

`app/root.tsx`:

```tsx
import { leadSource, firstTouch } from "./lead-source.server";
import type { Route } from "./+types/root";

const recordFirstTouch: Route.MiddlewareFunction = async ({ request }, next) => {
  const response = await next();
  if (request.method !== "GET") return response;
  if (await leadSource.parse(request.headers.get("Cookie"))) return response;
  const source = await firstTouch(request);
  if (source) response.headers.append("Set-Cookie", await leadSource.serialize(source));
  return response;
};

export const middleware: Route.MiddlewareFunction[] = [recordFirstTouch];
```

- `.server.ts` の接尾辞で、このモジュールと `COOKIE_SECRET` をクライアントのバンドルから除外します。
- `leadSource.parse` は、Cookie がない場合も署名が一致しない場合も `null` を返すので、改ざんされた値は信用されず無視されます。
- ミドルウェアは先に `next()` を呼び、そのあとヘッダーを追加するだけです。ドキュメントには、サーバーのレスポンスではステータスを読んでヘッダーを設定し、ボディには触れないようにとあります。
- サーバーのミドルウェアがクライアント側のナビゲーションで動くのは、`.data` を取得するときだけです。それで問題ありません。タグは最初のドキュメントリクエストで届きます。

## ルートの action

`app/routes/contact-sales.tsx`:

```tsx
import { data, Form, redirect, useNavigation } from "react-router";
import { type LeadSource, leadSource } from "../lead-source.server";
import type { Route } from "./+types/contact-sales";

const CHOICES = {
  company_size: ["1_10", "11_50", "51_200", "201_1000", "1000_plus"],
  budget: ["under_10k", "10k_50k", "50k_plus", "unsure"],
  timeline: ["this_quarter", "next_quarter", "later", "researching"],
};

function validate(values: Record<string, string>) {
  const errors: Record<string, string> = {};
  if (!values.name) errors.name = "Tell us who to reply to.";
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email)) errors.email = "Enter a work email we can reply to.";
  if (!values.company) errors.company = "Which company is this for?";
  for (const [key, allowed] of Object.entries(CHOICES)) {
    if (!allowed.includes(values[key])) errors[key] = "Pick one of the options.";
  }
  if (values.message.length > 5000) errors.message = "Keep it under 5,000 characters.";
  return errors;
}

export async function action({ request }: Route.ActionArgs) {
  const form = await request.formData();
  const keys = ["name", "email", "company", "company_size", "budget", "timeline", "message", "_gotcha"];
  const values = Object.fromEntries(keys.map((key) => [key, String(form.get(key) ?? "").trim()]));
  const errors = validate(values);
  if (Object.keys(errors).length) return data({ errors, values }, { status: 400 });

  const source: Partial<LeadSource> = (await leadSource.parse(request.headers.get("Cookie"))) ?? {};
  const qualified =
    values.company_size !== "1_10" &&
    ["10k_50k", "50k_plus"].includes(values.budget) &&
    ["this_quarter", "next_quarter"].includes(values.timeline);
  const origin = request.headers.get("origin");

  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 ? { Origin: origin } : {}),
    },
    body: JSON.stringify({ ...source, ...values, fit: qualified ? "qualified" : "nurture" }),
  });
  const result = await response.json().catch(() => null);
  if (!result?.ok) {
    const fields: Record<string, string[]> = result?.error?.fields ?? {};
    const failed = Object.fromEntries(Object.entries(fields).map(([key, list]) => [key, list[0] ?? ""]));
    if (!Object.keys(failed).length) failed.form = "We couldn't send that. Email sales@example.com instead.";
    return data({ errors: failed, values }, { status: response.status });
  }

  if (!qualified || values._gotcha) return redirect("/contact-sales/thanks", 303);
  return redirect(`https://cal.com/acme/intro?${new URLSearchParams({ name: values.name, email: values.email })}`, 303);
}

export default function ContactSales({ actionData }: Route.ComponentProps) {
  const pending = useNavigation().state === "submitting";
  const errors: Record<string, string | undefined> = actionData?.errors ?? {};
  const values: Record<string, string | undefined> = actionData?.values ?? {};
  const error = (key: string) => (errors[key] ? <p role="alert">{errors[key]}</p> : null);

  return (
    <Form method="post">
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required defaultValue={values.name} />
      {error("name")}
      <label htmlFor="email">Work email</label>
      <input id="email" name="email" type="email" autoComplete="email" required defaultValue={values.email} />
      {error("email")}
      <label htmlFor="company">Company</label>
      <input id="company" name="company" autoComplete="organization" required defaultValue={values.company} />
      {error("company")}
      <label htmlFor="company_size">Company size</label>
      <select id="company_size" name="company_size" required defaultValue={values.company_size ?? ""}>
        <option value="" disabled>Choose one</option>
        <option value="1_10">1 to 10</option>
        <option value="11_50">11 to 50</option>
        <option value="51_200">51 to 200</option>
        <option value="201_1000">201 to 1,000</option>
        <option value="1000_plus">More than 1,000</option>
      </select>
      {error("company_size")}
      <label htmlFor="budget">Budget</label>
      <select id="budget" name="budget" required defaultValue={values.budget ?? ""}>
        <option value="" disabled>Choose one</option>
        <option value="under_10k">Under $10k</option>
        <option value="10k_50k">$10k to $50k</option>
        <option value="50k_plus">More than $50k</option>
        <option value="unsure">Not sure yet</option>
      </select>
      {error("budget")}
      <label htmlFor="timeline">When do you want to start?</label>
      <select id="timeline" name="timeline" required defaultValue={values.timeline ?? ""}>
        <option value="" disabled>Choose one</option>
        <option value="this_quarter">This quarter</option>
        <option value="next_quarter">Next quarter</option>
        <option value="later">Later this year</option>
        <option value="researching">Researching for now</option>
      </select>
      {error("timeline")}
      <label htmlFor="message">Anything we should know?</label>
      <textarea id="message" name="message" rows={4} defaultValue={values.message} />
      {error("message")}
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {error("form")}
      <button disabled={pending}>{pending ? "Sending" : "Talk to sales"}</button>
    </Form>
  );
}
```

- 検証は依存のない小さな関数です。すでに Zod を使っているなら差し替えてください。action の形は変わりません。
- `data({ errors, values }, { status: 400 })` は本物のエラーステータスを返します。これにより、送信が失敗したときに React Router がローダーを再検証することもなくなります。
- サーバー側の fetch は Origin を送らず、Ikiform は無料の `localhost` テストをそこから判別するので、ブラウザーの `Origin` ヘッダーを転送します。
- `redirect()` は絶対 URL を受け付けます。ドキュメントは、リダイレクト先に含まれるユーザー入力を検証するよう警告しています。ここで訪問者から来るのはクエリ文字列だけで、`URLSearchParams` がそれをエンコードします。
- `values` は `source` のあとに展開されますが、キーはすべて異なるので、Cookie がフォームのフィールドを上書きすることはありません。

### 送信中とエラーの UI

action の実行中は `useNavigation().state` が `submitting` になり、ボタンが無効になります。入力欄は `actionData.values` から `defaultValue` を読むので、ネイティブの送信がエラー付きで戻ってきても、訪問者が入力した内容はすべて表示されたままです。JavaScript があれば、フォームはまったくリセットされません。

### ハニーポットと人間確認

ハニーポットはほかの値と一緒に送られ、Ikiform は埋まったものをスパムとして保存し、action は送信者をお礼のルートへ送ります。シークレットキーを使うと Ikiform の人間確認はスキップされるので、必要なら [フォームに Turnstile を追加する](https://ikiform.com/ja/guides/spam-protection/cloudflare-turnstile.md)のように action で Turnstile のトークンを検証してください。

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

action には実行時のサーバーが必要です。`react-router.config.ts` で `ssr: false` にすると、`action` を持つルートはビルドで拒否されます。`prerender` に列挙したパスは静的ファイルになり、そのファイルを配信するホストではミドルウェアが動かないので、キャンペーンのランディングページはサーバーで描画したままにしてください。

| ホスト | 知っておくこと |
| --- | --- |
| Vercel | `react-router.config.ts` に `@vercel/react-router` のプリセットを追加します。両方のシークレットをプロジェクトの環境変数に設定します。 |
| Netlify | `@netlify/vite-plugin-react-router` を追加します。両方のシークレットをサイトの設定に設定します。 |
| Cloudflare | `@cloudflare/vite-plugin` を使います。React Router 8 では独自の Cloudflare 開発用プロキシが削除されました。両方のシークレットを `wrangler secret put` で保存し、Worker のテンプレートが env を公開する方法で読み込んでください。 |

## 動作を確認する

開発サーバーからの送信は `localhost` のオリジンを持ちます。Ikiform に無料プランはありません。テストは無料で、プランのないワークスペースは `localhost` からのテスト送信を月 50 件まで受け付けます。本番運用するには、月 $6 から 500 件の Pro を選ぶか、買い切りで 10,000 件 $49 または 50,000 件 $149 のパックを購入してください。パックのクレジットに有効期限はありません。[料金](https://ikiform.com/ja/pricing)を参照してください。

1. `react-router dev` を実行して `/?utm_source=test&utm_campaign=launch` を開き、開発者ツールで署名付きの `lead_source` Cookie を探します。
2. Cookie の値を手で書き換えてリードを送信します。署名が一致しなくなるので、UTM のフィールドは空で届きます。
3. JavaScript をオフにし、**Timeline** を空のまま送信します。ページがエラーと入力した値付きで再読み込みされます。
4. 条件に合うリードとして送信します。51〜200 人、$50k 超、来四半期。名前とメールアドレスが入力済みの状態で Cal.com に移動します。
5. ダッシュボードで **Submissions** を開き、`utm_campaign`、`referrer`、`fit` を確認します。
6. 通知の各受信者の受信トレイを確認します。それぞれにフィールド入りのメールが届き、フォームに `email` フィールドがあれば **Reply** で送信者に返信できます。

## ハマりどころ

| 問題 | 対処法 |
| --- | --- |
| `middleware` が効かない | React Router 7 では `future.v8_middleware` フラグの背後にありました。フラグをオンにするか、デフォルトで有効な 8 にアップグレードしてください。 |
| `action` functions prohibited でビルドが失敗する | `ssr: false` が設定されています。アプリの SSR をオンに戻してください。特定のパスのプリレンダーは SSR と併用できます。 |
| Cookie が保存されない | `COOKIE_SECRET` が空か、デプロイのたびに変わるので、署名がすべて失敗しています。固定の値を設定してください。 |
| `422 validation_failed` | 送信がフォームのフィールド定義に合っていません。`error.fields` に各フィールドと理由が示されます。入力欄の `name` をフィールド ID に合わせてください。 |
| `402 test_limit_reached` | ワークスペースにプランがなく、今月の無料の `localhost` テスト 50 件を使い切っています。プランを選ぶか、ダッシュボードの **Send test** を使ってください。 |

## よくある質問

### React Router の action から別のドメインにリダイレクトできますか？

はい。redirect() は絶対 URL を受け付けます。ネイティブのフォーム送信なら Location ヘッダーをブラウザー自身がたどり、JavaScript があれば React Router が別オリジンへドキュメント全体のナビゲーションを行います。

### React Router のミドルウェアから Cookie を設定するには？

next() を呼んでレスポンスを受け取り、createCookie の cookie.serialize() で作った Set-Cookie ヘッダーを追加します。ミドルウェアは root.tsx などのルートから Route.MiddlewareFunction[] としてエクスポートします。

### React Router のリードフォームは JavaScript なしでも動きますか？

はい。Form は通常の form 要素を描画し、action はネイティブの送信で動きます。エラー付きの data() や redirect() を返す動作も、JavaScript がある場合と同じです。

### Ikiform は無料ですか？

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

## その他のガイド

- [リード獲得フォーム](https://ikiform.com/ja/guides/lead-capture.md): 自分のサイトに置くリード獲得フォームです。返信先付きでメールを送り、チームチャットに通知し、CRM に流し込むので、見込み客がまだいるうちに返信できます。
- [TanStack Start のリードフォーム](https://ikiform.com/ja/guides/lead-capture/tanstack-start.md): TanStack Start のリードフォームです。ファーストタッチの UTM を記録するグローバルなリクエストミドルウェア、Zod を使うサーバー関数、日程調整ツールへの throw によるリダイレクトを組み合わせます。
- [SvelteKit のリードフォーム](https://ikiform.com/ja/guides/lead-capture/sveltekit.md): SvelteKit のリードフォーム。handle フックでファーストタッチの UTM を記録し、フォームアクションで Valibot による検証を行い、redirect() で条件に合うリードをカレンダーに送ります。
- [デモ依頼フォーム](https://ikiform.com/ja/guides/lead-capture/demo-request.md): リードを保存してから、名前とメールアドレスを入力済みの状態で訪問者を Cal.com や Calendly に送るデモ予約フォームです。
- [見積もり依頼フォーム](https://ikiform.com/ja/guides/lead-capture/quote-request.md): 資料をアップロードではなく共有リンクで受け取り、すべての依頼に期限を付け、リンクの多いリードがスパムにならないようにする見積もり依頼フォームです。
- [相談申し込みフォーム](https://ikiform.com/ja/guides/lead-capture/consultation-request.md): 会計士、弁護士、コーチ、クリニック向けの相談申し込みフォームです。連絡方法と時間を尋ね、デリケートな詳細は電話のときに回します。
