# Astro でウェイトリストを作る

> 静的なランディングページに Astro のウェイトリストを。Action が登録を転送し、スクリプトがそれを呼び、オンデマンドの 1 ページが JavaScript なしの送信を受け止めます。

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

ランディングページはプリレンダーのままにし、サーバー側の処理は、メールアドレスを検証してシークレットキーでフォームバックエンドに転送する Astro Action にまとめます。短いスクリプトがその Action を呼ぶので訪問者はページにとどまり、フォームの通常の `action` は、JavaScript が読み込まれていないときに同じ Action を実行するオンデマンドの 1 ページを指します。

[メインガイド](https://ikiform.com/ja/guides/waitlist.md#form)には短い Astro のタブがあります。Action が 1 つ、フォームが 1 つ、オンデマンドでレンダリングするページが 1 つです。それでも動きますが、多くの人が Astro を選ぶ理由を手放すことになります。ランディングページこそ CDN から静的 HTML として配信したいページなのに、Astro のフォーム Action を使うとリクエストごとにレンダリングされてしまいます。このページでは、ランディングページを静的なまま保ち、登録だけをサーバーに移します。

## Astro ならではの違い

Astro はデフォルトですべてのページをプリレンダーします。これはウェイトリストに、他のフレームワークにはない 2 つの影響を与えます:

|   | プリレンダーしたランディングページ | オンデマンドでレンダリングするページ |
| --- | --- | --- |
| 配信元 | CDN からファイルとして | アダプターのサーバーまたは関数 |
| `<form action={actions.x}>` | 使えない。Astro はページをオンデマンドにする必要がある | JavaScript なしで動く |
| URL の `?ref=` | サーバーからは見えない。クエリなしで 1 回だけビルドされる | `Astro.url` から読める |
| `Astro.clientAddress` | 使えない | 使える |

そのため、ランディングページではフォーム Action のパターンを使えず、ビルド時に `?ref=` も読めません。解決策は役割を分けることです。静的なページがフォームとスクリプトを持ち、Action がサーバーの処理を行い、オンデマンドでレンダリングする小さな 1 ページ `/join` が JavaScript なしで届いた送信を受け止めます。

図: 静的なランディングページから 1 つの Astro Action に入る 2 つの経路

1. ランディングページは CDN から配信されるプレーンな HTML です。スクリプトが読み込まれると、?ref= や ?utm_source= を hidden の ref 入力欄にコピーします。
2. JavaScript があれば、スクリプトが通常の送信を止め、フォームのデータで actions.joinWaitlist を呼びます。Action はサーバーで実行され、登録を Ikiform に転送します。
3. スクリプトがフォームをお礼の一文に置き換えるか、入力欄の下にエラーを表示します。
4. JavaScript がなければ、ブラウザーはオンデマンドでレンダリングされる /join にフォームを送信します。
5. /join は Astro.callAction で同じ Action を呼びます。成功すると 303 で /thanks にリダイレクトするので、再読み込みしても再送信されません。エラーのときはメッセージ付きでフォームを表示します。

Action は 1 つ、入り口は 2 つ。サーバーが必要なのは /join と Action だけです。

## プロジェクトを準備する

フォームやシークレットキーの作成など、製品側の設定は [Astro のドキュメントガイド](https://ikiform.com/ja/docs/guides/astro.md)にあります。このページで使うファイル:

`プロジェクト`:

```text
src/
  actions/index.ts
  components/WaitlistForm.astro
  pages/index.astro
  pages/join.astro
  pages/thanks.astro
astro.config.mjs
.env
```

### アダプターと型付きのシークレット

Action はリクエスト時にサーバーで実行されるので、ほとんどのページが静的なままでもプロジェクトにはアダプターが必要です。`astro add` がアダプターをインストールし、設定を編集します。ホストに合わせて `node` を `vercel`、`netlify`、`cloudflare` に替えてください。

`ターミナル`:

```bash
npx astro add node
```

`astro.config.mjs`:

```js
import { defineConfig, envField } from "astro/config";
import node from "@astrojs/node";

export default defineConfig({
  adapter: node({ mode: "standalone" }),
  env: {
    schema: {
      IKIFORM_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
    },
  },
});
```

`envField` に `access: "secret"` を付けると、`IKIFORM_SECRET_KEY` は `astro:env/server` からしかインポートできなくなります。クライアントのスクリプトでインポートするとビルドが失敗しますが、それが狙いです。キーは **API keys** で `submissions:write` スコープを付けて作成します。

`.env`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
```

> サーバー側のバージョンはシークレットキーを付けて送信するので、Ikiform は **Allowed domains** とその人間確認をスキップします。その場合、すべての送信がサーバーの IP から届くため、訪問者ごとに 1 分あたり 5 件という Standard の制限が全訪問者の合計に適用されてしまいます。サーバー経由で送信するフォームには Relaxed か Custom の[レート制限](https://ikiform.com/ja/docs/rate-limits.md)を選び、`IKIFORM_SECRET_KEY` はサーバー専用の環境変数に置いてください。

### フィールドの定義

Action は `email` と `ref` を送ります。フォームの **Fields** タブでフィールドを定義する場合は両方を含めてください。含めないと、Ikiform は `422` と `unknown_field` で送信を拒否します:

`フィールド`:

```json
{
  "version": 1,
  "title": "Waitlist",
  "fields": [
    { "id": "email", "type": "email", "label": "Email", "required": true, "placeholder": "you@company.com" },
    { "id": "ref", "type": "text", "label": "Source", "validation": { "max_length": 40 } }
  ]
}
```

## 作ってみる

### 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 = /^[a-z0-9_-]{1,40}$/;

export const server = {
  joinWaitlist: defineAction({
    accept: "form",
    input: z.object({
      email: z.email("Enter the email we should send your invite to."),
      ref: z
        .string()
        .nullish()
        .transform((value) => (value && SOURCE.test(value) ? value : "direct")),
      _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 ${IKIFORM_SECRET_KEY}`,
          ...(origin ? { Origin: origin } : {}),
        },
        body: JSON.stringify({
          email: input.email,
          ref: input.ref,
          _gotcha: input._gotcha ?? "",
        }),
        signal: AbortSignal.timeout(8000),
      }).catch(() => null);

      if (!response) {
        throw new ActionError({ code: "GATEWAY_TIMEOUT", message: "We couldn't reach the list. Try again." });
      }
      if (response.status === 429) {
        throw new ActionError({ code: "TOO_MANY_REQUESTS", message: "Lots of people are joining. Try again in a minute." });
      }
      const result = await response.json().catch(() => null);
      if (result?.ok) return { joined: true };
      throw new ActionError({
        code: "BAD_REQUEST",
        message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "Couldn't join the list. Try again.",
      });
    },
  }),
};
```

- `accept: "form"` は `FormData` を解析します。空の入力欄は `null` で届くので、`ref` とハニーポットは `nullish()` にし、`ref` がなければ `direct` にします。
- `ref` は英小文字、数字、`-`、`_` からなる 40 文字以内でなければなりません。それ以外は `direct` として保存されるので、細工されたリンクでエクスポートがゴミで埋まることはありません。上限は定義の `max_length` と一致させています。
- `z.email()` は Zod 4 のトップレベルのメールスキーマで、`astro/zod` からエクスポートされています。不正なアドレスはここで失敗し、Ikiform には届きません。
- ブラウザーは、スクリプトの呼び出しでも通常の送信でも `Origin` ヘッダーを送ります。Ikiform は無料のテスト送信をこのヘッダーで判定しますが、Action 自身の `fetch` はこれを送らないので、Action が引き継いで渡します。
- ハニーポットはそのまま渡すので、ハニーポットを埋めたボットは引き続き Ikiform がスパムとして扱います。シークレットキー付きの送信は Ikiform の人間確認をスキップするため、ここで **Spam check** をオンにしても意味はありません。関門になるのは Action です。
- 失敗するたびに、原因に対応するコード付きの `ActionError` を投げます。スクリプトと `/join` はどちらも `error.message` を読みます。

### フォームコンポーネント

`src/components/WaitlistForm.astro`:

```astro
---
interface Props {
  error?: string;
}

