# 商談の予約までつなげるデモ依頼フォームを作る

> リードを保存してから、名前とメールアドレスを入力済みの状態で訪問者を Cal.com や Calendly に送るデモ予約フォームです。

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

まずデモ依頼をフォームバックエンドに送信すれば、訪問者が日時を選ばなくても、リードが保存されチームにメールが届きます。そのあと、クエリ文字列に `name` と `email` を付けてブラウザーを予約ページに送ります。Calendly と Cal.com はこれを使ってフォームを事前入力します。小さなチームはデモ動画など別の場所に送り、カレンダーは成約につながる商談のために空けておきましょう。

## デモ依頼ならではの違い

デモを依頼する人がほしいのは、メールのやり取りではなくカレンダー上の時間です。「火曜日はいかがですか？」のやり取りが 1 往復増えるたびに、他社で予約される機会が生まれます。だからフォームの役割は短く、見込み度を判断できるだけの情報を集めたら、タブを開いているうちにカレンダーを見せることです。

作りを左右する制約は 3 つあります。

- **予約ページを開く前にリードを保存する。** 予約ページを開いて閉じてしまう訪問者は大勢います。記録が予約ツールの中にしかなければ、そのリードは失われます。先に Ikiform に送信し、成功したときだけリダイレクトしてください。
- **`_redirect` には入力内容を載せられない。** 決まった URL で、Ikiform がそれに従うのは **Allowed domains** のどれかにある `https:` の URL の場合だけです。そこに `cal.com` や `calendly.com` を追加すると、それらのサイトからの送信も通ってしまいます。代わりに自分のコードで予約リンクを組み立ててください。
- **すべての依頼がライブの商談に値するわけではない。** チーム向け製品のデモを依頼する 1 人の創業者には、デモ動画と登録リンクのほうが役に立ちます。選択式の質問 1 つで、ブラウザーの次の行き先を決めます。

図: 保存したあと、デモ依頼がどこへ向かうか

1. 訪問者が名前、仕事用のメールアドレス、会社、チームの規模を入力し、フォームが Ikiform に送信します。
2. Ikiform が依頼を保存してチームにメールを送ります。ok が返るまで、ほかには何も起きません。
3. コードが、訪問者が選んだチームの規模を読みます。
4. 11 人以上のチームは、クエリ文字列に名前とメールアドレスを付けて予約ページへ。
5. 1〜10 人のチームは、登録リンク付きのデモ動画へ。

分岐は、Ikiform がリードを受け取ったあとにコードで行います。どちらの場合もチームにはすべての依頼が届くので、商談すべきだった小さなチームも見落としません。

## 4 つのフィールド、うち 1 つは振り分け用

| フィールド | 種類 | 理由 |
| --- | --- | --- |
| `name` | テキスト | 予約フォームに事前入力されるので、訪問者は 1 回入力するだけ |
| `email` | メール | 予約フォームに事前入力され、通知メールの reply-to になる |
| `company` | テキスト、ここでは必須 | 担当者が商談の前に調べる |
| `team_size` | 選択 | 予約ページかデモ動画かを決め、決まった 4 つの値のどれかで届く |

用途、時期、電話番号は商談のときに聞きましょう。会議の前に背景を知りたいなら、予約ツール側で質問を 1 つ追加できます。Calendly も Cal.com も、カスタムの質問も事前入力できます。

## 送信してから引き継ぐ

どの版も依頼を送信し、Ikiform の応答を待ってから、次の URL を組み立てます。例では Cal.com を使っています。Calendly ならイベントのリンクに差し替えてください。Calendly は事前入力のパラメーターとして `name` と `email` をドキュメントに記載しており、Cal.com は `name` と `email` を含め、どの予約フィールドも識別子で事前入力できます。

**HTML**

フォームとスクリプトをまとめたものです。JavaScript がなくてもフォームは送信され、**Redirect after submit** のページに移動するので、そこに予備として予約リンクを置いてください。

`demo.html`:

