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

> Nuxt のリードフォーム。サーバーミドルウェアが初回接触の UTM を記録し、Zod で検証する 1 つのサーバールートが fetch とプレーンな送信の両方に応え、有望なリードは商談を予約します。

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

最初のページリクエスト時に、Nitro のサーバーミドルウェアで UTM タグとリファラーを httpOnly Cookie に記録します。フォームは Zod で検証する 1 つのサーバールートに送信し、そのルートがアトリビューション付きでリードをフォームバックエンドに転送して、`$fetch` には JSON、プレーンなフォーム送信には `303` で応答します。クライアントでは、`navigateTo(url, { external: true })` で条件を満たしたリードを予約ツールに送ります。

[メインガイドの Nuxt タブ](https://ikiform.com/ja/guides/lead-capture.md#form)では、サーバールートを通して `$fetch` でリードを送り、お礼の一文を表示します。このページでは営業向けの版を作ります。会社の規模、予算、時期をサーバーでチェックし、各リードの背後にあるキャンペーンを記録し、カレンダーに引き継ぎ、JavaScript が読み込まれなくても動く経路を用意します。

## Nuxt で変わること

Nuxt にはフォームアクションがありません。フォームは他のエンドポイントと同じように Nitro のサーバールートに送信するので、対応すべき送信方法が 2 つ残ります。ハイドレーション後のページからの JSON 付き `$fetch` と、素の HTML フォームからの URL エンコードされた送信です。h3 の `readBody` は両方を解析するので、それぞれに合った方法で応答する限り、1 つのルートで両方に対応できます。

この便利さには代償があります。URL エンコードされたボディを受け付けるルートは、クロスサイトのプレーンなフォーム送信も受け付けてしまいます。h3 のドキュメントもこれを警告しており、SvelteKit や Next.js と違って Nuxt はオリジンチェックを自動で追加しません。ルート自身が `Origin` をチェックします。

図: ハイドレーション済みのフォームとプレーンなフォーム送信の両方に応える 1 つの Nuxt サーバールート

1. JavaScript があれば、ページはリアクティブな lead オブジェクトを $fetch で JSON として送信します。
2. サーバールートは他のオリジンからの送信を拒否し、Zod で検証して、アトリビューション付きでリードを転送します。
3. 次の URL を含む JSON で応答し、navigateTo で Cal.com かサンクスページに移動します。
4. バリデーションの失敗は createError で投げられ、ページはエラーの data からフィールドのメッセージを読みます。
5. JavaScript がなければ、ブラウザーはフォームを URL エンコードして同じルートに送信します。
6. ルートは同じチェックを行い、303 で応答します。行き先は予約ツール、サンクスページ、または失敗したフィールド名付きでフォームに戻すかのいずれかです。

応答を決めるのはルートではなくコンテンツタイプです。バリデーションと Ikiform への送信は一度だけ書けば済みます。

## プロジェクトの準備

Nuxt 4 のアプリから始め、`zod` をインストールします。キーを `runtimeConfig` で宣言し、`NUXT_IKIFORM_SECRET_KEY` として設定します。ルートが `useRuntimeConfig(event)` を読むと、Nuxt が実行時にこの変数を割り当てます。キーとフォームについては [Nuxt のドキュメントページ](https://ikiform.com/ja/docs/guides/nuxt.md)で説明しています。

`ファイル`:

```text
app/
  pages/
    contact-sales.vue
    contact-sales/thanks.vue
server/
  middleware/first-touch.ts
  api/lead.post.ts
  utils/lead.ts
nuxt.config.ts
.env
```

`nuxt.config.ts`:

```ts
export default defineNuxtConfig({
  runtimeConfig: {
    ikiformSecretKey: "",
  },
});
```

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

## 初回接触を記録するサーバーミドルウェア

`server/middleware/first-touch.ts`:

```ts
const TAGS = ["utm_source", "utm_medium", "utm_campaign"];

export default defineEventHandler((event) => {
  if (event.method !== "GET" || getCookie(event, "lead_source")) return;
  if (!getRequestHeader(event, "accept")?.includes("text/html")) return;

  const url = getRequestURL(event);
  const referer = getRequestHeader(event, "referer") ?? "";
  const from = URL.canParse(referer) ? new URL(referer) : null;
  const external = from && from.host !== url.host ? from.href.slice(0, 300) : "";
  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;

  source.set("referrer", external);
  source.set("landing_page", url.pathname.slice(0, 200));
  setCookie(event, "lead_source", source.toString(), {
    httpOnly: true,
    sameSite: "lax",
    path: "/",
    maxAge: 60 * 60 * 24 * 30,
  });
});
```

- Nitro は `server/middleware` 内のファイルをすべてのリクエストの前に実行し、何も返さないことを期待します。値を返すとリクエストがそこで終わってしまいます。
- `accept` のチェックで Cookie をページの読み込みに限定するので、API 呼び出しやアセットは対象外になります。
- `getCookie`、`setCookie` などの h3 ヘルパーは、`server/` 内で自動インポートされます。
- `routeRules` や `nuxt generate` でプリレンダリングされたページはファイルとして配信されるので、ミドルウェアはその訪問を一切見ません。キャンペーンのランディングページはサーバーレンダリングのままにしてください。

## 1 つのルートで 2 種類の応答

`server/utils/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(),
  _gotcha: z.string().optional(),
});

export type Lead = z.infer<typeof LeadSchema>;

export const isQualified = (lead: Lead) =>
  lead.company_size !== "1_10" &&
  (lead.budget === "10k_50k" || lead.budget === "50k_plus") &&
  (lead.timeline === "this_quarter" || lead.timeline === "next_quarter");
```

`server/api/lead.post.ts`:

```ts
import { z } from "zod";

const SOURCE = ["utm_source", "utm_medium", "utm_campaign", "referrer", "landing_page"];

export default defineEventHandler(async (event) => {
  const origin = getRequestHeader(event, "origin");
  if (origin && new URL(origin).host !== getRequestHost(event)) {
    throw createError({ status: 403, message: "Posts from other sites aren't accepted." });
  }
  const wantsJson = getRequestHeader(event, "content-type")?.includes("application/json");
  const parsed = LeadSchema.safeParse(await readBody(event));

  if (!parsed.success) {
    const fields = z.flattenError(parsed.error).fieldErrors;
    if (!wantsJson) return sendRedirect(event, `/contact-sales?invalid=${Object.keys(fields).join(",")}`, 303);
    throw createError({ status: 400, message: "Check the highlighted fields.", data: { fields } });
  }

  const lead = parsed.data;
  const stored = new URLSearchParams(getCookie(event, "lead_source") ?? "");
  const qualified = isQualified(lead);
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${useRuntimeConfig(event).ikiformSecretKey}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: JSON.stringify({
      ...lead,
      ...Object.fromEntries(SOURCE.map((key) => [key, stored.get(key) ?? ""])),
      fit: qualified ? "qualified" : "nurture",
    }),
  });
  const result = await response.json().catch(() => null);
  if (!result?.ok) {
    if (!wantsJson) return sendRedirect(event, "/contact-sales?failed=1", 303);
    throw createError({
      status: response.status,
      message: result?.error?.message ?? "We couldn't send that. Try again.",
      data: { fields: result?.error?.fields ?? {} },
    });
  }

  const next =
    qualified && !lead._gotcha
      ? `https://cal.com/acme/intro?${new URLSearchParams({ name: lead.name, email: lead.email })}`
      : "/contact-sales/thanks";
  return wantsJson ? { next } : sendRedirect(event, next, 303);
});
```

- `server/utils` からのエクスポートはサーバールートで自動インポートされるので、`LeadSchema` と `isQualified` にインポートは不要です。
- ブラウザーの同じ `Origin` ヘッダーが 2 つの役割を果たします。クロスサイトの送信をブロックし、さらに Ikiform に転送されます。サーバー側の fetch は Origin を送らず、Ikiform はそれを使って無料の `localhost` テストを見分けるからです。
- `createError` はフィールドのメッセージを `data` に入れ、Nuxt のエラー応答はそれを入れ子にするので、クライアントは `error.data.data.fields` を読みます。
- プレーンな送信はセッションなしでは状態を持ち帰れないので、失敗時は失敗したフィールド名をクエリ文字列に入れてリダイレクトします。訪問者は入力し直すことになりますが、これはフォールバックの経路です。

## ページ

`app/pages/contact-sales.vue`:

```vue
<script setup lang="ts">
const route = useRoute();
const lead = reactive({ name: "", email: "", company: "", company_size: "", budget: "", timeline: "", message: "", _gotcha: "" });
const errors = ref<Record<string, string[] | undefined>>({});
const failure = ref(route.query.failed ? "We couldn't send that. Email sales@example.com instead." : "");
const sending = ref(false);