const { error = "" } = Astro.props;
---

<form method="POST" action="/join" data-waitlist>
  <label>
    Email
    <input name="email" type="email" autocomplete="email" required placeholder="you@company.com" />
  </label>
  <input type="hidden" name="ref" value="direct" />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <p role="alert">{error}</p>
  <button>Join the waitlist</button>
</form>

<script>
  import { actions, isInputError } from "astro:actions";

  const query = new URLSearchParams(location.search);
  const source = (query.get("ref") ?? query.get("utm_source") ?? "").toLowerCase().slice(0, 40);

  for (const form of document.querySelectorAll<HTMLFormElement>("form[data-waitlist]")) {
    const ref = form.elements.namedItem("ref") as HTMLInputElement;
    const alert = form.querySelector<HTMLElement>("[role=alert]")!;
    const button = form.querySelector("button")!;
    if (source) ref.value = source;

    form.addEventListener("submit", async (event) => {
      event.preventDefault();
      button.disabled = true;
      button.textContent = "Joining";
      alert.textContent = "";

      const { error } = await actions.joinWaitlist(new FormData(form));
      if (!error) {
        const done = document.createElement("p");
        done.setAttribute("role", "status");
        done.textContent = "You're on the list. We'll email you on launch day.";
        form.replaceWith(done);
        return;
      }
      alert.textContent = isInputError(error) ? (error.fields.email?.[0] ?? "Check the address.") : error.message;
      button.disabled = false;
      button.textContent = "Join the waitlist";
    });
  }
