リード獲得フォーム

リード獲得フォーム3 分で読めます

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

Actions で作る Astro のリードフォームです。Zod による検証、静的サイトでのファーストタッチの UTM、オンデマンドの問い合わせページ、日程調整ツールへのリダイレクトを扱います。

Preet SutharIkiform 創業者

プリレンダーされたページのミドルウェアはビルド時にしか動かないので、マーケティングのページはプリレンダーのままにし、最初の訪問の UTM タグとリファラーはクライアントのスクリプトから小さな Cookie に記録します。問い合わせページはオンデマンドで描画し、Astro Action で astro/zod を使ってリードを検証し、流入元の情報と一緒にフォームバックエンドへ転送します。アクションから次の URL を返し、Astro.redirect() を呼んで条件に合うリードを日程調整ツールに送ります。

メインガイドの Astro タブでは、アクションでリードを送信してお礼の一文を表示します。このページではそれを営業向けのフォームにします。サーバーで Zod がチェックする会社の規模、予算、時期、静的サイトでもなくならないキャンペーンのデータ、条件に合うリードのカレンダーへの引き渡しです。

Astro で変わること

Astro はデフォルトでページをプリレンダーし、それが流入元の記録のすべてを左右します。プリレンダーされたページのミドルウェアは、ビルド時に 1 回だけ、訪問者も Cookie もクエリ文字列もない状態で動きます。?utm_source= が実際に付くランディングページはほぼ必ずプリレンダーされているので、ミドルウェアがタグを目にすることはありません。

そこでファーストタッチは、レイアウトに置いた短いスクリプトでブラウザー側に記録し、サーバーでアクションが読み戻します。Astro のドキュメントでは、フォームからアクションを呼ぶページはオンデマンドで描画する必要があるとされているので、オンデマンドで描画しなければならないのは問い合わせページだけです。

Astro がリードフォームの各部分をどこで動かすか流入元の情報送信ミドルウェア1 回だけ、訪問者なしFirstTouchスクリプト?utm_ とリファラーを読むlead_sourceCookie送信と一緒に送るフォームの POSTaction={actions.submitLead}Astro ActionZod、Cookie を読むAstro.redirectCal.com か/thanks同じページ各フィールドの下にメッセージ設定フォームのデータresult.data.nextisInputErrorビルド時ブラウザー内オンデマンドAstro がリードフォームの各部分をどこで動かすか流入元の情報送信ミドルウェア1 回だけ、訪問者なしFirstTouchスクリプト?utm_ とリファラーを読むlead_sourceCookie送信と一緒に送るフォームの POSTaction={actions.submitLead}Astro ActionZod、Cookie を読むAstro.redirectCal.com か /thanks同じページ各フィールドの下にメッセージ設定フォームのデータresult.data.nextisInputError
  1. ビルド時、ミドルウェアはプリレンダーされたページに対してリクエストのデータなしで動くので、訪問を記録できません。
  2. ブラウザーでは、FirstTouch スクリプトが最初のページビューで UTM タグとリファラーを読みます。
  3. それらを lead_source Cookie に 1 回だけ保存し、以降のすべてのリクエストがそれを運びます。
  4. 問い合わせフォームは素の HTML フォームでアクションに送信し、JavaScript は要りません。
  5. アクションが Zod で検証し、Cookie を読み、リードを Ikiform に転送します。
  6. ページが結果を読み、日程調整ツールかサンクスページにリダイレクトします。入力エラーはそれぞれのフィールドの下に表示されます。
サーバーが必要なのは問い合わせページだけです。訪問者がその前に読むものはすべて静的なままです。

プロジェクトの準備

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
.env
astro.config.mjs
import { 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 が同じルールをチェックし、それ以外のものを拒否します。

フィールド IDIkiform での型設定元
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 のレート制限を選び、IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。

静的サイトでのファーストタッチ

src/components/FirstTouch.astro
<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

src/actions/index.ts
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 になるので、ページは混雑しているフォームと不正な入力を区別できます。

問い合わせページ

src/pages/contact-sales.astro
---
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 のパックを購入してください。パックのクレジットに有効期限はありません。料金を参照してください。

  1. astro dev を実行して /pricing?utm_source=test&utm_campaign=launch を開き、開発者ツールで lead_source Cookie を確認します。
  2. /contact-sales に移動し、JavaScript をオフにして Budget を空のまま送信します。予算のエラー付きでページが再読み込みされます。
  3. 条件に合うリードとして送信します。11〜50 人、$50k 超、来四半期。名前とメールアドレスが入力済みの状態で Cal.com に移動します。
  4. Researching for now で送信します。/contact-sales/thanks に移動します。
  5. ダッシュボードで Submissions を開き、両方のリードの utm_source、referrer、landing_page、fit を確認します。
  6. 通知の各受信者の受信トレイを確認します。それぞれにフィールド入りのメールが届き、フォームに 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 に合わせてください。

よくある質問

手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com