リード獲得フォーム3 分で読めます
Astro で見込み客を選別するリードフォームを作る
Actions で作る Astro のリードフォームです。Zod による検証、静的サイトでのファーストタッチの UTM、オンデマンドの問い合わせページ、日程調整ツールへのリダイレクトを扱います。
プリレンダーされたページのミドルウェアはビルド時にしか動かないので、マーケティングのページはプリレンダーのままにし、最初の訪問の UTM タグとリファラーはクライアントのスクリプトから小さな Cookie に記録します。問い合わせページはオンデマンドで描画し、Astro Action で astro/zod を使ってリードを検証し、流入元の情報と一緒にフォームバックエンドへ転送します。アクションから次の URL を返し、Astro.redirect() を呼んで条件に合うリードを日程調整ツールに送ります。
メインガイドの Astro タブでは、アクションでリードを送信してお礼の一文を表示します。このページではそれを営業向けのフォームにします。サーバーで Zod がチェックする会社の規模、予算、時期、静的サイトでもなくならないキャンペーンのデータ、条件に合うリードのカレンダーへの引き渡しです。
Astro で変わること
Astro はデフォルトでページをプリレンダーし、それが流入元の記録のすべてを左右します。プリレンダーされたページのミドルウェアは、ビルド時に 1 回だけ、訪問者も Cookie もクエリ文字列もない状態で動きます。?utm_source= が実際に付くランディングページはほぼ必ずプリレンダーされているので、ミドルウェアがタグを目にすることはありません。
そこでファーストタッチは、レイアウトに置いた短いスクリプトでブラウザー側に記録し、サーバーでアクションが読み戻します。Astro のドキュメントでは、フォームからアクションを呼ぶページはオンデマンドで描画する必要があるとされているので、オンデマンドで描画しなければならないのは問い合わせページだけです。
- ビルド時、ミドルウェアはプリレンダーされたページに対してリクエストのデータなしで動くので、訪問を記録できません。
- ブラウザーでは、FirstTouch スクリプトが最初のページビューで UTM タグとリファラーを読みます。
- それらを lead_source Cookie に 1 回だけ保存し、以降のすべてのリクエストがそれを運びます。
- 問い合わせフォームは素の HTML フォームでアクションに送信し、JavaScript は要りません。
- アクションが Zod で検証し、Cookie を読み、リードを Ikiform に転送します。
- ページが結果を読み、日程調整ツールかサンクスページにリダイレクトします。入力エラーはそれぞれのフィールドの下に表示されます。
プロジェクトの準備
npx astro add vercel(または netlify、cloudflare)でアダプターを追加し、astro:env でキーを宣言します。これでキーがなければ Astro はビルドを拒否し、ブラウザーに送られることもありません。フォームとキーの作り方は Astro のドキュメントページにあります。
src/
actions/index.ts
components/FirstTouch.astro
layouts/Base.astro
pages/
pricing.astro
contact-sales.astro
contact-sales/thanks.astro
astro.config.mjs
.envimport { defineConfig, envField } from "astro/config";
import vercel from "@astrojs/vercel";
export default defineConfig({
adapter: vercel(),
env: {
schema: {
IKIFORM_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
},
},
});output を設定しなければ、すべてのページはプリレンダーのままで、prerender = false をエクスポートしたページだけがサーバーで動きます。下のフィールドをフォームの 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_SECRET_KEY はサーバー専用の環境変数に置いてください。静的サイトでのファーストタッチ
<script>
const TAGS = ["utm_source", "utm_medium", "utm_campaign"];
if (!document.cookie.split("; ").some((pair) => pair.startsWith("lead_source="))) {
const params = new URLSearchParams(location.search);
const from = URL.canParse(document.referrer) ? new URL(document.referrer) : null;
const external = from && from.host !== location.host ? from.href.slice(0, 300) : "";
const source = new URLSearchParams();
for (const tag of TAGS) source.set(tag, (params.get(tag) ?? "").slice(0, 100));
if (external || TAGS.some((tag) => source.get(tag))) {
source.set("referrer", external);
source.set("landing_page", location.pathname.slice(0, 200));
document.cookie = `lead_source=${encodeURIComponent(source.toString())}; path=/; max-age=2592000; samesite=lax`;
}
}
</script>- すべてのページが使うレイアウトに
<FirstTouch />を置きます。Astro はスクリプトを 1 回だけバンドルしてページの読み込みごとに実行し、Cookie のチェックによって最初の訪問だけが数えられます。 - スクリプトで設定するので、Cookie は
httpOnlyではありません。ヒントとして扱ってください。アクションは決まった 5 つのキーしか読まず、Ikiform はすべてのフィールドの長さに上限を設けています。 - 自分のホストからのリファラーは無視されるので、Pricing から Contact へのクリックで本当の流入元が上書きされることはありません。
- ランディングページをオンデマンドで描画しているなら、同じロジックを
src/middleware.tsにcontext.cookies.set()で書けます。先にcontext.isPrerenderedを確認してスキップしてください。
Action
import { ActionError, defineAction } from "astro:actions";
import { IKIFORM_SECRET_KEY } from "astro:env/server";
import { z } from "astro/zod";
const SOURCE = ["utm_source", "utm_medium", "utm_campaign", "referrer", "landing_page"];
export const server = {
submitLead: defineAction({
accept: "form",
input: 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).nullish(),
_gotcha: z.string().nullish(),
}),
handler: async (lead, context) => {
const stored = new URLSearchParams(context.cookies.get("lead_source")?.value ?? "");
const source = Object.fromEntries(SOURCE.map((key) => [key, stored.get(key) ?? ""]));
const qualified =
lead.company_size !== "1_10" &&
["10k_50k", "50k_plus"].includes(lead.budget) &&
["this_quarter", "next_quarter"].includes(lead.timeline);
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 ${IKIFORM_SECRET_KEY}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({ ...lead, ...source, fit: qualified ? "qualified" : "nurture" }),
});
const result = await response.json().catch(() => null);
if (!result?.ok) {
throw new ActionError({
code: response.status === 429 ? "TOO_MANY_REQUESTS" : "BAD_REQUEST",
message: result?.error?.message ?? "We couldn't send that. Email sales@example.com instead.",
});
}
if (!qualified || lead._gotcha) return { next: "/contact-sales/thanks" };
return { next: `https://cal.com/acme/intro?${new URLSearchParams({ name: lead.name, email: lead.email })}` };
},
}),
};accept: "form"を指定すると、Astro は空の入力を Zod に渡す前にnullにします。そのため選ばれていないselectは自分で書いたメッセージでz.enumに失敗し、任意のテキストにはnullish()が必要です。- サーバー側の fetch は Origin を送らず、Ikiform は無料の
localhostテストをそこから判別するので、ブラウザーのOriginヘッダーを転送します。astro devではhttp://localhost:4321です。 - アクションは自分でリダイレクトせず、次の行き先を返します。アクションは
redirectを呼べないので、ページが結果を使ってリダイレクトします。 - Ikiform の
429はTOO_MANY_REQUESTSになるので、ページは混雑しているフォームと不正な入力を区別できます。
問い合わせページ
---
import { actions, isInputError } from "astro:actions";
import Base from "../layouts/Base.astro";
export const prerender = false;
const result = Astro.getActionResult(actions.submitLead);
if (result && !result.error) return Astro.redirect(result.data.next, 303);
const fields = result?.error && isInputError(result.error) ? result.error.fields : {};
const failure = result?.error && !isInputError(result.error) ? result.error.message : "";
---
<Base title="Talk to sales">
<form method="POST" action={actions.submitLead}>
<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" required />
{fields.company && <p role="alert">{fields.company[0]}</p>}
<label for="company_size">Company size</label>
<select id="company_size" name="company_size" required>
<option value="">Choose one</option>
<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_1000">201 to 1,000</option>
<option value="1000_plus">More than 1,000</option>
</select>
{fields.company_size && <p role="alert">{fields.company_size[0]}</p>}
<label for="budget">Budget</label>
<select id="budget" name="budget" required>
<option value="">Choose one</option>
<option value="under_10k">Under $10k</option>
<option value="10k_50k">$10k to $50k</option>
<option value="50k_plus">More than $50k</option>
<option value="unsure">Not sure yet</option>
</select>
{fields.budget && <p role="alert">{fields.budget[0]}</p>}
<label for="timeline">When do you want to start?</label>
<select id="timeline" name="timeline" required>
<option value="">Choose one</option>
<option value="this_quarter">This quarter</option>
<option value="next_quarter">Next quarter</option>
<option value="later">Later this year</option>
<option value="researching">Researching for now</option>
</select>
{fields.timeline && <p role="alert">{fields.timeline[0]}</p>}
<label for="message">Anything we should know?</label>
<textarea id="message" name="message" rows="4"></textarea>
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{failure && <p role="alert">{failure}</p>}
<button>Talk to sales</button>
</form>
</Base>
<script>
const form = document.querySelector("form");
form?.addEventListener("submit", () => {
const button = form.querySelector("button");
if (button) {
button.disabled = true;
button.textContent = "Sending";
}
});
</script>フォームは素の HTML の方法で送信するので、JavaScript がオフでも動きます。Astro がアクションを実行し、その結果でこの同じページを描画し、フロントマターが 303 でリダイレクトするかエラーを描画します。isInputError は Zod のフィールドごとのメッセージとアクション自身の失敗を区別します。小さなスクリプトはボタンを無効にするだけなので、ダブルクリックでリードが二重に送られることはありません。
ハニーポットと人間確認
ハニーポットはスキーマの一部として転送されるので、Ikiform は埋まったものをスパムとして保存し、アクションは送信者をカレンダーではなくサンクスページに送ります。シークレットキーを使うと Ikiform 自身の人間確認はスキップされます。ボットがページを見つけたら、フォームに Turnstile を追加するのようにハンドラーで Turnstile のトークンを検証してください。
デプロイの注意点
| ホスト | 知っておくこと |
|---|---|
| Vercel | @astrojs/vercel は問い合わせページとアクションを関数として動かし、それ以外を静的ファイルとして配信します。プロジェクトの環境変数に IKIFORM_SECRET_KEY を追加します。 |
| Netlify | @astrojs/netlify は Netlify Functions で同じことをします。キーはサイトの環境変数に設定します。 |
| Cloudflare | @astrojs/cloudflare はオンデマンドのページを Worker で動かします。キーは wrangler secret put でシークレットとして保存します。astro:env が実行時にそれを読みます。 |
動作を確認する
astro dev からの送信は localhost のオリジンを持ちます。Ikiform に無料プランはありません。テストは無料で、プランのないワークスペースは localhost からのテスト送信を月 50 件まで受け付けます。本番運用するには、月 $6 から 500 件の Pro を選ぶか、買い切りで 10,000 件 $49 または 50,000 件 $149 のパックを購入してください。パックのクレジットに有効期限はありません。料金を参照してください。
astro devを実行して/pricing?utm_source=test&utm_campaign=launchを開き、開発者ツールでlead_sourceCookie を確認します。/contact-salesに移動し、JavaScript をオフにして Budget を空のまま送信します。予算のエラー付きでページが再読み込みされます。- 条件に合うリードとして送信します。11〜50 人、$50k 超、来四半期。名前とメールアドレスが入力済みの状態で Cal.com に移動します。
- Researching for now で送信します。
/contact-sales/thanksに移動します。 - ダッシュボードで Submissions を開き、両方のリードの
utm_source、referrer、landing_page、fitを確認します。 - 通知の各受信者の受信トレイを確認します。それぞれにフィールド入りのメールが届き、フォームに
emailフィールドがあれば Reply で送信者に返信できます。
ハマりどころ
| 問題 | 対処法 |
|---|---|
| フォームを送信してもページに何も変化がない | 問い合わせページがプリレンダーされています。export const prerender = false を追加してください。Astro のドキュメントでは、フォームアクションにはオンデマンドの描画が必要とされています。 |
| エラーのあとすべてのフィールドが空になる | Astro はフォームの送信で入力欄をクリアします。残すには、<ClientRouter /> でビュートランジションを有効にし、各入力欄に transition:persist を追加してください。 |
| リンクにタグがあったのに UTM のフィールドが空になる | 以前の訪問で Cookie がすでに存在していたか、<FirstTouch /> のあるレイアウトがランディングページで使われていません。Cookie を消して試し直してください。 |
ビルド時に IKIFORM_SECRET_KEY is missing が出る | astro:env がスキーマを検証しています。ローカルでは .env に、本番ではホストの設定に変数を設定してください。 |
422 validation_failed | 送信がフォームのフィールド定義に合っていません。error.fields に各フィールドと理由が示されます。入力欄の name をフィールド ID に合わせてください。 |
よくある質問
はい。ただしミドルウェアではできません。プリレンダーされたページではミドルウェアはビルド時に動くからです。レイアウトに置いた小さなスクリプトが最初の訪問でタグとリファラーを読んで Cookie に保存し、リードが送信されたときにアクションがその Cookie を読みます。
はい。method が POST で action にアクションを指定したフォームは、素の HTML の方法で送信します。ページはオンデマンドで描画する必要があり、Astro.getActionResult() で結果を読みます。
ハンドラーから URL を返し、ページのフロントマターで Astro.getActionResult() を確認して、エラーがなければその URL で Astro.redirect() を返します。Cal.com のリンクのような絶対 URL も使えます。
いいえ、無料プランはありません。テストは無料で、支払う前に localhost から月 50 件のテスト送信ができます。本番運用するには、月 $6 で 500 件からの Pro か、1 回払いで 10,000 件 $49 または 50,000 件 $149 のパックを選べます。クレジットに有効期限はありません。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com