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

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

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

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

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

## Astro で変わること

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

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

図: Astro がリードフォームの各部分をどこで動かすか

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 のドキュメントページ](https://ikiform.com/ja/docs/guides/astro.md)にあります。

`ファイル`:

```text
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`:

```js
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 が同じルールをチェックし、それ以外のものを拒否します。

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

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

`src/components/FirstTouch.astro`:

```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`:

```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`:

```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 を追加する](https://ikiform.com/ja/guides/spam-protection/cloudflare-turnstile.md)のようにハンドラーで 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 のパックを購入してください。パックのクレジットに有効期限はありません。[料金](https://ikiform.com/ja/pricing)を参照してください。

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 に合わせてください。 |

## よくある質問

### 静的な Astro サイトで UTM パラメーターを記録できますか？

はい。ただしミドルウェアではできません。プリレンダーされたページではミドルウェアはビルド時に動くからです。レイアウトに置いた小さなスクリプトが最初の訪問でタグとリファラーを読んで Cookie に保存し、リードが送信されたときにアクションがその Cookie を読みます。

### Astro のフォームアクションは JavaScript なしで動きますか？

はい。method が POST で action にアクションを指定したフォームは、素の HTML の方法で送信します。ページはオンデマンドで描画する必要があり、Astro.getActionResult() で結果を読みます。

### Astro Action が成功したあとにリダイレクトするには？

ハンドラーから URL を返し、ページのフロントマターで Astro.getActionResult() を確認して、エラーがなければその URL で Astro.redirect() を返します。Cal.com のリンクのような絶対 URL も使えます。

### Ikiform は無料ですか？

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

## その他のガイド

- [リード獲得フォーム](https://ikiform.com/ja/guides/lead-capture.md): 自分のサイトに置くリード獲得フォームです。返信先付きでメールを送り、チームチャットに通知し、CRM に流し込むので、見込み客がまだいるうちに返信できます。
- [Next.js のリードフォーム](https://ikiform.com/ja/guides/lead-capture/nextjs.md): Next.js App Router のリードフォームです。サーバーアクションで Zod による検証を行い、ファーストタッチの UTM を Cookie に保持し、条件に合うリードをカレンダーに送ります。
- [SvelteKit のリードフォーム](https://ikiform.com/ja/guides/lead-capture/sveltekit.md): SvelteKit のリードフォーム。handle フックでファーストタッチの UTM を記録し、フォームアクションで Valibot による検証を行い、redirect() で条件に合うリードをカレンダーに送ります。
- [デモ依頼フォーム](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): 会計士、弁護士、コーチ、クリニック向けの相談申し込みフォームです。連絡方法と時間を尋ね、デリケートな詳細は電話のときに回します。