```html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST" id="demo-form">
  <label for="name">Name</label>
  <input id="name" name="name" autocomplete="name" required />

  <label for="email">Work email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />

  <label for="company">Company</label>
  <input id="company" name="company" autocomplete="organization" required />

  <label for="team_size">Team size</label>
  <select id="team_size" name="team_size" required>
    <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_plus">201 or more</option>
  </select>

  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <p role="alert" id="demo-error" hidden></p>
  <button>Pick a time</button>
</form>

<script>
  const BOOKING = "https://cal.com/acme/demo";
  const SELF_SERVE = "/tour";
  const form = document.getElementById("demo-form");
  const error = document.getElementById("demo-error");

  form.addEventListener("submit", async (event) => {
    event.preventDefault();
    const data = new FormData(form);
    form.querySelector("button").disabled = true;
    error.hidden = true;
    try {
      const response = await fetch(form.action, {
        method: "POST",
        headers: { Accept: "application/json" },
        body: data,
      });
      const result = await response.json();
      if (!result.ok) throw new Error(Object.values(result.error.fields ?? {})[0]?.[0] ?? result.error.message);
      const small = data.get("team_size") === "1_10";
      const next = new URL(small ? SELF_SERVE : BOOKING, location.href);
      next.searchParams.set("name", String(data.get("name")));
      next.searchParams.set("email", String(data.get("email")));
      location.assign(next);
    } catch (failure) {
      error.textContent = failure instanceof Error ? failure.message : "Couldn't send. Try again.";
      error.hidden = false;
      form.querySelector("button").disabled = false;
    }
  });
</script>
```

**Next.js**

サーバーアクションは、Ikiform が ok を返したら予約 URL で `redirect()` を呼びます。Next.js はそこで絶対 URL を受け付け、サーバーアクションは素のフォーム送信に `303` で応答するので、フォームはハイドレーション前でも動きます。`redirect()` は処理のために例外を投げるので、`try` ブロックの外に置いてください。

`app/demo/actions.ts`:

```ts
"use server";

import { headers } from "next/headers";
import { redirect } from "next/navigation";

const BOOKING = "https://cal.com/acme/demo";

export async function requestDemo(_previous: string, formData: FormData): Promise<string> {
  const lead = {
    name: String(formData.get("name") ?? "").trim(),
    email: String(formData.get("email") ?? "").trim(),
    company: String(formData.get("company") ?? "").trim(),
    team_size: String(formData.get("team_size") ?? ""),
    _gotcha: String(formData.get("_gotcha") ?? ""),
  };
  const origin = (await headers()).get("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),
  });
  const result = await response.json().catch(() => null);
  if (!result?.ok) {
    const fields: Record<string, string[]> = result?.error?.fields ?? {};
    return Object.values(fields)[0]?.[0] ?? result?.error?.message ?? "Couldn't send. Try again.";
  }

  const next = new URL(lead.team_size === "1_10" ? "https://acme.example/tour" : BOOKING);
  next.searchParams.set("name", lead.name);
  next.searchParams.set("email", lead.email);
  redirect(next.toString());
}
```

`app/demo/demo-form.tsx`:

```tsx
"use client";

import { useActionState } from "react";
import { requestDemo } from "./actions";

export function DemoForm() {
  const [error, action, pending] = useActionState(requestDemo, "");

  return (
    <form action={action}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required />
      <label htmlFor="email">Work email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      <label htmlFor="company">Company</label>
      <input id="company" name="company" autoComplete="organization" required />
      <label htmlFor="team_size">Team size</label>
      <select id="team_size" name="team_size" required>
        <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_plus">201 or more</option>
      </select>
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {error ? <p role="alert">{error}</p> : null}
      <button disabled={pending}>{pending ? "Finding times" : "Pick a time"}</button>
    </form>
  );
}
```

**SvelteKit**

フォームアクションは、エラーのときは最初のメッセージを付けて `fail()` を返し、成功したら `redirect(303, url, { external: true })` を呼びます。SvelteKit 3 はこのオプションなしでは別サイトへのリダイレクトを拒否します。`$app/env/private` から読めるように、`src/env.ts` で `defineEnvVars` を使って `IKIFORM_SECRET_KEY` を宣言してください。デフォルトのアクションに送信する任意の `+page.svelte` のフォームと組み合わせます。JavaScript がなければ、ブラウザーが `303` を自分でたどります。

`src/routes/demo/+page.server.ts`:

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

export const actions = {
  default: async ({ request, fetch, url }) => {
    const data = await request.formData();
    const lead = Object.fromEntries(
      ["name", "email", "company", "team_size", "_gotcha"].map((key) => [key, String(data.get(key) ?? "").trim()]),
    );

    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),
    });
    const result = await response.json();
    if (!result.ok) {
      const fields: Record<string, string[]> = result.error.fields ?? {};
      return fail(response.status, { lead, message: Object.values(fields)[0]?.[0] ?? result.error.message });
    }

    const next = new URL(lead.team_size === "1_10" ? "https://acme.example/tour" : "https://cal.com/acme/demo");
    next.searchParams.set("name", lead.name);
    next.searchParams.set("email", lead.email);
    redirect(303, next.toString(), { external: true });
  },
} satisfies Actions;
```

**React**

シングルページアプリでは、ブラウザーが Ikiform に直接送信し、`location.assign` で次へ進みます。フォームの保護には **Allowed domains** を使い、シークレットキーは絶対に使わないでください。

`src/DemoForm.tsx`:

```tsx
import { type FormEvent, useState } from "react";