for (const key of String(route.query.invalid ?? "").split(",").filter(Boolean)) {
  errors.value[key] = ["Check this field."];
}

const selects = {
  company_size: ["Company size", { "1_10": "1 to 10", "11_50": "11 to 50", "51_200": "51 to 200", "201_1000": "201 to 1,000", "1000_plus": "More than 1,000" }],
  budget: ["Budget", { under_10k: "Under $10k", "10k_50k": "$10k to $50k", "50k_plus": "More than $50k", unsure: "Not sure yet" }],
  timeline: ["When do you want to start?", { this_quarter: "This quarter", next_quarter: "Next quarter", later: "Later this year", researching: "Researching for now" }],
} as const;

async function send() {
  sending.value = true;
  errors.value = {};
  failure.value = "";
  try {
    const { next } = await $fetch<{ next: string }>("/api/lead", { method: "POST", body: lead });
    await navigateTo(next, { external: next.startsWith("https://") });
  } catch (error) {
    const data = (error as { data?: { message?: string; data?: { fields?: Record<string, string[]> } } }).data;
    errors.value = data?.data?.fields ?? {};
    if (!Object.keys(errors.value).length) failure.value = data?.message ?? "We couldn't send that. Try again.";
    sending.value = false;
  }
}
</script>

<template>
  <form action="/api/lead" method="post" @submit.prevent="send">
    <label for="name">Name</label>
    <input id="name" v-model="lead.name" name="name" autocomplete="name" required />
    <p v-if="errors.name" role="alert">{{ errors.name[0] }}</p>
    <label for="email">Work email</label>
    <input id="email" v-model="lead.email" name="email" type="email" autocomplete="email" required />
    <p v-if="errors.email" role="alert">{{ errors.email[0] }}</p>
    <label for="company">Company</label>
    <input id="company" v-model="lead.company" name="company" autocomplete="organization" required />
    <p v-if="errors.company" role="alert">{{ errors.company[0] }}</p>
    <template v-for="([label, options], id) in selects" :key="id">
      <label :for="id">{{ label }}</label>
      <select :id="id" v-model="lead[id]" :name="id" required>
        <option value="" disabled>Choose one</option>
        <option v-for="(text, value) in options" :key="value" :value="value">{{ text }}</option>
      </select>
      <p v-if="errors[id]" role="alert">{{ errors[id]?.[0] }}</p>
    </template>
    <label for="message">Anything we should know?</label>
    <textarea id="message" v-model="lead.message" name="message" rows="4" />
    <input v-model="lead._gotcha" name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <p v-if="failure" role="alert">{{ failure }}</p>
    <button :disabled="sending">{{ sending ? "Sending" : "Talk to sales" }}</button>
  </form>