</script>
```

- フォームの `action` は通常の URL である `/join` です。JavaScript がなければブラウザーはそこに送信するので、スクリプトの読み込み前や失敗時でもフォームは動きます。
- プリレンダーされたページはクエリ文字列なしでビルドされているので、スクリプトがブラウザーで `?ref=` を読みます。`/?ref=newsletter` から来た訪問者には `newsletter` が付きます。
- Astro はコンポーネントの `<script>` をページごとに 1 回だけバンドルするので、ループでページ上のすべてのウェイトリストフォームを設定します。ヒーローとフッターに 1 つずつ置いても、どちらも動きます。
- ラベルは `id` を参照する代わりに入力欄を包んでいるので、1 ページに 2 つ置いても衝突しません。

### ランディングページと /join

`src/pages/index.astro`:

```astro
---
import WaitlistForm from "../components/WaitlistForm.astro";
---

<main>
  <h1>Acme is almost here</h1>
  <p>Leave your email and we'll send you an invite the day it opens.</p>
  <WaitlistForm />
</main>
```

`prerender` をエクスポートしていないので静的なままです。`/join` はプリレンダーを外し、通常の送信を処理します:

`src/pages/join.astro`:

```astro
---
import { actions, isInputError } from "astro:actions";
import WaitlistForm from "../components/WaitlistForm.astro";

export const prerender = false;

let error = "";
if (Astro.request.method === "POST") {
  const result = await Astro.callAction(actions.joinWaitlist, await Astro.request.formData());
  if (!result.error) return Astro.redirect("/thanks", 303);
  error = isInputError(result.error) ? (result.error.fields.email?.[0] ?? "Check the address.") : result.error.message;
  Astro.response.status = 400;
}
---

<main>
  <h1>Join the Acme waitlist</h1>
  <WaitlistForm error={error} />