const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const BOOKING = "https://cal.com/acme/demo";

export function DemoForm() {
  const [error, setError] = useState("");
  const [pending, setPending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    setPending(true);
    setError("");
    try {
      const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body: data });
      const result = await response.json();
      if (!result.ok) {
        const fields: Record<string, string[]> = result.error.fields ?? {};
        setError(Object.values(fields)[0]?.[0] ?? result.error.message);
        setPending(false);
        return;
      }
      const next = new URL(data.get("team_size") === "1_10" ? "/tour" : BOOKING, window.location.href);
      next.searchParams.set("name", String(data.get("name")));
      next.searchParams.set("email", String(data.get("email")));
      window.location.assign(next);
    } catch {
      setError("Couldn't reach the server. Try again.");
      setPending(false);
    }
  }

  return (
    <form action={ENDPOINT} method="post" onSubmit={onSubmit}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required />
      <label htmlFor="email">Work email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      <label htmlFor="company">Company</label>
      <input id="company" name="company" autoComplete="organization" required />
      <label htmlFor="team_size">Team size</label>
      <select id="team_size" name="team_size" required>
        <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_plus">201 or more</option>
      </select>
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {error ? <p role="alert">{error}</p> : null}
      <button disabled={pending}>{pending ? "Finding times" : "Pick a time"}</button>
    </form>
  );
}
```

> サーバー側の版はシークレットキー付きで送信するので、Ikiform は **Allowed domains** と人間確認をスキップします。その場合はすべての送信がサーバーの IP から来るため、訪問者ごとに 1 分あたり 5 件という Standard の制限が、全訪問者をまとめて数えることになります。サーバー経由で送信するフォームには Relaxed か Custom の[レート制限](https://ikiform.com/ja/docs/rate-limits.md)を選び、`IKIFORM_SECRET_KEY` はサーバー専用の環境変数に置いてください。サーバー側の fetch は Origin を送らず、Ikiform は無料の `localhost` テストをそこから判別するので、サーバーの版は訪問者の `Origin` ヘッダーを転送します。

## Gmail をブロックせずに仕事用のメールアドレスを扱う

Ikiform には、フリーメールのプロバイダーを拒否する設定はありません。メールのフィールドの `allowed_domains` オプションは許可リストで、自社のドメインに限った社内フォームには便利ですが、公開のデモフォームには向きません。それで問題ありません。Gmail をブロックすると、スマートフォンからフォームに入力する創業者、コンサルタント、購入検討者もブロックしてしまうからです。

代わりに、チームが目にする場所でそうした依頼にタグを付けましょう。この関数は、各依頼をチャットや CRM に送るリレーの中で動き、商談の前に担当者にヒントを与えます。

`lib/demo-tag.ts`:

```ts
const FREE_MAIL = new Set(["gmail.com", "googlemail.com", "outlook.com", "hotmail.com", "yahoo.com", "icloud.com", "proton.me"]);

