リード獲得フォーム

リード獲得フォーム3 分で読めます

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

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

Preet SutharIkiform 創業者

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

メインガイドの React Router タブでは、ルートの 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 の有無にかかわらずリードを処理する様子最初の訪問JavaScript ありJavaScript なしドキュメントの GET?utm_campaign=...ルートのミドルウェアawait next() のあとSet-Cookie署名付き Cookielead_source<Form>.data を取得action検証、送信、判定redirect()Cal.comをドキュメントで読み込み<form>ネイティブの POSTaction同じ関数303 Locationブラウザーがたどるページを再描画エラーと値createCookiePOST303POST303data(..., 400)同じ React Router の action が、JavaScript の有無にかかわらずリードを処理する様子最初の訪問JavaScript ありJavaScript なしドキュメントの GET?utm_campaign=...ルートのミドルウェアawait next() のあとSet-Cookie署名付き Cookielead_source<Form>.data を取得action検証、送信、判定redirect()Cal.comをドキュメントで読み込み<form>ネイティブの POSTaction同じ関数303 Locationブラウザーがたどるページを再描画エラーと値createCookiePOST303POST303data(..., 400)
  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 の署名用のシークレットです。キーの作り方はサーバーからの送信にあります。

ファイル
app/
  root.tsx
  lead-source.server.ts
  routes.ts
  routes/
    contact-sales.tsx
    contact-sales.thanks.tsx
react-router.config.ts
.env
.env
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
COOKIE_SECRET=a-long-random-string

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

フィールド IDIkiform での型設定元
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 のレート制限を選び、IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。

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

app/lead-source.server.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
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
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 を追加するのように action で Turnstile のトークンを検証してください。

レンダリングとデプロイ

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

ホスト知っておくこと
Vercelreact-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 のパックを購入してください。パックのクレジットに有効期限はありません。料金を参照してください。

  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 を使ってください。

よくある質問

手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com