リード獲得フォーム3 分で読めます
React Router で見込み客を選別するリードフォームを作る
React Router フレームワークモードのリードフォーム。署名付きのファーストタッチ Cookie を設定するルートのミドルウェア、検証するルートの action、日程調整ツールへのリダイレクトを組み合わせます。
最初のドキュメントリクエストで、ルート(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 に届く前に自分の流入元を書き換えることはできません。
- 最初のドキュメントリクエストは、URL にキャンペーンのタグを、ヘッダーに外部のリファラーを含んでいます。
- ルートのミドルウェアはページを描画させてから、流入元を入れた署名付きの Set-Cookie を追加します。
- JavaScript があると、Form はデータリクエストで action に送信し、React Router はリダイレクトをページ全体の読み込みでたどります。
- JavaScript がないと、ブラウザーがネイティブに送信し、303 を自分でたどります。
- どちらの場合も、検証に失敗するとステータス 400 のデータが返り、ページは入力された値の横にエラーを表示します。
プロジェクトの準備
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
.envIKIFORM_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_SECRET_KEY はサーバー専用の環境変数に置いてください。署名付きのファーストタッチ Cookie
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;
}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
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 に列挙したパスは静的ファイルになり、そのファイルを配信するホストではミドルウェアが動かないので、キャンペーンのランディングページはサーバーで描画したままにしてください。
| ホスト | 知っておくこと |
|---|---|
| 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 のパックを購入してください。パックのクレジットに有効期限はありません。料金を参照してください。
react-router devを実行して/?utm_source=test&utm_campaign=launchを開き、開発者ツールで署名付きのlead_sourceCookie を探します。- Cookie の値を手で書き換えてリードを送信します。署名が一致しなくなるので、UTM のフィールドは空で届きます。
- JavaScript をオフにし、Timeline を空のまま送信します。ページがエラーと入力した値付きで再読み込みされます。
- 条件に合うリードとして送信します。51〜200 人、$50k 超、来四半期。名前とメールアドレスが入力済みの状態で Cal.com に移動します。
- ダッシュボードで Submissions を開き、
utm_campaign、referrer、fitを確認します。 - 通知の各受信者の受信トレイを確認します。それぞれにフィールド入りのメールが届き、フォームに
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 を使ってください。 |
よくある質問
はい。redirect() は絶対 URL を受け付けます。ネイティブのフォーム送信なら Location ヘッダーをブラウザー自身がたどり、JavaScript があれば React Router が別オリジンへドキュメント全体のナビゲーションを行います。
next() を呼んでレスポンスを受け取り、createCookie の cookie.serialize() で作った Set-Cookie ヘッダーを追加します。ミドルウェアは root.tsx などのルートから Route.MiddlewareFunction[] としてエクスポートします。
はい。Form は通常の form 要素を描画し、action はネイティブの送信で動きます。エラー付きの data() や redirect() を返す動作も、JavaScript がある場合と同じです。
いいえ、無料プランはありません。テストは無料で、支払う前に localhost から月 50 件のテスト送信ができます。本番運用するには、月 $6 で 500 件からの Pro か、1 回払いで 10,000 件 $49 または 50,000 件 $149 のパックを選べます。クレジットに有効期限はありません。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com