</main>
```

`Astro.callAction` は同じバリデーションで Action を実行するので、変更箇所は 1 か所で済みます。`303` でブラウザーは GET で `/thanks` に移動します。これは同じお礼の一文で作る静的ページです。

## デプロイの注意点

| ホスト | アダプター | シークレット |
| --- | --- | --- |
| Vercel | `npx astro add vercel` | プロジェクトの **Environment Variables** に `IKIFORM_SECRET_KEY` を追加します |
| Netlify | `npx astro add netlify` | **Site configuration › Environment variables** に追加します |
| Cloudflare | `npx astro add cloudflare` | Worker の設定でシークレットとして追加するか、`wrangler secret put IKIFORM_SECRET_KEY` を実行します |

どのホストでも、ランディングページと `/thanks` は静的ファイルとして配信され、Action と `/join` は 1 つのサーバー関数になります。静かな日のコストはほぼゼロで、ローンチ時のアクセス集中もほとんどが CDN で受け止められます。

## 動作を確認する

1. `astro dev` を実行し、`http://localhost:4321/?ref=test` を開いて登録します。再読み込みなしで、フォームがお礼の一文に変わります。
2. ダッシュボードで **Submissions** を開きます。`ref` が `test` に設定された登録が入っています。
3. ブラウザーで JavaScript をオフにし、再読み込みしてもう一度登録します。`/thanks` に移動し、新しい登録の `ref` は `direct` になっています。
4. JavaScript をオフにしたまま `not-an-email` を送信します。`/join` がメールのエラー付きでフォームを再表示します。
5. ハニーポットに値を入れて `_gotcha=bot` で送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。
6. `astro dev` からの送信は `localhost` のオリジンを持つので、テスト送信として扱われます。Ikiform に無料プランはありません。テストは無料で、プランのないワークスペースでも `localhost` からのテスト送信を月 50 件まで受け付けます。本番運用するには、月 $6 で 500 件からの Pro を選ぶか、1 回払いで 10,000 件 $49 または 50,000 件 $149 のパックを購入します。クレジットに有効期限はありません。[料金](https://ikiform.com/ja/pricing)を参照してください。

## ハマりどころ

| 問題 | 対処法 |
| --- | --- |
| 通常の送信で自分のサイトから `403` が返る | デフォルトで有効な Astro の `security.checkOrigin` は、別のオリジンからオンデマンドのページへのフォーム送信を拒否します。開発中の `localhost:4321` のように、サイトが動いているのと同じホストから送信し、別のプレビュー用ドメインを経由しないでください。 |
| プリレンダーしたページでフォーム Action についてのビルドエラーが出る | 静的なページのフォームで `action={actions.joinWaitlist}` を使っています。上の例のように Action を `/join` のままにするか、そのページに `export const prerender = false` を追加してください。 |
| `ref` が常に `direct` になる | リンクの値に大文字や空白が含まれているか、スクリプトが読み込まれていません。スクリプトは値を小文字にしますが、`a-z`、`0-9`、`-`、`_` 以外が残っていれば Action が切り捨てます。 |
| `unknown_field` 付きの `422` | フォームのフィールド定義に `ref` がありません。上の定義のように追加してください。 |
| `402 plan_required` | ワークスペースにプランがなく、送信が本番のサイトから来ています。送信データは失われず、保留されています。**Settings › Plan and usage** でプランを選ぶかパックを購入すると、保留中の送信データが届きます。 |

## よくある質問

### Astro のウェイトリストページは静的なままにできますか？

はい。ランディングページはプリレンダーのままにし、クライアントのスクリプトから Action を呼び、フォームの通常の action はオンデマンドでレンダリングする 1 ページに向けます。サーバーが必要なのは Action とそのページだけです。

### Astro Actions にアダプターは必要ですか？

はい。Action は呼ばれたときにサーバーで実行されるので、すべてのページをプリレンダーしていても、Node、Vercel、Netlify、Cloudflare などのアダプターが必要です。

### Astro のフォームを直接 Ikiform に送信しないのはなぜですか？

直接送信することもでき、その場合は Ikiform の訪問者ごとのレート制限と人間確認がそのまま使えます。Action を挟む価値があるのは、先に流入元タグを検証して整えたいときや、エンドポイントをページの HTML に出したくないときです。

### Ikiform は無料ですか？

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

## その他のガイド

- [新しい SaaS のウェイトリスト](https://ikiform.com/ja/guides/waitlist.md): 自分のランディングページにローンチ前のウェイトリストを作ります。フォーム、スパム対策、重複、確認メール、ローンチ日のきれいなエクスポートまで扱います。
- [Next.js のウェイトリスト](https://ikiform.com/ja/guides/waitlist/nextjs.md): Next.js App Router のウェイトリスト。シークレットキーで登録を転送するサーバーアクション、useActionState、楽観的なサンクス表示、訪問者ごとの制限を扱います。
- [プレーンな HTML のウェイトリスト](https://ikiform.com/ja/guides/waitlist/html.md): ビルド不要でプレーンな HTML だけのウェイトリストページ。JavaScript がオフでも動き、読み込めばその場でお礼を表示し、必要なときはフォールバックします。
- [Product Hunt ローンチリスト](https://ikiform.com/ja/guides/waitlist/product-hunt.md): Product Hunt の数週間前からローンチリストを集め、登録をチャネルごとにタグ付けし、太平洋時間のリセットに合わせてローンチ日のメールを 2 回に分けて送ります。
- [選考付きのベータ登録](https://ikiform.com/ja/guides/waitlist/beta-signup.md): サーバーが必須を強制する選考質問付きのベータ応募フォームと、応募者を採点してリストから各コホートを選ぶスクリプトを紹介します。
- [モバイルアプリのウェイトリスト](https://ikiform.com/ja/guides/waitlist/mobile-app.md): 使っているスマホを尋ね、iPhone のテスターを TestFlight に送り、Play のクローズドテストを埋める、iOS・Android アプリ向けの公開前ウェイトリストです。