</template>
```

Nuxt がハイドレーションを終えると `@submit.prevent` が処理を引き継ぎ、リアクティブな `lead` オブジェクトがエラーの間もすべての値を保持します。`navigateTo` は `external` が設定されていない限り絶対 URL を拒否するので、このフラグは URL に応じて決めます。Cal.com なら external、サンクスページならクライアントナビゲーションです。ハイドレーション前は `action` 属性が同じルートに送信し、ページは `?invalid=` を読んでフィールドに印を付けます。

### ハニーポットと人間確認

ハニーポットはスキーマに含まれていて転送されるので、Ikiform は値が入ったものをスパムとして保存し、ルートはサンクスページに送ります。シークレットキーを使うと Ikiform の人間確認は省略されます。必要なら、[フォームに Turnstile を導入](https://ikiform.com/ja/guides/spam-protection/cloudflare-turnstile.md)のように、ルートで Turnstile のトークンを検証してください。

## デプロイの注意点

| ホスト | 知っておくこと |
| --- | --- |
| Vercel | Nitro は Vercel を検出して関数をビルドします。環境変数に `NUXT_IKIFORM_SECRET_KEY` を設定してください。 |
| Netlify | Nitro は Netlify も検出します。サイトの設定で変数を設定してください。 |
| Cloudflare | `cloudflare_module` プリセットでビルドし、`wrangler secret put` で変数を保存します。イベントを渡して `useRuntimeConfig(event)` を読めば、実行時に値が取得されます。 |

## 動作を確認する

`nuxt dev` からの送信には `localhost` のオリジンが付きます。Ikiform に無料プランはありません。テストは無料です。プランのないワークスペースでも、`localhost` からのテスト送信を月 50 件まで受け付けます。本番運用するには、500 件で月 $6 からの Pro を選ぶか、買い切りにしてください。10,000 件で $49、または 50,000 件で $149 で、クレジットに有効期限はありません。[料金](https://ikiform.com/ja/pricing)を参照してください。

1. `nuxt dev` を実行し、`/?utm_source=test&utm_campaign=launch` を開いて、開発者ツールで `lead_source` Cookie を確認します。
2. `/contact-sales` で **Timeline** を空のまま送信します。エラーが表示され、他の回答は残ります。
3. JavaScript をオフにして同じように送信します。フィールドに印が付いた状態で `/contact-sales?invalid=timeline` に戻ってきます。
4. 条件を満たすリードとして送信します（11〜50 人、$50k 超、今四半期）。名前とメールアドレスが入力済みの状態で Cal.com に着きます。
5. ダッシュボードで **Submissions** を開き、`utm_campaign`、`referrer`、`fit` を確認します。
6. 通知の各受信者の受信トレイを確認します。それぞれにフィールド入りのメールが届き、フォームに `email` フィールドがあれば **Reply** で送信者に返信できます。

## ハマりどころ

| 問題 | 対処法 |
| --- | --- |
| リードが条件を満たすと `navigateTo` が例外を投げる | `external: true` がありません。これがないと、`navigateTo` は他サイトへの絶対 URL を拒否します。 |
| 本番でキーが空になる | 変数名は `NUXT_IKIFORM_SECRET_KEY` でなければならず、実行時に読まれるよう、ルートは `useRuntimeConfig(event)` を呼ぶ必要があります。 |
| プロキシの背後で `403 Cross-site post` | プロキシがルートから見える `Host` を変えています。元のホストを転送するか、代わりにオリジンを公開ドメインと比較してください。 |
| API 呼び出しや画像に Cookie が設定される | `accept` のチェックが削除されています。ミドルウェアはページの読み込みだけに限定してください。 |
| `422 validation_failed` | 送信がフォームのフィールド定義に合っていません。`error.fields` に各フィールドと理由が示されます。入力欄の `name` をフィールド ID に合わせてください。 |

## よくある質問

### Nuxt で外部 URL にリダイレクトするにはどうすればよいですか？

クライアントでは navigateTo(url, { external: true }) を呼びます。このフラグがないと、他サイトへの遷移では例外が投げられます。サーバールートでは、sendRedirect(event, url, 303) でプレーンなフォーム送信にリダイレクトで応答します。

### 1 つの Nuxt サーバールートで $fetch とプレーンなフォーム送信の両方を扱えますか？

はい。readBody は JSON と URL エンコードのボディを解析するので、コンテンツタイプを確認し、$fetch には JSON、ネイティブの送信には 303 リダイレクトで応答します。ルートがクロスサイトのフォーム送信も受け付けるようになるので、Origin ヘッダーも確認してください。

### Nuxt では UTM トラッキングをどこに置くべきですか？

server/middleware 配下のファイルです。すべてのサーバーリクエストの前に実行され、クエリ文字列とリファラーを読めて、後でリードのルートが読む httpOnly Cookie を設定できます。ファイルとして配信されるプリレンダリング済みのページでは実行されません。

### Ikiform は無料ですか？

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

## その他のガイド

- [リード獲得フォーム](https://ikiform.com/ja/guides/lead-capture.md): 自分のサイトに置くリード獲得フォームです。返信先付きでメールを送り、チームチャットに通知し、CRM に流し込むので、見込み客がまだいるうちに返信できます。
- [SvelteKit のリードフォーム](https://ikiform.com/ja/guides/lead-capture/sveltekit.md): SvelteKit のリードフォーム。handle フックでファーストタッチの UTM を記録し、フォームアクションで Valibot による検証を行い、redirect() で条件に合うリードをカレンダーに送ります。
- [Next.js のリードフォーム](https://ikiform.com/ja/guides/lead-capture/nextjs.md): Next.js App Router のリードフォームです。サーバーアクションで Zod による検証を行い、ファーストタッチの UTM を Cookie に保持し、条件に合うリードをカレンダーに送ります。
- [デモ依頼フォーム](https://ikiform.com/ja/guides/lead-capture/demo-request.md): リードを保存してから、名前とメールアドレスを入力済みの状態で訪問者を Cal.com や Calendly に送るデモ予約フォームです。
- [見積もり依頼フォーム](https://ikiform.com/ja/guides/lead-capture/quote-request.md): 資料をアップロードではなく共有リンクで受け取り、すべての依頼に期限を付け、リンクの多いリードがスパムにならないようにする見積もり依頼フォームです。
- [相談申し込みフォーム](https://ikiform.com/ja/guides/lead-capture/consultation-request.md): 会計士、弁護士、コーチ、クリニック向けの相談申し込みフォームです。連絡方法と時間を尋ね、デリケートな詳細は電話のときに回します。
