ガイド4 分で読めます
すぐに手元に届くリード獲得フォームを作る
自分のサイトに置くリード獲得フォームです。返信先付きでメールを送り、チームチャットに通知し、CRM に流し込むので、見込み客がまだいるうちに返信できます。
自分のページに短いフォームを置き、名前、仕事用のメールアドレス、見込み度を判断する質問 1 つをフォームバックエンドに送信します。通知メールをオンにすれば、各リードが Reply(返信)の宛先をリード本人にした状態で受信トレイに届きます。さらに署名付きの Webhook をチームチャットと CRM に送ります。そのうえで、送信から最初の返信までの分数を計測してください。フォームのデザインよりも、その数字のほうが商談の成否を左右します。
リードフォームは、役割を持ったお問い合わせフォームです。その役割は営業の会話を始めることです。入力する人は、たいてい別のタブで他社と比較しています。実在の人から役に立つ返信を最初に送ったチームが、たいてい商談を手にします。このガイドでは、自分のサイトにフォームを作り、各リードを数秒以内に届け、CRM は任意のままにします。
リードが届くまでの流れ
フォームは Ikiform に送信します。Ikiform は送信をスパムチェックして保存し、3 つのことを同時に行います。リードを返信先にしたメールをチームに送り、署名付きの Webhook を送り、ダッシュボードにリードを表示します。Webhook を通じて、リードはチームチャットと CRM に届きます。
- 訪問者がサイトのリードフォームに入力し、Ikiform のエンドポイントに送信されます。
- Ikiform はハニーポット、スパムスコア、レート制限をチェックしてからリードを保存します。
- 数秒後に各通知先にメールが届き、Reply を押すとリードに返信できます。
- リードはダッシュボードに表示され、検索、絞り込み、エクスポートができます。
- Ikiform が署名付きの submission.created Webhook を送ります。
- リレーや自動化ツールがリードをチームチャットに投稿し、CRM に連絡先を作成します。
リードを獲得する 4 つの方法
よくある構成には、それぞれ得意なことがあります。リードをどこに置きたいか、誰がフォームを保守するかで選んでください。
| 方法 | 得意なこと | トレードオフ |
|---|---|---|
| HubSpot のフォームや Pipedrive の Web フォームなど、CRM 自体のフォーム | つなぎの作業なしで、リードが連絡先として CRM に入ります。トラッキングとリードスコアリングが 1 つの製品にまとまります。 | フォームは CRM のスクリプトとスタイルで埋め込まれます。CRM を乗り換えると、すべてのフォームを作り直すことになります。一部の機能はプランに依存します。 |
| Typeform や Tally などのフォームビルダーの埋め込み | ロジック付きの洗練されたマルチステップフォームをコードなしで作れます。 | お金を払ってスタイルを変えない限り、埋め込みはビルダーの見た目のままです。Webhook と回答数の上限はプランに依存します。 |
| 自分の API ルートとデータベース | 保存前のリードスコアリングなど、どんなロジックでも書けます。 | スパムフィルタリング、レート制限、メール通知、リトライ、エクスポートを自分で作り、1 つのフォームのためにサーバーを動かし続ける必要があります。 |
| Ikiform などのフォームバックエンド | どのサイトでも自分の HTML を使えます。スパムフィルタリング、返信先付きのメール通知、署名付き Webhook、CSV エクスポートがサーバーなしで手に入ります。 | リードスコアリング、CRM のレコード、リードへのメールはありません。それらは Webhook を通じて CRM やメールツールが担います。 |
営業プロセスがすべて 1 つの CRM の中で回っていて、そのフォームに満足しているなら、それを使いましょう。フォームバックエンドが向いているのは、フォームをサイトのデザインに合わせる必要があるとき、CRM を乗り換える可能性があるとき、または 1 つを選ばずに同じフォームからメール、チャット、CRM に届けたいときです。
尋ねるのは少なく、見込み度の判断は 1 回で
フィールドを 1 つ増やすたびに送信数は減ります。ほとんどのリードフォームに必要なのは 4 つです。誰に返信するか、どこで働いているか、何を求めているか、そして説明する余地です。見込み度の判断は選択式の質問 1 つで行います。決まった少数の値のどれかで届くので、振り分けの処理が信頼できるからです。下で試してみてください。ブラウザーで Ikiform のバリデーターを実行し、保存される内容か、返ってくる 422 を表示します。
プレビュー
Ikiform のバリデーターをブラウザーで実行します。何も送信されません。
| フィールド | 理由 | 注意点 |
|---|---|---|
name | 名前から始まる返信 | id は name のままにします。名前欄のリンクは強いスパムのシグナルです |
email | Reply の宛先 | id は email のままにします。ラベルを Work email(仕事用メールアドレス)にすると仕事用のアドレスを使ってもらいやすくなりますが、強制はしません |
company | 返信する前に調べる | 任意。必須にするとフリーランスからの送信が減ります |
interest | 振り分けと優先度 | select は一覧の値を 1 つ保存するか送信を拒否するので、自動化の分岐に使えます |
message | 最初の返信のための背景情報 | 任意。必須の長文欄は、人が入力をやめてしまうフィールドです |
電話番号、チームの規模、予算は、次の行動が変わるフォームでだけ尋ねましょう。見積もり依頼のテンプレートは、サービス、予算、時期を選択式にした充実版です。営業担当が全リードに電話するなら、電話番号を尋ねます。チームの規模で振り分けるなら、select で尋ねます。それ以外は最初の返信に回してください。
フォーム
スタックを選んでください。どの版もサーバーのメッセージを該当するフィールドの下に表示し、訪問者が入力した内容を残します。書きかけのメッセージが 1 つのタイプミスで消えると、リードはあきらめてしまうからです。成功時には誰がいつ返信するかを示すので、送れたかどうか迷う人はいません。
フォームは通常の方法で送信してサンクスページにリダイレクトするので、スクリプトが読み込まれる前でも動きます。各フィールドにはエラー用の空の枠があります。
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST" id="lead-form" novalidate>
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required />
<p class="error" data-error-for="name" hidden></p>
<label for="email">Work email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<p class="error" data-error-for="email" hidden></p>
<label for="company">Company</label>
<input id="company" name="company" autocomplete="organization" />
<label for="interest">What can we help with?</label>
<select id="interest" name="interest" required>
<option value="pricing">Pricing for my team</option>
<option value="demo">A live demo</option>
<option value="custom">A custom plan or contract</option>
<option value="other">Something else</option>
</select>
<label for="message">Anything we should know?</label>
<textarea id="message" name="message" rows="4"></textarea>
<p class="error" data-error-for="message" hidden></p>
<input type="hidden" name="page" value="pricing" />
<input type="hidden" name="_redirect" value="https://example.com/thanks" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<button>Talk to sales</button>
</form>スクリプトは訪問者をページにとどめます。サーバーから返った各フィールドのエラーをそのフィールドの下に表示し、成功するとフォームを、誰がいつ返信するかを伝える 1 行に差し替えます。
const form = document.getElementById("lead-form");
function showErrors(fields) {
form.querySelectorAll("[data-error-for]").forEach((slot) => {
const message = fields[slot.dataset.errorFor]?.[0];
slot.textContent = message ?? "";
slot.hidden = !message;
});
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
const button = form.querySelector("button");
button.disabled = true;
button.textContent = "Sending";
try {
const response = await fetch(form.action, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});
const result = await response.json();
if (result.ok) {
const thanks = document.createElement("p");
thanks.setAttribute("role", "status");
thanks.textContent = `Thanks, ${form.elements.name.value.trim().split(" ")[0]}. A person on our team will reply to ${form.elements.email.value} within one working day.`;
form.replaceWith(thanks);
return;
}
showErrors(result.error.fields ?? {});
if (!result.error.fields) alert(result.error.message);
} catch {
alert("Something went wrong. Email sales@example.com and we'll pick it up.");
}
button.disabled = false;
button.textContent = "Talk to sales";
});Server Action がシークレットキーを付けてリードを転送し、各フィールドの最初のエラーと訪問者が入力した値を返すので、422 でも何も失われません。
"use server";
import { headers } from "next/headers";
export interface LeadState {
sent: boolean;
firstName?: string;
errors: Record<string, string>;
values: Record<string, string>;
}
const KEYS = ["name", "email", "company", "interest", "message", "page", "_gotcha"] as const;
export async function sendLead(_previous: LeadState, formData: FormData): Promise<LeadState> {
const values = Object.fromEntries(KEYS.map((key) => [key, String(formData.get(key) ?? "").trim()]));
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(values),
});
const result = await response.json().catch(() => null);
if (result?.ok) return { sent: true, firstName: values.name.split(" ")[0], errors: {}, values: {} };
const fields: Record<string, string[]> = result?.error?.fields ?? {};
const errors = Object.fromEntries(Object.entries(fields).map(([key, messages]) => [key, messages[0] ?? ""]));
if (!Object.keys(errors).length) errors.form = result?.error?.message ?? "Something went wrong. Email sales@example.com and we'll pick it up.";
return { sent: false, errors, values };
}"use client";
import { useActionState } from "react";
import { type LeadState, sendLead } from "./actions";
const initial: LeadState = { sent: false, errors: {}, values: {} };
export function LeadForm() {
const [state, action, pending] = useActionState(sendLead, initial);
const { errors, values } = state;
if (state.sent) {
return <p role="status">Thanks, {state.firstName}. A person on our team will reply within one working day.</p>;
}
return (
<form action={action}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required defaultValue={values.name} />
{errors.name ? <p role="alert">{errors.name}</p> : null}
<label htmlFor="email">Work email</label>
<input id="email" name="email" type="email" autoComplete="email" required defaultValue={values.email} />
{errors.email ? <p role="alert">{errors.email}</p> : null}
<label htmlFor="company">Company</label>
<input id="company" name="company" autoComplete="organization" defaultValue={values.company} />
<label htmlFor="interest">What can we help with?</label>
<select id="interest" name="interest" defaultValue={values.interest ?? "pricing"}>
<option value="pricing">Pricing for my team</option>
<option value="demo">A live demo</option>
<option value="custom">A custom plan or contract</option>
<option value="other">Something else</option>
</select>
<label htmlFor="message">Anything we should know?</label>
<textarea id="message" name="message" rows={4} defaultValue={values.message} />
{errors.message ? <p role="alert">{errors.message}</p> : null}
<input type="hidden" name="page" value="pricing" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{errors.form ? <p role="alert">{errors.form}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Talk to sales"}</button>
</form>
);
}Next.js でさらに詳しく知るには Next.js で見込み客を選別するリードフォームを作る を読んでください。
何かがサーバーから出ていく前に Zod がフィールドをチェックし、isInputError でフィールドごとのメッセージを得られます。フォームがアクションに送信できるよう、ページはオンデマンドでレンダリングする必要があります。
import { ActionError, defineAction } from "astro:actions";
import { getSecret } from "astro:env/server";
import { z } from "astro/zod";
export const server = {
sendLead: defineAction({
accept: "form",
input: z.object({
name: z.string().trim().min(1, "Tell us who to reply to."),
email: z.email("Enter a work email we can reply to."),
company: z.string().trim().optional(),
interest: z.enum(["pricing", "demo", "custom", "other"]),
message: z.string().trim().max(5000).optional(),
page: z.string().default("pricing"),
_gotcha: z.string().nullish(),
}),
handler: async (input, context) => {
const origin = context.request.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 ${getSecret("IKIFORM_SECRET_KEY")}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify(input),
});
const result = await response.json();
if (!result.ok) throw new ActionError({ code: "BAD_REQUEST", message: result.error.message });
return { firstName: input.name.split(" ")[0] };
},
}),
};---
import { actions, isInputError } from "astro:actions";
export const prerender = false;
const result = Astro.getActionResult(actions.sendLead);
const fields = result?.error && isInputError(result.error) ? result.error.fields : {};
---
{result?.data ? (
<p role="status">Thanks, {result.data.firstName}. A person on our team will reply within one working day.</p>
) : (
<form method="POST" action={actions.sendLead}>
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required />
{fields.name && <p role="alert">{fields.name[0]}</p>}
<label for="email">Work email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
{fields.email && <p role="alert">{fields.email[0]}</p>}
<label for="company">Company</label>
<input id="company" name="company" autocomplete="organization" />
<label for="interest">What can we help with?</label>
<select id="interest" name="interest">
<option value="pricing">Pricing for my team</option>
<option value="demo">A live demo</option>
<option value="custom">A custom plan or contract</option>
<option value="other">Something else</option>
</select>
<label for="message">Anything we should know?</label>
<textarea id="message" name="message" rows="4"></textarea>
<input type="hidden" name="page" value="pricing" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{result?.error && !isInputError(result.error) && <p role="alert">{result.error.message}</p>}
<button>Talk to sales</button>
</form>
)}Astro でさらに詳しく知るには Astro で見込み客を選別するリードフォームを作る を読んでください。
サーバー関数が .validator() で FormData を読み取って転送し、名前か、ルートが各入力欄の下に描画するフィールドエラーのマップのどちらかを返します。
import { createFileRoute } from "@tanstack/react-router";
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";
type Result = { ok: true; firstName: string } | { ok: false; errors: Record<string, string> };
const sendLead = createServerFn({ method: "POST" })
.validator((data: unknown) => {
if (!(data instanceof FormData)) throw new Error("Expected form data");
const read = (key: string) => String(data.get(key) ?? "").trim();
return {
name: read("name"),
email: read("email"),
company: read("company"),
interest: read("interest"),
message: read("message"),
page: read("page"),
_gotcha: read("_gotcha"),
};
})
.handler(async ({ data }): Promise<Result> => {
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(data),
});
const result = await response.json();
if (result.ok) return { ok: true, firstName: data.name.split(" ")[0] ?? "" };
const fields: Record<string, string[]> = result.error.fields ?? { form: [result.error.message] };
return { ok: false, errors: Object.fromEntries(Object.entries(fields).map(([key, list]) => [key, list[0] ?? ""])) };
});
export const Route = createFileRoute("/contact-sales")({ component: ContactSales });
function ContactSales() {
const send = useServerFn(sendLead);
const [result, setResult] = useState<Result | null>(null);
const [pending, setPending] = useState(false);
const errors = result && !result.ok ? result.errors : {};
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setPending(true);
try {
setResult(await send({ data: new FormData(event.currentTarget) }));
} catch {
setResult({ ok: false, errors: { form: "Something went wrong. Email sales@example.com and we'll pick it up." } });
} finally {
setPending(false);
}
}
if (result?.ok) {
return <p role="status">Thanks, {result.firstName}. A person on our team will reply within one working day.</p>;
}
return (
<form method="post" onSubmit={onSubmit}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
{errors.name ? <p role="alert">{errors.name}</p> : null}
<label htmlFor="email">Work email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
{errors.email ? <p role="alert">{errors.email}</p> : null}
<label htmlFor="company">Company</label>
<input id="company" name="company" autoComplete="organization" />
<label htmlFor="interest">What can we help with?</label>
<select id="interest" name="interest">
<option value="pricing">Pricing for my team</option>
<option value="demo">A live demo</option>
<option value="custom">A custom plan or contract</option>
<option value="other">Something else</option>
</select>
<label htmlFor="message">Anything we should know?</label>
<textarea id="message" name="message" rows={4} />
<input type="hidden" name="page" value="pricing" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{errors.form ? <p role="alert">{errors.form}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Talk to sales"}</button>
</form>
);
}TanStack Start でさらに詳しく知るには TanStack Start で見込み客を選別するリードフォームを作る を読んでください。
ルートのアクションが送信を転送し、data() を通じてフィールドエラーを Ikiform のステータスコードと共に返します。コンポーネントは actionData からそれを読み取ります。
import { data, Form, useNavigation } from "react-router";
import type { Route } from "./+types/contact-sales";
const KEYS = ["name", "email", "company", "interest", "message", "page", "_gotcha"];
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const values = Object.fromEntries(KEYS.map((key) => [key, String(formData.get(key) ?? "").trim()]));
const origin = request.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(values),
});
const result = await response.json();
if (result.ok) return { sent: true as const, firstName: values.name.split(" ")[0] };
const fields: Record<string, string[]> = result.error.fields ?? { form: [result.error.message] };
return data(
{ sent: false as const, errors: Object.fromEntries(Object.entries(fields).map(([key, list]) => [key, list[0]])) },
{ status: response.status },
);
}
export default function ContactSales({ actionData }: Route.ComponentProps) {
const navigation = useNavigation();
const pending = navigation.state === "submitting";
if (actionData?.sent) {
return <p role="status">Thanks, {actionData.firstName}. A person on our team will reply within one working day.</p>;
}
const errors: Record<string, string | undefined> = actionData?.errors ?? {};
return (
<Form method="post">
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
{errors.name ? <p role="alert">{errors.name}</p> : null}
<label htmlFor="email">Work email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
{errors.email ? <p role="alert">{errors.email}</p> : null}
<label htmlFor="company">Company</label>
<input id="company" name="company" autoComplete="organization" />
<label htmlFor="interest">What can we help with?</label>
<select id="interest" name="interest">
<option value="pricing">Pricing for my team</option>
<option value="demo">A live demo</option>
<option value="custom">A custom plan or contract</option>
<option value="other">Something else</option>
</select>
<label htmlFor="message">Anything we should know?</label>
<textarea id="message" name="message" rows={4} />
<input type="hidden" name="page" value="pricing" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{errors.form ? <p role="alert">{errors.form}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Talk to sales"}</button>
</Form>
);
}React Router でさらに詳しく知るには React Router で見込み客を選別するリードフォームを作る を読んでください。
フォームアクションは値とエラーを付けて fail() を返し、update({ reset: false }) により、訪問者が 1 つのフィールドを直している間も入力内容が画面に残ります。$app/env/private から読めるよう、src/env.ts で defineEnvVars を使って IKIFORM_SECRET_KEY を宣言してください。
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import type { Actions } from "./$types";
const KEYS = ["name", "email", "company", "interest", "message", "page", "_gotcha"];
export const actions = {
default: async ({ request, fetch, url }) => {
const data = await request.formData();
const values = Object.fromEntries(KEYS.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(values),
});
const result = await response.json();
if (!result.ok) {
const fields: Record<string, string[]> = result.error.fields ?? { form: [result.error.message] };
const errors = Object.fromEntries(Object.entries(fields).map(([key, list]) => [key, list[0]]));
return fail(response.status, { values, errors });
}
return { firstName: values.name.split(" ")[0] };
},
} satisfies Actions;<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 ?? {});
</script>
{#if form?.firstName}
<p role="status">Thanks, {form.firstName}. A person on our team will reply within one working day.</p>
{:else}
<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={form?.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={form?.values?.email ?? ""} />
{#if errors.email}<p role="alert">{errors.email}</p>{/if}
<label for="company">Company</label>
<input id="company" name="company" autocomplete="organization" value={form?.values?.company ?? ""} />
<label for="interest">What can we help with?</label>
<select id="interest" name="interest" value={form?.values?.interest ?? "pricing"}>
<option value="pricing">Pricing for my team</option>
<option value="demo">A live demo</option>
<option value="custom">A custom plan or contract</option>
<option value="other">Something else</option>
</select>
<label for="message">Anything we should know?</label>
<textarea id="message" name="message" rows="4">{form?.values?.message ?? ""}</textarea>
<input type="hidden" name="page" value="pricing" />
<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>
{/if}SvelteKit でさらに詳しく知るには SvelteKit で見込み客を選別するリードフォームを作る を読んでください。
サーバールートがキーをクライアントから隠し、Ikiform のフィールドエラーをエラーの data で返します。環境変数に NUXT_IKIFORM_SECRET_KEY を設定してください。
export default defineEventHandler(async (event) => {
const body = await readBody<Record<string, string | undefined>>(event);
const config = useRuntimeConfig(event);
const origin = getRequestHeader(event, "origin");
const keys = ["name", "email", "company", "interest", "message", "page", "_gotcha"];
const values = Object.fromEntries(keys.map((key) => [key, String(body[key] ?? "").trim()]));
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${config.ikiformSecretKey}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify(values),
});
const result = await response.json();
if (!result.ok) {
throw createError({
status: response.status,
message: result.error.message,
data: { fields: result.error.fields ?? {} },
});
}
return { firstName: values.name?.split(" ")[0] };
});export default defineNuxtConfig({
runtimeConfig: {
ikiformSecretKey: "",
},
});<script setup lang="ts">
const lead = reactive({ name: "", email: "", company: "", interest: "pricing", message: "", _gotcha: "" });
const errors = ref<Record<string, string[]>>({});
const failure = ref("");
const sending = ref(false);
const firstName = ref("");
async function send() {
sending.value = true;
errors.value = {};
failure.value = "";
try {
const result = await $fetch("/api/lead", { method: "POST", body: { ...lead, page: "pricing" } });
firstName.value = result.firstName ?? "there";
} 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 ?? "Something went wrong. Email sales@example.com and we'll pick it up.";
} finally {
sending.value = false;
}
}
</script>
<template>
<p v-if="firstName" role="status">Thanks, {{ firstName }}. A person on our team will reply within one working day.</p>
<form v-else @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" />
<label for="interest">What can we help with?</label>
<select id="interest" v-model="lead.interest" name="interest">
<option value="pricing">Pricing for my team</option>
<option value="demo">A live demo</option>
<option value="custom">A custom plan or contract</option>
<option value="other">Something else</option>
</select>
<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 でさらに詳しく知るには Nuxt で見込み客を選別するリードフォームを作る を読んでください。
自分のサーバーがない場合、フォームはブラウザーから直接 Ikiform に送信します。このコードにシークレットキーを入れないでください。代わりに Allowed domains がフォームを守ります。
import { type FormEvent, useState } from "react";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
export function LeadForm() {
const [status, setStatus] = useState<"idle" | "sending" | "sent">("idle");
const [errors, setErrors] = useState<Record<string, string[]>>({});
const [firstName, setFirstName] = useState("");
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const body = new FormData(event.currentTarget);
setStatus("sending");
setErrors({});
try {
const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
const result = await response.json();
if (result.ok) {
setFirstName(String(body.get("name")).trim().split(" ")[0] ?? "");
setStatus("sent");
return;
}
setErrors(result.error.fields ?? { form: [result.error.message] });
} catch {
setErrors({ form: ["Something went wrong. Email sales@example.com and we'll pick it up."] });
}
setStatus("idle");
}
if (status === "sent") {
return <p role="status">Thanks, {firstName}. A person on our team will reply within one working day.</p>;
}
return (
<form action={ENDPOINT} method="post" onSubmit={onSubmit}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
{errors.name ? <p role="alert">{errors.name[0]}</p> : null}
<label htmlFor="email">Work email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
{errors.email ? <p role="alert">{errors.email[0]}</p> : null}
<label htmlFor="company">Company</label>
<input id="company" name="company" autoComplete="organization" />
<label htmlFor="interest">What can we help with?</label>
<select id="interest" name="interest">
<option value="pricing">Pricing for my team</option>
<option value="demo">A live demo</option>
<option value="custom">A custom plan or contract</option>
<option value="other">Something else</option>
</select>
<label htmlFor="message">Anything we should know?</label>
<textarea id="message" name="message" rows={4} />
<input type="hidden" name="page" value="pricing" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{errors.form ? <p role="alert">{errors.form[0]}</p> : null}
<button disabled={status === "sending"}>{status === "sending" ? "Sending" : "Talk to sales"}</button>
</form>
);
}同じブラウザー側の送信を Vue コンポーネントにしたものです。フォームは action を持ったままなので、スクリプトの読み込みに失敗した訪問者でも連絡できます。
<script setup lang="ts">
import { ref } from "vue";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const status = ref<"idle" | "sending" | "sent">("idle");
const errors = ref<Record<string, string[]>>({});
const firstName = ref("");
async function send(event: SubmitEvent) {
const body = new FormData(event.target as HTMLFormElement);
status.value = "sending";
errors.value = {};
try {
const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
const result = await response.json();
if (result.ok) {
firstName.value = String(body.get("name")).trim().split(" ")[0] ?? "";
status.value = "sent";
return;
}
errors.value = result.error.fields ?? { form: [result.error.message] };
} catch {
errors.value = { form: ["Something went wrong. Email sales@example.com and we'll pick it up."] };
}
status.value = "idle";
}
</script>
<template>
<p v-if="status === 'sent'" role="status">Thanks, {{ firstName }}. A person on our team will reply within one working day.</p>
<form v-else :action="ENDPOINT" method="post" @submit.prevent="send">
<label for="name">Name</label>
<input id="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" 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" name="company" autocomplete="organization" />
<label for="interest">What can we help with?</label>
<select id="interest" name="interest">
<option value="pricing">Pricing for my team</option>
<option value="demo">A live demo</option>
<option value="custom">A custom plan or contract</option>
<option value="other">Something else</option>
</select>
<label for="message">Anything we should know?</label>
<textarea id="message" name="message" rows="4" />
<input type="hidden" name="page" value="pricing" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="errors.form" role="alert">{{ errors.form[0] }}</p>
<button :disabled="status === 'sending'">{{ status === "sending" ? "Sending" : "Talk to sales" }}</button>
</form>
</template>frm_xxxxxxxxxxxxxxxxをダッシュボードにある自分のフォームの ID に置き換えます。- 非表示の
_gotcha入力欄はハニーポットです。人には見えず、すべての入力欄を埋めるボットは自らスパムだと名乗ることになります。 - 非表示の
page入力欄は、料金ページ、機能ページ、キャンペーンのランディングページなど、リードがどのページから来たかを記録します。フィールドを定義している場合はpageを任意のテキストフィールドとして追加してください。そうしないと送信はUnknown fieldで拒否されます。 - サーバー版は訪問者の
Originヘッダーを転送します。サーバー側の fetch は何も送らず、Ikiform は無料のlocalhostテストをこのヘッダーで判定するからです。 - プレーンな HTML フォームを自分のサンクスページに着地させるには、Redirect after submit か、Allowed domains のいずれかを指す
_redirect入力欄が必要です。
IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。各フレームワークの設定はドキュメントで詳しく解説しています:Next.js、Astro、SvelteKit、Nuxt、React、Vue、サーバーからの送信、プレーンな HTML。
数分以内にリードに連絡する
フォームは簡単な部分です。売れるリードフォームと集めるだけのリードフォームを分けるのは、送信から人による返信までの時間です。うまくいっているときの流れは次のとおりです。
- リードがフォームを送信します。
- Ikiform はそれを保存し、1 秒を大きく下回る時間でブラウザーに応答します。
- 数秒後、通知メールと Webhook がキューから送られます。
- チームの誰かが受信トレイで Reply を押すか、チャットの mailto リンクを押すと、返信がリードに届きます。
受信トレイから返信する
各通知メールは返信先アドレスをリードに設定します。Ikiform はまず _replyto フィールドから、次に email、email_address、your_email、reply_to、contact_email という名前のフィールドから取得します。フィールドの id を email にしておけば、Reply は Ikiform ではなくリードに届きます。件名は New submission: にフォーム名を付けたものなので、Talk to sales のようにフォームの用途に合わせた名前を付け、その件名で絞り込んでリードにラベルやスターを付けましょう。
フォームの通知設定で、フォームごとに最大 10 件の通知先を追加できます。ワークスペースのメンバーか、先にメールで確認する外部アドレスです。誰も担当していない共有エイリアスではなく、実際に返信する人に送ってください。5 つの受信トレイに届いて誰も引き受けないリードは、1 つの受信トレイに届いたリードより長く待たされます。
チームがいる場所に通知する
メールは、誰かが受信トレイを担当していればうまくいきます。チャット中心のチームは、リードがそこに表示されたほうが速く返信します。Slack、Microsoft Teams、Discord の手順に従って Webhook を追加してください。メッセージに mailto: リンクを入れておけば、引き受けた人がワンクリックで返信できます。
尋ねた内容で振り分ける
interest は一覧の値しか受け付けないので、小さなリレーでも安心して分岐できます。このルートはデモの依頼と契約の質問をそれぞれ専用のチャンネルに送り、それ以外はメインの営業チャンネルに送ります。
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
const CHANNELS: Record<string, string | undefined> = {
demo: process.env.SLACK_DEMO_WEBHOOK,
custom: process.env.SLACK_ENTERPRISE_WEBHOOK,
};
export async function POST(request: Request) {
const payload = await request.text();
let event;
try {
event = await verifyWebhook({ payload, headers: request.headers, secret: process.env.IKIFORM_WEBHOOK_SECRET! });
} catch (error) {
if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
throw error;
}
if (event.type !== "submission.created") return new Response(null, { status: 204 });
const lead = event.data.fields;
const interest = String(lead.interest ?? "other");
const target = CHANNELS[interest] ?? process.env.SLACK_SALES_WEBHOOK!;
const text = [
`*${lead.name}* from ${lead.company || "an unknown company"} wants: ${interest}`,
`Reply: mailto:${lead.email}`,
lead.message ? `> ${String(lead.message).slice(0, 300)}` : "",
]
.filter(Boolean)
.join("\n");
const response = await fetch(target, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text }),
});
if (!response.ok) return new Response(`Slack answered ${response.status}`, { status: 502 });
return new Response(null, { status: 204 });
}verifyWebhookが署名とタイムスタンプをチェックするので、他人が偽のリードをチャンネルに投稿することはできません。- Slack が落ちているときに
502を返すと、Ikiform は同じ配信を同じwebhook-idで約 3 日間リトライします。 - 選択肢のラベルは自由に変えて構いません。リレーや自動化が依存したら、その値は変えないでください。
パイプラインにゴミを入れない
リードフォームには特有のスパムが集まります。SEO、被リンク獲得、開発の外注を売り込んでくる人たちです。最速の返信を無駄にするので、誰かに届く前に除外しましょう。
| 防御 | 防げるもの | 設定 |
|---|---|---|
| ハニーポット | すべての入力欄を埋めるボット | 非表示の _gotcha 入力欄 |
| スパムスコア | seo services などの既知の売り込みフレーズ(+2)、名前の中のリンク(+3)、3 つ以上のリンク(+2)、繰り返し(+3)。5 でスパム | なし |
| レート制限 | ループで送信するスクリプト | フォームを Strict(訪問者ごとに 10 分あたり 2 件)に設定します。本物のリードが 10 分間に 2 回送信することはまずありません |
| 許可ドメイン | 他のサイトからの送信 | Allowed domains に自分のドメインを追加する |
| 人間確認 | 本物のブラウザーで動くボット | Spam check を Ikiform に設定します。その場合 fetch 版には challenge() のトークンが必要です |
スパムにも本物のリードと同じ成功の応答が返り、メールも Webhook も送られず、使用量にも数えられません。つまり、誤判定されたリードも静かに埋もれます。週に 1 回は Spam フィルターを確認してください。スパムではないとマークすると、その時点でメールと Webhook が送られます。スパム対策のドキュメントにすべてのシグナルが載っています。
リードの出どころを把握する
各送信には、ページのオリジンとリファラーがすでに記録されており、Webhook には meta.origin と meta.referrer として含まれます。キャンペーンのタグには少し手助けが必要です。訪問者はあるページに着地して、別のページで送信することがよくあるからです。このスニペットは utm_ パラメーターを非表示の入力欄にコピーし、セッションの間覚えておきます。
<input type="hidden" name="utm_source" />
<input type="hidden" name="utm_medium" />
<input type="hidden" name="utm_campaign" />
<script>
const params = new URLSearchParams(location.search);
for (const key of ["utm_source", "utm_medium", "utm_campaign"]) {
let value = params.get(key);
try {
if (value) sessionStorage.setItem(key, value);
else value = sessionStorage.getItem(key);
} catch {}
const input = document.querySelector(`#lead-form [name="${key}"]`);
if (input && value) input.value = value.slice(0, 200);
}
</script>フレームワークでは、同じパラメーターをサーバーで URL から読み取り、非表示の入力欄として描画します。フィールドを定義している場合は、各 utm_ の id を任意のテキストフィールドとして追加してください。_ で始まる名前は保存されないので、残したいデータには使わないでください。
リードが次に目にするもの
Ikiform はフォームに入力した本人にメールを送りません。通知メールはあなたとチームだけに届くので、自動返信、確認メール、ダブルオプトインはありません。それらは Webhook をきっかけにメールツールから送ってください。リードフォームならそれで問題ないことが多いです。自動のメールより、すぐに届く個人的な返信のほうが効果的です。それでも即時の受付通知を送りたいなら、Webhook が連絡先を作成したときに CRM やメールツールから送れば、返信や配信停止を 1 か所で管理できます。
何を送るにしても、ページ上の成功メッセージは約束です。誰がいつまでに返信するかを書き、それを守ってください。1 営業日と書いて 10 分で返信するフォームは信頼を得ます。10 分と書いて翌日に返信するフォームは信頼を失います。
CRM にリードを入れる
ワンクリックの連携はありません。どのツールにも、署名付きの Webhook で直接、または Zapier や n8n などの自動化ツールを経由して届けます。CRM の場合は、リレーが Ikiform の決まった形の Webhook ボディを CRM の連絡先作成の呼び出しに変換するということです。HubSpot や Pipedrive にリードを送るでは、重複とリトライも含めて両方を説明しています。CRM が必要になるまでは、ダッシュボードと毎週の CSV エクスポートで十分なパイプラインになります。
状況に合わせて選ぶ
上の構成はどんなリードにも使えます。以下のページでは、特定の種類のリードで変わる点と、リードを CRM に入れる方法を扱います。
- デモ依頼フォームリードを保存してから、名前とメールアドレスを入力済みの状態で訪問者を Cal.com や Calendly に送るデモ予約フォームです。
- 見積もり依頼フォーム資料をアップロードではなく共有リンクで受け取り、すべての依頼に期限を付け、リンクの多いリードがスパムにならないようにする見積もり依頼フォームです。
- 相談申し込みフォーム会計士、弁護士、コーチ、クリニック向けの相談申し込みフォームです。連絡方法と時間を尋ね、デリケートな詳細は電話のときに回します。
- プロジェクト問い合わせフォームスタジオやフリーランス向けのプロジェクト依頼フォームです。誰かが概要を読む前に、予算と時期で問い合わせを有望、検討、対象外に振り分けます。
- HubSpot や Pipedrive にリードを送る署名付きの Ikiform の Webhook を、HubSpot の連絡先 upsert や Pipedrive の人物とリードの呼び出しに変換し、配信がリトライされても連絡先が重複しないようにします。
- Next.js のリードフォームNext.js App Router のリードフォームです。サーバーアクションで Zod による検証を行い、ファーストタッチの UTM を Cookie に保持し、条件に合うリードをカレンダーに送ります。
- Astro のリードフォームActions で作る Astro のリードフォームです。Zod による検証、静的サイトでのファーストタッチの UTM、オンデマンドの問い合わせページ、日程調整ツールへのリダイレクトを扱います。
- TanStack Start のリードフォームTanStack Start のリードフォームです。ファーストタッチの UTM を記録するグローバルなリクエストミドルウェア、Zod を使うサーバー関数、日程調整ツールへの throw によるリダイレクトを組み合わせます。
- React Router のリードフォームReact Router フレームワークモードのリードフォーム。署名付きのファーストタッチ Cookie を設定するルートのミドルウェア、検証するルートの action、日程調整ツールへのリダイレクトを組み合わせます。
- SvelteKit のリードフォームSvelteKit のリードフォーム。handle フックでファーストタッチの UTM を記録し、フォームアクションで Valibot による検証を行い、redirect() で条件に合うリードをカレンダーに送ります。
- Nuxt のリードフォームNuxt のリードフォーム。サーバーミドルウェアが初回接触の UTM を記録し、Zod で検証する 1 つのサーバールートが fetch とプレーンな送信の両方に応え、有望なリードは商談を予約します。
動作を確認する
ページに集客する前に、これを一通り確認してください。Ikiform に無料プランはありません。テストは無料です。プランのないワークスペースでも、localhost からのテスト送信を月 50 件まで受け付けます。本番運用するには、500 件で月 $6 からの Pro を選ぶか、買い切りにしてください。10,000 件で $49、または 50,000 件で $149 で、クレジットに有効期限はありません。料金を参照してください。
- フォームの Allowed domains に自分のサイトのドメインを追加し、開発中は
localhostも追加します。空のままにすると、どこからの送信も受け付けます。 - ページのフォームに入力して送信します。自分のサンクスページか、Ikiform のサンクスページに移動します。
- ダッシュボードで Submissions を開きます。送ったすべてのフィールドを含む新しい送信データがあります。
- 各受信者の受信トレイを確認します。件名は
New submission:にフォーム名が続いたもので、Reply を押すと入力したアドレス宛てのメッセージが開きます。 - Webhook を追加した場合は、Webhooks でエンドポイントを開きます。配信が
succeededになり、サーバーの応答が表示されます。 - ハニーポットを埋めて
_gotcha=botで送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。 - 何も届かない場合は、ダッシュボードのステータスフィルターを Spam に切り替えてください。スパムにも本物の送信と同じ成功の応答が返ります。
- 時間を測ります。スマートフォンから送信し、メールとチャットのメッセージが届くまでの時間を記録します。
トラブルシューティング
| 問題 | 対処法 |
|---|---|
| Reply の宛先がリードではなく Ikiform のアドレスになる | フォームに、Ikiform がリードのメールアドレスと認識するフィールドがありません。フィールド名を email に変えるか、アドレスを入れた _replyto フィールドを送ってください。認識される名前:email、email_address、your_email、reply_to、contact_email。 |
page や utm_source で Unknown field 付きの 422 になる | フォームにフィールド定義があり、非表示の入力欄がそこに含まれていません。任意のテキストフィールドとして追加するか、入力欄を削除してください。 |
422 validation_failed | 送信がフォームのフィールド定義に合っていません。error.fields に各フィールドと理由が示されます。入力欄の name をフィールド ID に合わせてください。 |
403 origin_not_allowed が返る、またはすべての送信がスパムになる | ページのドメインがフォームの Allowed domains にありません。ドメインと、ローカルテスト用の localhost を追加してください。登録したドメインのサブドメインは許可されます。 |
429 rate_limited | 1 人の訪問者がフォームのレート制限を超えて送信しました。Standard では訪問者ごとに 1 分あたり 5 件までです。Retry-After まで待つか、共有の IP 1 つから送信するフォームには Relaxed か Custom を選んでください。 |
403 challenge_required | フォームのスパムチェックが Ikiform に設定されていて、fetch の送信にトークンがありませんでした。送信のたびに @ikiform/sdk の challenge(formId) でトークンを取得するか、チェックをオフにしてください。 |
| 送信データはダッシュボードにあるのに、メールも Webhook も届かない | スパムとして保存されています。スパムでは何も送られません。Spam フィルターで開いて理由を確認し、スパムではないとマークすると、メールと Webhook が送られます。 |
| 受信者に通知メールが届かない | ワークスペース外のアドレスは、Ikiform が送るメールから先に確認を済ませる必要があります。確認済みかどうかを確かめ、次に受信者の迷惑メールフォルダーを確認してください。 |
よくある質問
見込み客の連絡先と要望を集め、チームがフォローアップできるようにする、ウェブサイト上のフォームです。一般的なお問い合わせフォームとの違いは、たいてい選択式の質問 1 つで見込み度を判断し、営業にすばやく届くよう組まれていることです。
フォームを Ikiform のエンドポイントに向け、そのフォームの通知メールをオンのままにします。各リードは数秒で最大 10 人の受信者に届き、メールのフィールド名が email であるか、フォームが _replyto を送っていれば、Reply を押すとリードに返信できます。
きちんと返信できる最小限にします。たいていは名前、メールアドレス、見込み度を判断する質問 1 つ、任意のメッセージです。電話番号、予算、チームの規模は、その答えでチームの次の行動が変わるときだけ追加してください。
Webhook 経由なら送れます。ワンクリックの連携はないので、小さなリレーか、Zapier、Make、n8n などの自動化ツールが署名付きの各 Webhook を CRM の API に変換し、連絡先やリードを作成します。
Ikiform からは届きません。Ikiform がメールを送るのはチームで、フォームに入力した人ではありません。受付の連絡は Webhook をきっかけに CRM やメールツールから送るか、通知メールから個人的に返信してください。
組み込みの設定ではできません。メールのフィールドの許可ドメインのオプションは許可リストで、公開フォームではなく社内向けのフォームに向いています。すべてのアドレスを受け付け、フリーメールのリードはリレーか CRM で振り分けてください。個人のアドレスを使う本物の購入検討者も大勢います。
いいえ、無料プランはありません。テストは無料で、支払う前に localhost から月 50 件のテスト送信ができます。本番運用するには、月 $6 で 500 件からの Pro か、1 回払いで 10,000 件 $49 または 50,000 件 $149 のパックを選べます。クレジットに有効期限はありません。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com