export function demoTag(fields: Record<string, unknown>) {
  const domain = String(fields.email ?? "").split("@")[1]?.toLowerCase() ?? "";
  const size = String(fields.team_size ?? "");
  if (FREE_MAIL.has(domain)) return "personal address, check the company";
  if (size === "51_200" || size === "201_plus") return "priority";
  return "standard";
}
```

`verifyWebhook` のあとに `event.data.fields` を渡して呼び、タグをチャットのメッセージか CRM のプロパティに追加します。リレーの全体は [HubSpot や Pipedrive にリードを送る](https://ikiform.com/ja/guides/lead-capture/leads-to-crm.md)にあります。

## 動作を確認する

1. **Allowed domains** にサイトのドメインと `localhost` を追加します。
2. チームの規模を **51 to 200** にして送信します。名前とメールアドレスが入力済みの状態で予約ページが開きます。
3. 日時を選ばずに予約ページを閉じます。依頼は **Submissions** に残っていて、通知メールも受信トレイに届いています。
4. **1 to 10** でもう一度送信し、デモ動画に移動することを確認します。
5. 無効なメールアドレスで送信します。フォームにエラーが表示され、予約ページは開きません。
6. テストの会議を予約し、予約ツールの確認メールが入力したアドレスに届くことを確認します。

## ハマりどころ

- **予約されたデモ 1 件につき記録は 2 つ。** Ikiform が依頼を、予約ツールが会議を持っています。CRM では名前ではなくメールアドレスで照合してください。
- **Ikiform からの確認メールはない。** Ikiform はフォームに入力した人にはメールを送りません。予約された商談は、予約ツール自身の確認メールとカレンダーの招待でカバーされます。予約に至らなかった依頼には、その日のうちに通知メールから返信してください。
- **クエリ文字列は見える。** 予約 URL の名前とメールアドレスは、ブラウザーの履歴と予約ツールのアナリティクスに残ります。それ以外のものは渡さないでください。
- **埋め込み。** 代わりに予約ツールを自分の `/book` ページに埋め込む場合、Cal.com がページのクエリパラメーターを引き継ぐのは `forwardQueryParams` をオンにしたときだけです。Calendly は埋め込み時の事前入力を JavaScript の埋め込みの `prefill` オブジェクトで行うと記載しているので、素の iframe ではなくそちらを使ってください。
- **スパムチェック。** **Spam check** を **Ikiform** にしている場合、ブラウザーの版は送信前に `@ikiform/sdk` の `challenge()` を呼ぶ必要があり、呼ばないと `403 challenge_required` が返ります。サーバーの版はチェックをスキップします。

## よくある質問

### フォームの名前とメールアドレスを Calendly や Cal.com に渡すには？

予約リンクのクエリ文字列に ?name=Ada%20Lovelace&email=ada%40example.com のように追加します。フォームバックエンドが ok を返したあとに自分のコードで URL を組み立て、ブラウザーをそこへ送ってください。

### フォームなしで、いきなり予約ページに送らないのはなぜですか？

予約ツールを開いても予約せずに去る訪問者は多く、予約ツールが記録するのは予約した人だけです。先に短いフォームを置けば、チームは reply-to 付きですべての依頼を手にでき、日時を選ばなかった人にもフォローアップできます。

### _redirect で予約ページに送れますか？

フォームの許可ドメインにある決まった URL にしか送れず、訪問者の入力内容も含められません。予約フォームに事前入力するには、ページのスクリプトかサーバーアクションで URL を組み立ててください。

### デモフォームで Gmail などのフリーメールのアドレスを拒否できますか？

組み込みのブロックリストはありません。メールのフィールドの許可ドメインのオプションは一覧のドメインだけを許可するもので、社内フォーム向けです。すべてのアドレスを受け付け、フリーメールの依頼にはリレーか CRM でタグを付けてください。

## その他のガイド

- [リード獲得フォーム](https://ikiform.com/ja/guides/lead-capture.md): 自分のサイトに置くリード獲得フォームです。返信先付きでメールを送り、チームチャットに通知し、CRM に流し込むので、見込み客がまだいるうちに返信できます。
- [HubSpot や Pipedrive にリードを送る](https://ikiform.com/ja/guides/lead-capture/leads-to-crm.md): 署名付きの Ikiform の Webhook を、HubSpot の連絡先 upsert や Pipedrive の人物とリードの呼び出しに変換し、配信がリトライされても連絡先が重複しないようにします。
- [相談申し込みフォーム](https://ikiform.com/ja/guides/lead-capture/consultation-request.md): 会計士、弁護士、コーチ、クリニック向けの相談申し込みフォームです。連絡方法と時間を尋ね、デリケートな詳細は電話のときに回します。
- [見積もり依頼フォーム](https://ikiform.com/ja/guides/lead-capture/quote-request.md): 資料をアップロードではなく共有リンクで受け取り、すべての依頼に期限を付け、リンクの多いリードがスパムにならないようにする見積もり依頼フォームです。
- [プロジェクト問い合わせフォーム](https://ikiform.com/ja/guides/lead-capture/project-inquiry.md): スタジオやフリーランス向けのプロジェクト依頼フォームです。誰かが概要を読む前に、予算と時期で問い合わせを有望、検討、対象外に振り分けます。
- [Next.js のリードフォーム](https://ikiform.com/ja/guides/lead-capture/nextjs.md): Next.js App Router のリードフォームです。サーバーアクションで Zod による検証を行い、ファーストタッチの UTM を Cookie に保持し、条件に合うリードをカレンダーに送ります。
