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

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

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

最初の訪問時に `proxy.ts` でキャンペーンのタグとリファラーを取得し、Cookie に保持します。サーバーアクションで Zod を使ってリードを検証し、会社の規模、予算、時期で点数を付け、流入元の情報と一緒にフォームバックエンドへ転送します。そのあと `redirect()` を呼び、条件に合うリードは日程調整ツールへ、それ以外はサンクスページへ送ります。

[メインガイドの Next.js タブ](https://ikiform.com/ja/guides/lead-capture.md#form)では、リードを転送してお礼の一文を表示します。このページでは営業向けの版を作ります。サーバーでチェックする 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 のリードが最初の広告クリックから商談の予約までたどる道筋

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 のドキュメントページ](https://ikiform.com/ja/docs/guides/nextjs.md)に詳しくあります。

`ファイル`:

```text
app/
  contact-sales/
    page.tsx
    actions.ts
    lead-form.tsx
    thanks/page.tsx
lib/
  lead.ts
proxy.ts
.env.local
```

`.env.local`:

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

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

`proxy.ts`:

```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`:

```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`:

```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`:

```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`:

```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 を追加する](https://ikiform.com/ja/guides/spam-protection/cloudflare-turnstile.md)のように、送信前にアクションで 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 のパックを購入してください。パックのクレジットに有効期限はありません。[料金](https://ikiform.com/ja/pricing)を参照してください。

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` を保持しています。それぞれ返された値をキーにしてください。 |

## よくある質問

### Next.js のサーバーアクションから外部の URL にリダイレクトできますか？

はい。next/navigation の redirect() は絶対 URL を受け付けます。サーバーアクションの中では、JavaScript が読み込まれていればクライアントで遷移し、ハイドレーション前に送信されたフォームには 303 で応答します。例外を投げて動くので、try ブロックの外で呼んでください。

### UTM パラメーターを問い合わせページではなく proxy.ts で取得するのはなぜですか？

訪問者が最初に来たページでコンバージョンすることはめったにありません。プロキシは最初のリクエストで動いてタグとリファラーを Cookie に保存し、あとでサーバーアクションがそれを読みます。問い合わせページは URL を読まないので静的なままです。

### Next.js でリードを選別するのにデータベースは必要ですか？

いいえ。サーバーアクションがリードに点数を付け、その結果を fit フィールドに入れて Ikiform に転送し、リダイレクトします。Ikiform がリードを保存してチームにメールを送り、CRM に Webhook を送ることもできます。

### Ikiform は無料ですか？

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

## その他のガイド

- [リード獲得フォーム](https://ikiform.com/ja/guides/lead-capture.md): 自分のサイトに置くリード獲得フォームです。返信先付きでメールを送り、チームチャットに通知し、CRM に流し込むので、見込み客がまだいるうちに返信できます。
- [デモ依頼フォーム](https://ikiform.com/ja/guides/lead-capture/demo-request.md): リードを保存してから、名前とメールアドレスを入力済みの状態で訪問者を Cal.com や Calendly に送るデモ予約フォームです。
- [Astro のリードフォーム](https://ikiform.com/ja/guides/lead-capture/astro.md): Actions で作る Astro のリードフォームです。Zod による検証、静的サイトでのファーストタッチの UTM、オンデマンドの問い合わせページ、日程調整ツールへのリダイレクトを扱います。
- [React Router のリードフォーム](https://ikiform.com/ja/guides/lead-capture/react-router.md): React Router フレームワークモードのリードフォーム。署名付きのファーストタッチ Cookie を設定するルートのミドルウェア、検証するルートの action、日程調整ツールへのリダイレクトを組み合わせます。
- [見積もり依頼フォーム](https://ikiform.com/ja/guides/lead-capture/quote-request.md): 資料をアップロードではなく共有リンクで受け取り、すべての依頼に期限を付け、リンクの多いリードがスパムにならないようにする見積もり依頼フォームです。
- [相談申し込みフォーム](https://ikiform.com/ja/guides/lead-capture/consultation-request.md): 会計士、弁護士、コーチ、クリニック向けの相談申し込みフォームです。連絡方法と時間を尋ね、デリケートな詳細は電話のときに回します。
