リード獲得フォーム3 分で読めます
Next.js で見込み客を選別するリードフォームを作る
Next.js App Router のリードフォームです。サーバーアクションで Zod による検証を行い、ファーストタッチの UTM を Cookie に保持し、条件に合うリードをカレンダーに送ります。
最初の訪問時に proxy.ts でキャンペーンのタグとリファラーを取得し、Cookie に保持します。サーバーアクションで Zod を使ってリードを検証し、会社の規模、予算、時期で点数を付け、流入元の情報と一緒にフォームバックエンドへ転送します。そのあと redirect() を呼び、条件に合うリードは日程調整ツールへ、それ以外はサンクスページへ送ります。
メインガイドの Next.js タブでは、リードを転送してお礼の一文を表示します。このページでは営業向けの版を作ります。サーバーでチェックする 3 つの選別用の質問、訪問者がコンバージョンする前にサイト内を回ってもなくならないキャンペーンのデータ、そして商談する価値のあるリードのカレンダーへの直接の引き渡しです。
Next.js で変わること
仕事の大半は Next.js の 2 つの機能が担います。proxy.ts は Next.js 16 より前に middleware.ts と呼ばれていたファイルで、静的なページも含めてすべてのページリクエストの前に動きます。訪問者が Talk to sales にたどり着くころには ?utm_source= も Referer ヘッダーも URL から消えているので、最初の訪問でそれらを読むのに最適な場所です。そのあとサーバーアクションが Cookie を読むので、問い合わせページ自体は静的なままにできます。
2 つ目は redirect() です。サーバーアクションの中では絶対 URL を受け付けるので、条件に合うリードはクライアントのコードなしで Cal.com に移動します。JavaScript が読み込まれていれば Next.js はクライアントで遷移し、ハイドレーション前ならフォームの送信に 303 で応答します。
- 訪問者が UTM タグ付きのキャンペーンのリンクか、ほかのサイトのリンクから来ます。
- proxy.ts がタグ、リファラー、ランディングのパスを httpOnly の Cookie に 1 回だけ保存します。
- 料金ページと問い合わせページは静的なままで、キャッシュから配信されます。
- 送信時、サーバーアクションが Zod でフィールドを検証し、Cookie を読みます。
- リード、流入元の情報、計算した fit をシークレットキー付きで Ikiform に送信します。
- 条件に合うリードは名前とメールアドレスを入力済みにして日程調整ツールへリダイレクトし、それ以外はサンクスページへ送ります。
- 検証エラーは、訪問者が入力したすべての値と一緒にアクションの状態として戻ります。
プロジェクトの準備
Next.js 16 の任意の App Router プロジェクトから始めます。zod をインストールし、ダッシュボードでフォームを作り、API keys で submissions:write スコープのシークレットキーを作ります。キーとフォームの設定については Next.js のドキュメントページに詳しくあります。
app/
contact-sales/
page.tsx
actions.ts
lead-form.tsx
thanks/page.tsx
lib/
lead.ts
proxy.ts
.env.localIKIFORM_SECRET_KEY=ik_sk_your_secret_key次に、Ikiform でもチェックされるよう、フォームの Fields タブでフィールドを追加します。定義がなければ Ikiform は送られたものを何でも保存しますが、定義があれば未知のフィールドは 422 になります。ペイロードに流入元の情報が入ったら、それが望ましい動作です。
| フィールド 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 はサーバー専用の環境変数に置いてください。proxy.ts でのファーストタッチの記録
import { type NextRequest, NextResponse } from "next/server";
const TAGS = ["utm_source", "utm_medium", "utm_campaign"];
export function proxy(request: NextRequest) {
const response = NextResponse.next();
if (request.cookies.has("lead_source")) return response;
const url = request.nextUrl;
const referer = request.headers.get("referer") ?? "";
const from = URL.canParse(referer) ? new URL(referer) : null;
const external = from && from.host !== url.host ? from.href : "";
const source = new URLSearchParams();
for (const tag of TAGS) source.set(tag, url.searchParams.get(tag)?.slice(0, 100) ?? "");
if (!external && !TAGS.some((tag) => source.get(tag))) return response;
source.set("referrer", external.slice(0, 300));
source.set("landing_page", url.pathname.slice(0, 200));
response.cookies.set("lead_source", source.toString(), {
httpOnly: true,
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 30,
});
return response;
}
export const config = {
matcher: [
{
source: "/((?!api|_next/static|_next/image|favicon.ico).*)",
missing: [
{ type: "header", key: "next-router-prefetch" },
{ type: "header", key: "purpose", value: "prefetch" },
],
},
],
};- Cookie は 1 回だけ設定され、上書きされないので、最後のサイト内クリックではなく最初のキャンペーンが記録されます。ラストタッチがよければ、
hasのチェックを削除してください。 - 自分のホストからのリファラーは無視されます。そうしないと、最初以降のページビューがすべて自分自身からの紹介として数えられてしまいます。
- matcher は静的ファイルとルーターのプリフェッチを飛ばします。プリフェッチは自分のページをリファラーとして持つので、そうしないと画面上のリンクごとにプロキシが動いてしまいます。
URLSearchParamsを使えば Cookie の値がコンパクトになり、安全に解析できます。アクションは知っている 5 つのキーしか読まないので、Cookie を書き換えた訪問者がフィールドを追加することはできません。
検証、点数付け、引き渡し
スキーマは専用のモジュールに置き、アクションと将来の API ルートで共有できるようにします。fit のルールはあえて単純にしています。11 人以上、予算 $10k 以上、開始が 2 四半期以内です。
import { z } from "zod";
export const LeadSchema = z.object({
name: z.string().trim().min(1, "Tell us who to reply to.").max(120),
email: z.email("Enter a work email we can reply to."),
company: z.string().trim().min(1, "Which company is this for?").max(200),
company_size: z.enum(["1_10", "11_50", "51_200", "201_1000", "1000_plus"], { error: "Pick a company size." }),
budget: z.enum(["under_10k", "10k_50k", "50k_plus", "unsure"], { error: "Pick a budget range." }),
timeline: z.enum(["this_quarter", "next_quarter", "later", "researching"], { error: "Pick a timeline." }),
message: z.string().trim().max(5000).optional(),
});
export type Lead = z.infer<typeof LeadSchema>;
export function isQualified(lead: Lead) {
const bigEnough = lead.company_size !== "1_10";
const funded = lead.budget === "10k_50k" || lead.budget === "50k_plus";
const soon = lead.timeline === "this_quarter" || lead.timeline === "next_quarter";
return bigEnough && funded && soon;
}"use server";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { z } from "zod";
import { isQualified, LeadSchema } from "@/lib/lead";
export interface LeadState {
errors: Record<string, string>;
values: Record<string, string>;
}
const SOURCE = ["utm_source", "utm_medium", "utm_campaign", "referrer", "landing_page"];
export async function submitLead(_previous: LeadState, formData: FormData): Promise<LeadState> {
const values = Object.fromEntries([...formData].map(([key, value]) => [key, String(value)]));
const parsed = LeadSchema.safeParse(values);
if (!parsed.success) {
const { fieldErrors } = z.flattenError(parsed.error);
const errors = Object.fromEntries(Object.entries(fieldErrors).map(([key, list]) => [key, list?.[0] ?? ""]));
return { errors, values };
}
const stored = new URLSearchParams((await cookies()).get("lead_source")?.value ?? "");
const source = Object.fromEntries(SOURCE.map((key) => [key, stored.get(key) ?? ""]));
const origin = (await headers()).get("origin");
const qualified = isQualified(parsed.data);
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({
...parsed.data,
...source,
fit: qualified ? "qualified" : "nurture",
_gotcha: values._gotcha ?? "",
}),
signal: AbortSignal.timeout(8000),
}).catch(() => null);
const result = await response?.json().catch(() => null);
if (!result?.ok) {
const fields: Record<string, string[]> = result?.error?.fields ?? {};
const errors = Object.fromEntries(Object.entries(fields).map(([key, list]) => [key, list[0] ?? ""]));
if (!Object.keys(errors).length) errors.form = "We couldn't send that. Email sales@example.com instead.";
return { errors, values };
}
if (!qualified || values._gotcha) redirect("/contact-sales/thanks");
const { name, email } = parsed.data;
redirect(`https://cal.com/acme/intro?${new URLSearchParams({ name, email })}`);
}- Zod はサーバーから何かが出ていく前に動き、
z.flattenErrorがその問題をフィールドごとに 1 つのメッセージにまとめます。 - ブラウザーはサーバーアクションの送信のたびに
Originヘッダーを送り、アクションはそれを転送します。サーバー側の fetch は Origin を送らず、Ikiform は無料のlocalhostテストをそこから判別するからです。 fitはサーバーで計算します。訪問者がフォームを書き換えて自分を条件に合うリードにすることはできません。redirect()の呼び出しは 2 つともtryブロックの外にあります。redirect()は例外を投げて動くので、catchがあると握りつぶされてしまいます。- ハニーポットが埋まった送信も送られるので、Ikiform はそれをスパムとして記録しますが、カレンダーに届くことはありません。
JavaScript があるときとないときのフォーム
"use client";
import { useActionState } from "react";
import { type LeadState, submitLead } from "./actions";
const initial: LeadState = { errors: {}, values: {} };
function FieldError({ message }: { message?: string }) {
return message ? <p role="alert">{message}</p> : null;
}
export function LeadForm() {
const [state, action, pending] = useActionState(submitLead, initial);
const { errors, values } = state;
return (
<form action={action}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required defaultValue={values.name} />
<FieldError message={errors.name} />
<label htmlFor="email">Work email</label>
<input id="email" name="email" type="email" autoComplete="email" required defaultValue={values.email} />
<FieldError message={errors.email} />
<label htmlFor="company">Company</label>
<input id="company" name="company" autoComplete="organization" required defaultValue={values.company} />
<FieldError message={errors.company} />
<label htmlFor="company_size">Company size</label>
<select id="company_size" name="company_size" required defaultValue={values.company_size ?? ""} key={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>
<FieldError message={errors.company_size} />
<label htmlFor="budget">Budget</label>
<select id="budget" name="budget" required defaultValue={values.budget ?? ""} key={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>
<FieldError message={errors.budget} />
<label htmlFor="timeline">When do you want to start?</label>
<select id="timeline" name="timeline" required defaultValue={values.timeline ?? ""} key={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>
<FieldError message={errors.timeline} />
<label htmlFor="message">Anything we should know?</label>
<textarea id="message" name="message" rows={4} defaultValue={values.message} />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
<FieldError message={errors.form} />
<button disabled={pending}>{pending ? "Sending" : "Talk to sales"}</button>
</form>
);
}import { LeadForm } from "./lead-form";
export default function ContactSales() {
return (
<main>
<h1>Talk to sales</h1>
<p>Tell us a little about your team. If we look like a fit, you pick a call time on the next screen.</p>
<LeadForm />
</main>
);
}useActionState で、フォームに送信中のフラグと返されたエラーが渡されます。アクションはサーバー関数なので、フォームはハイドレーション前でも送信できます。Next.js が結果をサーバーで描画し、訪問者にはページ全体の読み込みを伴って同じエラーが表示されます。
React はアクションの実行後にフォームをリセットするので、どのエラーの経路でも values を返し、各入力欄はそれを defaultValue として読み戻します。select は変更された defaultValue を自分では反映しないので、各 select の key で、返された選択肢で再マウントさせます。
ハニーポットと人間確認
非表示のハニーポットはそのまま転送されます。シークレットキー付きの送信では Ikiform 自身の人間確認は動かないので、このフォームにボットのトラフィックが来るなら、フォームに Turnstile を追加するのように、送信前にアクションで Turnstile のトークンを検証してください。
レンダリングとデプロイ
問い合わせページはリクエストのデータを何も読まないので、next build がプリレンダーします。プロキシとアクションはリクエストごとに動きます。ホストでの動かし方を確認してください。
| ホスト | 知っておくこと |
|---|---|
| Vercel | プロキシとサーバーアクションは設定なしで動きます。プロジェクトの環境変数の Production と Preview に IKIFORM_SECRET_KEY を追加します。 |
| Netlify | Next.js は Netlify のアダプターで設定なしに動きます。キーはサイトの環境変数に、関数をスコープにして設定します。 |
| Cloudflare | OpenNext の Cloudflare アダプターでデプロイし、キーは wrangler secret put で保存します。Cookie に頼る前に、そのドキュメントで現在のプロキシ対応状況を確認してください。 |
動作を確認する
next dev からの送信は localhost のオリジンを持ちます。Ikiform に無料プランはありません。テストは無料で、プランのないワークスペースは localhost からのテスト送信を月 50 件まで受け付けます。本番運用するには、月 $6 から 500 件の Pro を選ぶか、買い切りで 10,000 件 $49 または 50,000 件 $149 のパックを購入してください。パックのクレジットに有効期限はありません。料金を参照してください。
next devを実行して/pricing?utm_source=test&utm_campaign=launchを開きます。ブラウザーの開発者ツールでlead_sourceCookie を確認します。/contact-salesまでクリックで進み、Company size を空のまま送信します。フィールドの下にエラーが表示され、ほかの値は残ります。- 条件に合うリードとして送信します。51〜200 人、$10k〜$50k、今四半期。名前とメールアドレスが入力済みの状態で Cal.com に移動します。
- 1〜10 人のチームとして送信します。
/contact-sales/thanksに移動します。 - ダッシュボードで Submissions を開きます。両方のリードが
utm_source、landing_page、fit付きで入っています。 - 通知の各受信者の受信トレイを確認します。それぞれにフィールド入りのメールが届き、フォームに
emailフィールドがあれば Reply で送信者に返信できます。
ハマりどころ
| 問題 | 対処法 |
|---|---|
| UTM のフィールドが空で届く | タグを追加する前に Cookie が設定されていたか、プロキシのファイルがプロジェクトのルート(src/ を使っているならその中)にありません。Cookie を消してタグ付きで再読み込みしてください。 |
| リダイレクトが起きず、フォームに何も表示されない | redirect() を try の中で呼んでいます。サンプルのようにブロックのあとに移してください。 |
fit や referrer で Unknown field 付きの 422 になる | フォームのフィールド定義にそれがありません。表のすべての ID を任意のフィールドとして追加してください。 |
402 test_limit_reached | ワークスペースにプランがなく、今月の無料の localhost テスト 50 件を使い切っています。プランを選ぶか、ダッシュボードの Send test を使ってください。 |
| エラーのあと select が Choose one に戻る | key プロパティがないので、React がリセットされた select を保持しています。それぞれ返された値をキーにしてください。 |
よくある質問
はい。next/navigation の redirect() は絶対 URL を受け付けます。サーバーアクションの中では、JavaScript が読み込まれていればクライアントで遷移し、ハイドレーション前に送信されたフォームには 303 で応答します。例外を投げて動くので、try ブロックの外で呼んでください。
訪問者が最初に来たページでコンバージョンすることはめったにありません。プロキシは最初のリクエストで動いてタグとリファラーを Cookie に保存し、あとでサーバーアクションがそれを読みます。問い合わせページは URL を読まないので静的なままです。
いいえ。サーバーアクションがリードに点数を付け、その結果を fit フィールドに入れて Ikiform に転送し、リダイレクトします。Ikiform がリードを保存してチームにメールを送り、CRM に Webhook を送ることもできます。
いいえ、無料プランはありません。テストは無料で、支払う前に localhost から月 50 件のテスト送信ができます。本番運用するには、月 $6 で 500 件からの Pro か、1 回払いで 10,000 件 $49 または 50,000 件 $149 のパックを選べます。クレジットに有効期限はありません。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com