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

> TanStack Start のリードフォームです。ファーストタッチの UTM を記録するグローバルなリクエストミドルウェア、Zod を使うサーバー関数、日程調整ツールへの throw によるリダイレクトを組み合わせます。

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

`src/start.ts` のグローバルなリクエストミドルウェアで、最初のページリクエストの UTM タグとリファラーを記録し、httpOnly の Cookie に保持します。`createServerFn` のハンドラーで Zod を使ってリードを検証し、流入元の情報と一緒にフォームバックエンドへ転送して、`redirect({ href })` を throw して条件に合うリードを日程調整ツールに送ります。サーバー関数は `useServerFn` 経由で呼ぶと、リダイレクトを自動でたどってくれます。

[メインガイドの TanStack Start タブ](https://ikiform.com/ja/guides/lead-capture.md#form)では、サーバー関数でリードを送ってお礼の一文を表示します。このページでは営業向けの版を作ります。サーバーで検証する 3 つの選別用の質問、訪問者を連れてきたキャンペーン、条件に合うリードのカレンダーへの直接のリダイレクトです。

## TanStack Start で変わること

Start には、ファイルベースのミドルウェアの規約がありません。すべてのリクエストで動かすべきものは、グローバルなリクエストミドルウェアとして `src/start.ts` に置きます。これは SSR、サーバールート、サーバー関数のどれよりも前に動きます。ファーストタッチを記録するのはそこです。

落とし穴があります。Start がサーバー関数の CSRF 保護を自動で組み込むのは、`src/start.ts` が存在しないときだけです。流入元の記録のためにこのファイルを作った瞬間から保護は自分の責任になり、`createCsrfMiddleware()` を自分で追加しなければなりません。忘れると、リードのエンドポイントがクロスサイトの送信を受け付けてしまいます。

図: リードが TanStack Start のミドルウェアとサーバー関数を通る流れ

1. すべてのリクエストは最初に CSRF ミドルウェアを通ります。チェックするのはサーバー関数の呼び出しだけです。
2. Cookie がまだない GET では、firstTouch が UTM タグと外部のリファラーを読んで保存します。
3. ページはいつもどおり描画されます。
4. 送信時、useServerFn が FormData をサーバー関数に送ります。
5. バリデーターがそれを普通のオブジェクトに変え、ハンドラーがそれに Zod をかけます。
6. 有効なリードは、流入元の情報、fit、訪問者の Origin と一緒に Ikiform に送られます。
7. ハンドラーは Cal.com かサンクスページのルートへのリダイレクトを throw し、useServerFn がそれをたどります。エラーはデータとして戻ります。

src/start.ts を書くと Start のデフォルトのリクエストミドルウェアが置き換わるので、CSRF は手で戻します。

## プロジェクトの準備

React の TanStack Start プロジェクトから始め、`zod` をインストールします。シークレットキーを `.env` に `IKIFORM_SECRET_KEY` として入れます。サーバー関数は `process.env` からそれを読み、クライアントのバンドルに含まれることはありません。ドキュメントに Start 専用のページはありません。キーとエンドポイントについては[サーバーからの送信](https://ikiform.com/ja/docs/guides/server.md)にあります。

`ファイル`:

```text
src/
  start.ts
  lib/lead.ts
  routes/
    contact-sales.tsx
    contact-sales.thanks.tsx
vite.config.ts
.env
```

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

## ファーストタッチ用のグローバルミドルウェア

`src/start.ts`:

```ts
import { createCsrfMiddleware, createMiddleware, createStart } from "@tanstack/react-start";
import { getCookie, setCookie } from "@tanstack/react-start/server";

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

const csrf = createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === "serverFn" });

const firstTouch = createMiddleware().server(({ request, next }) => {
  if (request.method !== "GET" || getCookie("lead_source")) return next();

  const url = new URL(request.url);
  const referer = request.headers.get("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))) {
    source.set("referrer", external);
    source.set("landing_page", url.pathname.slice(0, 200));
    setCookie("lead_source", source.toString(), {
      httpOnly: true,
      sameSite: "lax",
      path: "/",
      maxAge: 60 * 60 * 24 * 30,
    });
  }
  return next();
});

export const startInstance = createStart(() => ({
  requestMiddleware: [csrf, firstTouch],
}));
```

- フィルターで、CSRF のチェックをサーバー関数に限定します。グローバルに使う場合に Start のドキュメントが推奨している方法です。`Sec-Fetch-Site`、`Origin`、`Referer` をリクエストのオリジンと比較します。
- `firstTouch` は GET 以外を飛ばすので、サーバー関数の送信が Cookie に触れることはありません。
- `setCookie` と `getCookie` は `@tanstack/react-start/server` から来ており、サーバーでしか動きません。
- リクエスト中に設定した Cookie は、次のリクエストまで `getCookie` では読めません。ここではそれで問題ありません。リードはあとのリクエストで送信されます。

## サーバー関数

`src/lib/lead.ts`:

```ts
import { redirect } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
import { getCookie, getRequestHeader } from "@tanstack/react-start/server";
import { z } from "zod";

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(),
});

const SOURCE = ["utm_source", "utm_medium", "utm_campaign", "referrer", "landing_page"];
const FIT = { size: ["1_10"], budget: ["10k_50k", "50k_plus"], timeline: ["this_quarter", "next_quarter"] };

export type LeadResult = { errors: Record<string, string> };

export const submitLead = createServerFn({ method: "POST" })
  .validator((data: unknown) => {
    if (!(data instanceof FormData)) throw new Error("Expected form data");
    return Object.fromEntries([...data].map(([key, value]) => [key, String(value)]));
  })
  .handler(async ({ data }): Promise<LeadResult> => {
    const parsed = LeadSchema.safeParse(data);
    if (!parsed.success) {
      const { fieldErrors } = z.flattenError(parsed.error);
      return { errors: Object.fromEntries(Object.entries(fieldErrors).map(([key, list]) => [key, list?.[0] ?? ""])) };
    }
    const lead = parsed.data;
    const stored = new URLSearchParams(getCookie("lead_source") ?? "");
    const qualified =
      !FIT.size.includes(lead.company_size) && FIT.budget.includes(lead.budget) && FIT.timeline.includes(lead.timeline);
    const origin = getRequestHeader("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({
        ...lead,
        ...Object.fromEntries(SOURCE.map((key) => [key, stored.get(key) ?? ""])),
        fit: qualified ? "qualified" : "nurture",
      }),
    });
    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] ?? ""]));
      return { errors: Object.keys(errors).length ? errors : { form: result?.error?.message ?? "Try again." } };
    }

    if (!qualified || lead._gotcha) throw redirect({ to: "/contact-sales/thanks" });
    throw redirect({ href: `https://cal.com/acme/intro?${new URLSearchParams({ name: lead.name, email: lead.email })}` });
  });
