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

> SvelteKit のリードフォーム。handle フックでファーストタッチの UTM を記録し、フォームアクションで Valibot による検証を行い、redirect() で条件に合うリードをカレンダーに送ります。

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

最初の GET のときに `handle` フックで UTM タグとリファラーを httpOnly の Cookie に記録します。フォームアクションで Valibot を使ってリードを検証し、通らなければエラーと値を付けて `fail()` を返し、有効なリードは流入元の情報と一緒にフォームバックエンドへ転送します。そのあと `redirect(303, url, { external: true })` を呼んで条件に合うリードを日程調整ツールに送ります。`use:enhance` はそのリダイレクトをたどって別のサイトへ移動します。

[メインガイドの SvelteKit タブ](https://ikiform.com/ja/guides/lead-capture.md#form)では、フォームアクションからリードを転送し、お礼の一文を表示します。このページでは営業チームが求める版を作ります。サーバーで検証する会社の規模、予算、時期、各リードの背後にあるキャンペーン、そして条件に合うリードのカレンダーへの引き渡しです。

## SvelteKit で変わること

フォームアクションは SvelteKit で送信を処理するネイティブな方法で、きれいにフォールバックします。JavaScript がなければブラウザーが送信してリダイレクトを自分でたどり、`use:enhance` があれば同じアクションが `fetch` 経由で動きます。別オリジンへのリダイレクト結果はページ全体の読み込みにフォールバックするので、Cal.com への引き渡しに追加のクライアントコードは要りません。

SvelteKit には、すべてのサーバーリクエストを包むフック `handle` もあります。ファーストタッチを記録するのに最適な場所です。SvelteKit 3 では、このフォームに関わる点が 2 つ変わりました。別のサイトへのリダイレクトには `{ external: true }` が必要になり、シークレットは `src/env.ts` で宣言して実行時に `$app/env/private` から読むようになりました。

図: SvelteKit がアクションの結果の種類ごとに行うこと

1. handle フックはすべての GET を見ます。lead_source Cookie がまだなく、UTM タグか外部のリファラーがあれば Cookie を設定します。
2. SvelteKit はいつもどおりリクエストを解決し、ページを描画します。
3. 送信時、use:enhance が fetch でフォームを送信し、送信中の状態にします。
4. アクションは Valibot で検証し、Cookie を読み、訪問者の Origin を付けてリードを転送します。
5. Cal.com へのリダイレクトはクライアント側のナビゲーションにできないので、SvelteKit はページ全体として読み込みます。
6. fail の結果は form プロパティを更新します。reset を false にした update で、入力欄は入力したままになります。

JavaScript がなければ、ブラウザーが同じ 2 つのことを自分で行います。303 をたどるか、form 付きでページを描画します。

## プロジェクトの準備

SvelteKit 3 のプロジェクトから始め、`valibot` をインストールしてアダプターを追加します。シークレットキーを `.env` に `IKIFORM_SECRET_KEY` として入れ、`src/env.ts` で宣言します。デフォルトで非公開かつ実行時に読み込まれ、SvelteKit はクライアントのコードへの `$app/env/private` のインポートを拒否します。キーとフォームについては [SvelteKit のドキュメントページ](https://ikiform.com/ja/docs/guides/sveltekit.md)にあります。

`ファイル`:

```text
src/
  env.ts
  hooks.server.ts
  routes/
    contact-sales/
      +page.server.ts
      +page.svelte
      thanks/+page.svelte
vite.config.ts
.env
```

`src/env.ts`:

```ts
import { defineEnvVars } from "@sveltejs/kit/env";

export const variables = defineEnvVars({
  IKIFORM_SECRET_KEY: {},
});
```

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

## handle フックでのファーストタッチ

`src/hooks.server.ts`:

```ts
import type { Handle } from "@sveltejs/kit/hooks";

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

export const handle: Handle = async ({ event, resolve }) => {
  if (event.request.method === "GET" && !event.cookies.get("lead_source")) {
    const referer = event.request.headers.get("referer") ?? "";
    const from = URL.canParse(referer) ? new URL(referer) : null;
    const external = from && from.host !== event.url.host ? from.href.slice(0, 300) : "";
    const source = new URLSearchParams();
    for (const tag of TAGS) source.set(tag, event.url.searchParams.get(tag)?.slice(0, 100) ?? "");

    if (external || TAGS.some((tag) => source.get(tag))) {
      source.set("referrer", external);
      source.set("landing_page", event.url.pathname.slice(0, 200));
      event.cookies.set("lead_source", source.toString(), {
        path: "/",
        httpOnly: true,
        sameSite: "lax",
        maxAge: 60 * 60 * 24 * 30,
      });
    }
  }
  return resolve(event);
};
```

- 対象は GET リクエストだけなので、アクション自身の POST で Cookie が設定されることはありません。
- SvelteKit 3 では Cookie の `path` のデフォルトが `/` です。明示的に設定しておけば、`path` が必須の SvelteKit 2 でもサンプルが動きます。
- 自分のホストからのリファラーは無視されるので、サイト内のクリックで本当の流入元が置き換わることはありません。
- ファイルとして配信されるプリレンダー済みのページでは、フックは動きません。キャンペーンのランディングページはオンデマンドで描画したままにしてください。そうしないと、タグがフックに届きません。

## フォーム Action

`src/routes/contact-sales/+page.server.ts`:

```ts
import { fail, redirect } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import * as v from "valibot";
import type { Actions } from "./$types";

export const prerender = false;

const Lead = v.object({
  name: v.pipe(v.string(), v.trim(), v.nonEmpty("Tell us who to reply to."), v.maxLength(120)),
  email: v.pipe(v.string(), v.trim(), v.email("Enter a work email we can reply to.")),
  company: v.pipe(v.string(), v.trim(), v.nonEmpty("Which company is this for?"), v.maxLength(200)),
  company_size: v.picklist(["1_10", "11_50", "51_200", "201_1000", "1000_plus"], "Pick a company size."),
  budget: v.picklist(["under_10k", "10k_50k", "50k_plus", "unsure"], "Pick a budget range."),
  timeline: v.picklist(["this_quarter", "next_quarter", "later", "researching"], "Pick a timeline."),
  message: v.pipe(v.string(), v.trim(), v.maxLength(5000, "Keep it under 5,000 characters.")),
  _gotcha: v.string(),
});

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

export const actions = {
  default: async ({ request, cookies, fetch, url }) => {
    const form = await request.formData();
    const values = Object.fromEntries(Object.keys(Lead.entries).map((key) => [key, String(form.get(key) ?? "")]));
    const parsed = v.safeParse(Lead, values);
    if (!parsed.success) {
      const nested = v.flatten<typeof Lead>(parsed.issues).nested ?? {};
      const errors = Object.fromEntries(Object.entries(nested).map(([key, list]) => [key, list?.[0] ?? ""]));
      return fail(400, { errors, values });
    }

    const lead = parsed.output;
    const stored = new URLSearchParams(cookies.get("lead_source") ?? "");
    const qualified =
      lead.company_size !== "1_10" &&
      (lead.budget === "10k_50k" || lead.budget === "50k_plus") &&
      (lead.timeline === "this_quarter" || lead.timeline === "next_quarter");

    const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
        Origin: request.headers.get("origin") ?? url.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] ?? ""]));
      if (!Object.keys(errors).length) errors.form = "We couldn't send that. Email sales@example.com instead.";
      return fail(response.status >= 400 ? response.status : 502, { errors, values });
    }

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

- Valibot の `picklist` は空の `select` をあなたのメッセージで拒否し、`flatten` はフィールドごとにメッセージの一覧を 1 つ返します。
- ネイティブの送信のあと、ページには値のコピーがほかにないので、`fail()` は値も返します。SvelteKit 3 は `fail` のステータスを HTTP ステータスとして送ります。
- サーバー側の fetch は Origin を送らず、Ikiform は無料の `localhost` テストをそこから判別するので、ブラウザーの `Origin` ヘッダーを転送します。
- `redirect()` は例外を投げるので、そのあとの処理は実行されません。SvelteKit 2 以降は `throw` なしで呼び出し、SvelteKit 3 以降は別オリジンの宛先に `{ external: true }` が必要です。付けないとリダイレクトせずにアクションが失敗します。
- アクションを持つページはプリレンダーできません。親のレイアウトがプリレンダーをオンにした場合に備えて、`prerender = false` で明示しておきます。

## ページ

`src/routes/contact-sales/+page.svelte`:

```svelte
<script lang="ts">
  import { enhance } from "$app/forms";
  import type { PageProps } from "./$types";

  let { form }: PageProps = $props();
  let pending = $state(false);
  const errors = $derived<Record<string, string | undefined>>(form?.errors ?? {});
  const values = $derived<Record<string, string | undefined>>(form?.values ?? {});

  const selects = [
    { id: "company_size", label: "Company size", options: [["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"]] },
    { id: "budget", label: "Budget", options: [["under_10k", "Under $10k"], ["10k_50k", "$10k to $50k"], ["50k_plus", "More than $50k"], ["unsure", "Not sure yet"]] },
    { id: "timeline", label: "When do you want to start?", options: [["this_quarter", "This quarter"], ["next_quarter", "Next quarter"], ["later", "Later this year"], ["researching", "Researching for now"]] },
  ];
</script>

<form
  method="POST"
  use:enhance={() => {
    pending = true;
    return async ({ update }) => {
      await update({ reset: false });
      pending = false;
    };
  }}
>
  <label for="name">Name</label>
  <input id="name" name="name" autocomplete="name" required value={values.name ?? ""} />
  {#if errors.name}<p role="alert">{errors.name}</p>{/if}

  <label for="email">Work email</label>
  <input id="email" name="email" type="email" autocomplete="email" required value={values.email ?? ""} />
  {#if errors.email}<p role="alert">{errors.email}</p>{/if}

  <label for="company">Company</label>
  <input id="company" name="company" autocomplete="organization" required value={values.company ?? ""} />
  {#if errors.company}<p role="alert">{errors.company}</p>{/if}

  {#each selects as select (select.id)}
    <label for={select.id}>{select.label}</label>
    <select id={select.id} name={select.id} required value={values[select.id] ?? ""}>
      <option value="" disabled>Choose one</option>
      {#each select.options as [value, text] (value)}
        <option {value}>{text}</option>
      {/each}
    </select>
    {#if errors[select.id]}<p role="alert">{errors[select.id]}</p>{/if}
  {/each}

  <label for="message">Anything we should know?</label>
  <textarea id="message" name="message" rows="4">{values.message ?? ""}</textarea>

  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  {#if errors.form}<p role="alert">{errors.form}</p>{/if}
  <button disabled={pending}>{pending ? "Sending" : "Talk to sales"}</button>
</form>
```

`use:enhance` は `update()` でデフォルトの動作を実行しますが、`reset: false` によって失敗後も入力欄の内容が残ります。JavaScript がなければ、アクションの `fail` のデータが `form` プロパティで戻り、`value` 属性がすべてのフィールドを埋め直します。

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

ハニーポットはスキーマに含まれているので Ikiform に届き、埋まったものはスパムとして保存されます。アクションは送信者をサンクスページに送ります。SvelteKit 組み込みの CSRF チェックが、別オリジンからのフォーム送信をすでに拒否しています。シークレットキーを使うと Ikiform の人間確認はスキップされるので、必要なら [フォームに Turnstile を追加する](https://ikiform.com/ja/guides/spam-protection/cloudflare-turnstile.md)のようにアクションで Turnstile のトークンを検証してください。

## デプロイの注意点

| ホスト | 知っておくこと |
| --- | --- |
| Vercel | `@sveltejs/adapter-vercel`、または `adapter-auto`。プロジェクトの環境変数に `IKIFORM_SECRET_KEY` を追加します。 |
| Netlify | `@sveltejs/adapter-netlify`。サイトの設定でキーを設定します。 |
| Cloudflare | `@sveltejs/adapter-cloudflare`。キーは `wrangler secret put` で保存します。キーは実行時にしか存在しませんが、宣言した変数はアプリの起動時に読み込まれるので、`$app/env/private` で問題なく扱えます。 |

## 動作を確認する

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

1. `vite dev` を実行して `/?utm_source=test&utm_medium=email` を開き、開発者ツールで `lead_source` Cookie を確認します。
2. `/contact-sales` で会社の規模を選び、**Budget** を空のまま送信します。予算のエラーが表示され、ほかの回答は残ります。
3. 条件に合うリードとして送信します。201〜1,000 人、$10k〜$50k、今四半期。名前とメールアドレスが入力済みの状態で Cal.com が読み込まれます。
4. **Later this year** で送信します。`/contact-sales/thanks` に移動します。
5. ダッシュボードで **Submissions** を開き、`utm_medium`、`landing_page`、`fit` を確認します。
6. 通知の各受信者の受信トレイを確認します。それぞれにフィールド入りのメールが届き、フォームに `email` フィールドがあれば **Reply** で送信者に返信できます。

## ハマりどころ

| 問題 | 対処法 |
| --- | --- |
| `403 Cross-site POST form submissions are forbidden` | SvelteKit の CSRF チェックが、一致しないオリジンを検出しました。ホストを渡さないプロキシの背後では、`vite.config.ts` の `sveltekit()` のオプションで `paths.origin` を設定してください。SvelteKit 3 では adapter-node の `ORIGIN` 変数が削除されました。自分の別のサイトなら `csrf.trustedOrigins` に追加します。 |
| 外部 URL に関するエラーで Cal.com へのリダイレクトが失敗する | SvelteKit 3 はデフォルトで外部へのリダイレクトを拒否します。第 3 引数に `{ external: true }`、または `{ external: ["https://cal.com"] }` のような許可リストを渡してください。 |
| ページが Cal.com に移動せず、コンソールにエラーが出る | カスタムの `use:enhance` コールバックがリダイレクト時に `goto()` を呼んでいます。`goto` は外部 URL を拒否します。代わりに `update()` か `applyAction()` を呼んでください。 |
| エラーの後にフィールドが空になる | `update()` が `reset: false` なしで呼ばれているか、入力欄が `form.values` を読んでいません。 |
| `422 validation_failed` | 送信がフォームのフィールド定義に合っていません。`error.fields` に各フィールドと理由が示されます。入力欄の `name` をフィールド ID に合わせてください。 |

## よくある質問

### SvelteKit のフォームアクションから外部 URL にリダイレクトできますか？

はい。アクションで redirect(303, 'https://...', { external: true }) を呼びます。SvelteKit 3 はこのオプションなしの外部ターゲットを拒否します。JavaScript がなければブラウザーがそれに従い、use:enhance があれば、その URL はアプリの一部ではないので SvelteKit はページ全体の読み込みにフォールバックします。

### SvelteKit 3 でシークレットキーを読むにはどうすればよいですか？

src/env.ts で defineEnvVars を使って宣言し、$app/env/private からインポートします。static: true を設定しない限り実行時に読まれるので、Cloudflare Workers のシークレットのような実行時にしかない値も使えます。$env モジュールも動きますが、非推奨です。

### SvelteKit のアクションが失敗した後もフォームの値を保つにはどうすればよいですか？

fail(400, { errors, values }) で値を返し、各入力欄の value を form.values にバインドします。use:enhance を使う場合は、フォームがクリアされないよう update({ reset: false }) を呼びます。

### Ikiform は無料ですか？

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

## その他のガイド

- [リード獲得フォーム](https://ikiform.com/ja/guides/lead-capture.md): 自分のサイトに置くリード獲得フォームです。返信先付きでメールを送り、チームチャットに通知し、CRM に流し込むので、見込み客がまだいるうちに返信できます。
- [Nuxt のリードフォーム](https://ikiform.com/ja/guides/lead-capture/nuxt.md): Nuxt のリードフォーム。サーバーミドルウェアが初回接触の UTM を記録し、Zod で検証する 1 つのサーバールートが fetch とプレーンな送信の両方に応え、有望なリードは商談を予約します。
- [Astro のリードフォーム](https://ikiform.com/ja/guides/lead-capture/astro.md): Actions で作る Astro のリードフォームです。Zod による検証、静的サイトでのファーストタッチの UTM、オンデマンドの問い合わせページ、日程調整ツールへのリダイレクトを扱います。
- [デモ依頼フォーム](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): 会計士、弁護士、コーチ、クリニック向けの相談申し込みフォームです。連絡方法と時間を尋ね、デリケートな詳細は電話のときに回します。
