新しい SaaS のウェイトリスト

新しい SaaS のウェイトリスト3 分で読めます

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

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

Preet SutharIkiform 創業者

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

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

Astro ならではの違い

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

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

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

静的なランディングページから 1 つの Astro Action に入る 2 つの経路JavaScript ありJavaScript なし静的ページスクリプトが URLから ref を入れるactions.joinWaitlist()サーバーで実行Ikiformシークレットキー、保存お礼の一文同じページ静的ページフォームを /joinに送信/joinオンデマンドのAstro.callActionIkiform同じ Action/thanks303 リダイレクトFormDataJSON結果POSTJSON303静的なランディングページから 1 つの Astro Action に入る 2 つの経路JavaScript ありJavaScript なし静的ページスクリプトが URL から ref を入れるactions.joinWaitlist()サーバーで実行Ikiformシークレットキー、保存お礼の一文同じページ静的ページフォームを /join に送信/joinオンデマンドの Astro.callActionIkiform同じ Action/thanks303 リダイレクトFormDataJSON結果POSTJSON303
  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 のドキュメントガイドにあります。このページで使うファイル:

プロジェクト
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 に替えてください。

ターミナル
npx astro add node
astro.config.mjs
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
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
サーバー側のバージョンはシークレットキーを付けて送信するので、Ikiform は Allowed domains とその人間確認をスキップします。その場合、すべての送信がサーバーの IP から届くため、訪問者ごとに 1 分あたり 5 件という Standard の制限が全訪問者の合計に適用されてしまいます。サーバー経由で送信するフォームには Relaxed か Custom のレート制限を選び、IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。

フィールドの定義

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

フィールド
{
  "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
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
---
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
---
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
---
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 に移動します。これは同じお礼の一文で作る静的ページです。

デプロイの注意点

ホストアダプターシークレット
Vercelnpx astro add vercelプロジェクトの Environment Variables に IKIFORM_SECRET_KEY を追加します
Netlifynpx astro add netlifySite configuration › Environment variables に追加します
Cloudflarenpx astro add cloudflareWorker の設定でシークレットとして追加するか、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 のパックを購入します。クレジットに有効期限はありません。料金を参照してください。

ハマりどころ

問題対処法
通常の送信で自分のサイトから 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 でプランを選ぶかパックを購入すると、保留中の送信データが届きます。

よくある質問

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