リード獲得フォーム

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

Next.js で見込み客を選別するリードフォームを作る

Next.js App Router のリードフォームです。サーバーアクションで Zod による検証を行い、ファーストタッチの UTM を Cookie に保持し、条件に合うリードをカレンダーに送ります。

Preet SutharIkiform 創業者

最初の訪問時に 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 で応答します。

Next.js のリードが最初の広告クリックから商談の予約までたどる道筋最初の訪問送信広告かリンク?utm_source=...proxy.tslead_source Cookieを設定静的ページキャッシュから配信サーバーアクションZod、Cookie を読むIkiformUTM と fit 付きのリードredirect()Cal.com、事前入力済みアクションの状態エラーと入力済みの値GETnext()JSONok422 か Zod のエラーNext.js のリードが最初の広告クリックから商談の予約までたどる道筋最初の訪問送信広告かリンク?utm_source=...proxy.tslead_source Cookieを設定静的ページキャッシュから配信サーバーアクションZod、Cookie を読むIkiformUTM と fit 付きのリードredirect()Cal.com、事前入力済みアクションの状態エラーと入力済みの値GETnext()JSONok422 か Zod のエラー
  1. 訪問者が UTM タグ付きのキャンペーンのリンクか、ほかのサイトのリンクから来ます。
  2. proxy.ts がタグ、リファラー、ランディングのパスを httpOnly の Cookie に 1 回だけ保存します。
  3. 料金ページと問い合わせページは静的なままで、キャッシュから配信されます。
  4. 送信時、サーバーアクションが Zod でフィールドを検証し、Cookie を読みます。
  5. リード、流入元の情報、計算した fit をシークレットキー付きで Ikiform に送信します。
  6. 条件に合うリードは名前とメールアドレスを入力済みにして日程調整ツールへリダイレクトし、それ以外はサンクスページへ送ります。
  7. 検証エラーは、訪問者が入力したすべての値と一緒にアクションの状態として戻ります。
Cookie がファーストタッチをページをまたいで運ぶので、問い合わせページは URL を読む必要がありません。

プロジェクトの準備

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.local
.env.local
IKIFORM_SECRET_KEY=ik_sk_your_secret_key

次に、Ikiform でもチェックされるよう、フォームの Fields タブでフィールドを追加します。定義がなければ Ikiform は送られたものを何でも保存しますが、定義があれば未知のフィールドは 422 になります。ペイロードに流入元の情報が入ったら、それが望ましい動作です。

フィールド 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 はサーバー専用の環境変数に置いてください。

proxy.ts でのファーストタッチの記録

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 四半期以内です。

lib/lead.ts
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;
}
app/contact-sales/actions.ts
"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 があるときとないときのフォーム

app/contact-sales/lead-form.tsx
"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>
  );
}
app/contact-sales/page.tsx
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 を追加します。
NetlifyNext.js は Netlify のアダプターで設定なしに動きます。キーはサイトの環境変数に、関数をスコープにして設定します。
CloudflareOpenNext の Cloudflare アダプターでデプロイし、キーは wrangler secret put で保存します。Cookie に頼る前に、そのドキュメントで現在のプロキシ対応状況を確認してください。

動作を確認する

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

  1. next dev を実行して /pricing?utm_source=test&utm_campaign=launch を開きます。ブラウザーの開発者ツールで lead_source Cookie を確認します。
  2. /contact-sales までクリックで進み、Company size を空のまま送信します。フィールドの下にエラーが表示され、ほかの値は残ります。
  3. 条件に合うリードとして送信します。51〜200 人、$10k〜$50k、今四半期。名前とメールアドレスが入力済みの状態で Cal.com に移動します。
  4. 1〜10 人のチームとして送信します。/contact-sales/thanks に移動します。
  5. ダッシュボードで Submissions を開きます。両方のリードが utm_source、landing_page、fit 付きで入っています。
  6. 通知の各受信者の受信トレイを確認します。それぞれにフィールド入りのメールが届き、フォームに 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 を保持しています。それぞれ返された値をキーにしてください。

よくある質問

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