```

- `.validator()` は `FormData` の形を変えるだけです。Zod はハンドラーで `safeParse` として動くので、フィールドエラーは、コンポーネントがフィールドごとに読めない throw されたエラーではなく、データとして戻ります。
- `fit` はサーバーで決まります。フォームを書き換えてもリードの格は上がりません。
- サーバー側の fetch は `Origin` を送らず、Ikiform は無料の `localhost` テストをそこから判別するので、`getRequestHeader("origin")` を転送します。
- `redirect({ href })` は外部向けの形で、`redirect({ to })` はアプリ内にとどまります。どちらも throw します。

## ルート

`src/routes/contact-sales.tsx`:

```tsx
import { createFileRoute } from "@tanstack/react-router";
import { useServerFn } from "@tanstack/react-start";
import { type FormEvent, useState } from "react";
import { submitLead } from "../lib/lead";

export const Route = createFileRoute("/contact-sales")({ component: ContactSales });

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;

function ContactSales() {
  const send = useServerFn(submitLead);
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [pending, setPending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setPending(true);
    try {
      const result = await send({ data: new FormData(event.currentTarget) });
      setErrors(result?.errors ?? {});
    } catch {
      setErrors({ form: "We couldn't send that. Email sales@example.com instead." });
    } finally {
      setPending(false);
    }
  }

  const error = (key: string) => (errors[key] ? <p role="alert">{errors[key]}</p> : null);

  return (
    <form method="post" action={submitLead.url} encType="multipart/form-data" onSubmit={onSubmit}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required />
      {error("name")}
      <label htmlFor="email">Work email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      {error("email")}
      <label htmlFor="company">Company</label>
      <input id="company" name="company" autoComplete="organization" required />
      {error("company")}
      {Object.entries(SELECTS).map(([id, [label, options]]) => (
        <div key={id}>
          <label htmlFor={id}>{label}</label>
          <select id={id} name={id} required defaultValue="">
            <option value="" disabled>Choose one</option>
            {options.map(([value, text]) => (
              <option key={value} value={value}>{text}</option>
            ))}
          </select>
          {error(id)}
        </div>
      ))}
      <label htmlFor="message">Anything we should know?</label>
      <textarea id="message" name="message" rows={4} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {error("form")}
      <button disabled={pending}>{pending ? "Sending" : "Talk to sales"}</button>
    </form>
  );
}
```

送信ハンドラーはデフォルトの送信を止め、`useServerFn` 経由でサーバー関数を呼びます。throw されたリダイレクトを、別のサイトへのものも含めてたどるのはこの関数です。フォームはリセットされないので、訪問者がフィールドを直している間もすべての値がそのまま残ります。

`action={submitLead.url}` 属性は JavaScript なしのときの経路です。Start は HTML フォーム向けに `.url` をドキュメントに記載していますが、その場合ブラウザーはこのページではなくサーバー関数の生のレスポンスを表示します。JavaScript をオフにして試し、それで許容できるか判断するか、フォームの下に営業向けの素のメールリンクを置いてください。

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

ハニーポットが埋まった送信も送られるので、Ikiform はそれをスパムとして保存し、ハンドラーは送信者をカレンダーではなくサンクスページのルートに送ります。シークレットキーを使うと Ikiform の人間確認はスキップされます。ボットがフォームを見つけたら、[フォームに Turnstile を追加する](https://ikiform.com/ja/guides/spam-protection/cloudflare-turnstile.md)に従ってハンドラーに Turnstile のチェックを追加してください。

## レンダリングとデプロイ

ルートはデフォルトでサーバーで描画され、ミドルウェアはそのリクエストごとに動きます。マーケティングのページを静的ファイルにプリレンダーすると、CDN はミドルウェアを動かさずに配信するので、流入元の記録はリクエストごとに描画されるページから始める必要があります。

| ホスト | 知っておくこと |
| --- | --- |
| Vercel | `nitro/vite` プラグインを追加してデプロイします。プロジェクトの環境変数に `IKIFORM_SECRET_KEY` を設定します。 |
| Netlify | `vite.config.ts` に `@netlify/vite-plugin-tanstack-start` を追加します。キーはサイトの設定で設定します。 |
| Cloudflare | `@cloudflare/vite-plugin` を追加し、Wrangler でデプロイします。キーは `wrangler secret put` で保存し、`process.env` に入るよう `nodejs_compat` を有効にします。 |

## 動作を確認する

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

1. 開発サーバーを起動して `/?utm_source=test&utm_campaign=launch` を開き、開発者ツールで `lead_source` Cookie が httpOnly になっていることを確認します。
2. `/contact-sales` で **Under $10k** を選び、**Company** を空のままにします。会社のエラーが表示され、ほかの値はすべて残ります。
3. 条件に合うリードとして送信します。201〜1,000 人、$10k〜$50k、今四半期。名前とメールアドレスが入力済みの状態でブラウザーが Cal.com に移動します。
4. 1〜10 人のチームとして送信します。`/contact-sales/thanks` に移動します。
5. ダッシュボードで **Submissions** を開き、`utm_source`、`landing_page`、`fit` を確認します。
6. 通知の各受信者の受信トレイを確認します。それぞれにフィールド入りのメールが届き、フォームに `email` フィールドがあれば **Reply** で送信者に返信できます。

## ハマりどころ

| 問題 | 対処法 |
| --- | --- |
| `src/start.ts` を追加したあと、自分のサーバー関数から `403` が返る | CSRF のチェックが呼び出しを拒否しました。プロキシの背後など、別の公開オリジンからのリクエストには `createCsrfMiddleware({ origin })` が必要です。 |
| クロスサイトの送信がサーバー関数に届く | `createCsrfMiddleware()` なしで `src/start.ts` が存在しています。サンプルのように追加してください。 |
| 条件に合う送信のあとでもページが移動しない | サーバー関数を `useServerFn` 経由ではなく直接呼んでいるので、throw されたリダイレクトをたどるものがありません。 |
| クライアントでサーバーのコードをインポートしているというエラーが出る | サーバー関数やミドルウェアの外で `getCookie` を呼んでいます。`.handler()` か `.server()` の中に置いてください。 |
| `422 validation_failed` | 送信がフォームのフィールド定義に合っていません。`error.fields` に各フィールドと理由が示されます。入力欄の `name` をフィールド ID に合わせてください。 |

## よくある質問

### TanStack Start のサーバー関数から外部の URL にリダイレクトするには？

ハンドラーの中で @tanstack/react-router の redirect({ href: 'https://...' }) を throw し、コンポーネントでは useServerFn 経由でサーバー関数を呼びます。useServerFn は、別のサイトへのものも含めてリダイレクトをたどります。

### TanStack Start ではミドルウェアをどこに置きますか？

グローバルなリクエストミドルウェアは src/start.ts に置き、createStart から requestMiddleware として返します。SSR、サーバールート、サーバー関数より前に動きます。このファイルを作るなら、createCsrfMiddleware() を自分で追加する必要があります。

### TanStack Start のサーバー関数で FormData を検証するには？

.validator() で入力が FormData であることを確認して普通のオブジェクトに変え、ハンドラーで Zod の safeParse を実行すれば、フィールドごとに 1 つのメッセージを返せます。

### Ikiform は無料ですか？

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

## その他のガイド

- [リード獲得フォーム](https://ikiform.com/ja/guides/lead-capture.md): 自分のサイトに置くリード獲得フォームです。返信先付きでメールを送り、チームチャットに通知し、CRM に流し込むので、見込み客がまだいるうちに返信できます。
- [React Router のリードフォーム](https://ikiform.com/ja/guides/lead-capture/react-router.md): React Router フレームワークモードのリードフォーム。署名付きのファーストタッチ Cookie を設定するルートのミドルウェア、検証するルートの action、日程調整ツールへのリダイレクトを組み合わせます。
- [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): 会計士、弁護士、コーチ、クリニック向けの相談申し込みフォームです。連絡方法と時間を尋ね、デリケートな詳細は電話のときに回します。
