新しい SaaS のウェイトリスト3 分で読めます
Astro でウェイトリストを作る
静的なランディングページに Astro のウェイトリストを。Action が登録を転送し、スクリプトがそれを呼び、オンデマンドの 1 ページが JavaScript なしの送信を受け止めます。
ランディングページはプリレンダーのままにし、サーバー側の処理は、メールアドレスを検証してシークレットキーでフォームバックエンドに転送する 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 なしで届いた送信を受け止めます。
- ランディングページは CDN から配信されるプレーンな HTML です。スクリプトが読み込まれると、?ref= や ?utm_source= を hidden の ref 入力欄にコピーします。
- JavaScript があれば、スクリプトが通常の送信を止め、フォームのデータで actions.joinWaitlist を呼びます。Action はサーバーで実行され、登録を Ikiform に転送します。
- スクリプトがフォームをお礼の一文に置き換えるか、入力欄の下にエラーを表示します。
- JavaScript がなければ、ブラウザーはオンデマンドでレンダリングされる /join にフォームを送信します。
- /join は Astro.callAction で同じ Action を呼びます。成功すると 303 で /thanks にリダイレクトするので、再読み込みしても再送信されません。エラーのときはメッセージ付きでフォームを表示します。
プロジェクトを準備する
フォームやシークレットキーの作成など、製品側の設定は 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 nodeimport { 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 スコープを付けて作成します。
IKIFORM_SECRET_KEY=ik_sk_your_secret_keyIKIFORM_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
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を読みます。
フォームコンポーネント
---
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
---
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 はプリレンダーを外し、通常の送信を処理します:
---
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 で受け止められます。
動作を確認する
astro devを実行し、http://localhost:4321/?ref=testを開いて登録します。再読み込みなしで、フォームがお礼の一文に変わります。- ダッシュボードで Submissions を開きます。
refがtestに設定された登録が入っています。 - ブラウザーで JavaScript をオフにし、再読み込みしてもう一度登録します。
/thanksに移動し、新しい登録のrefはdirectになっています。 - JavaScript をオフにしたまま
not-an-emailを送信します。/joinがメールのエラー付きでフォームを再表示します。 - ハニーポットに値を入れて
_gotcha=botで送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。 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 でプランを選ぶかパックを購入すると、保留中の送信データが届きます。 |
よくある質問
はい。ランディングページはプリレンダーのままにし、クライアントのスクリプトから Action を呼び、フォームの通常の action はオンデマンドでレンダリングする 1 ページに向けます。サーバーが必要なのは Action とそのページだけです。
はい。Action は呼ばれたときにサーバーで実行されるので、すべてのページをプリレンダーしていても、Node、Vercel、Netlify、Cloudflare などのアダプターが必要です。
直接送信することもでき、その場合は Ikiform の訪問者ごとのレート制限と人間確認がそのまま使えます。Action を挟む価値があるのは、先に流入元タグを検証して整えたいときや、エンドポイントをページの HTML に出したくないときです。
いいえ、無料プランはありません。テストは無料で、支払う前に localhost からテスト送信を月 50 件まで送れます。本番運用には、500 件で月 $6 からの Pro か、買い切りで 10,000 件 $49、または 50,000 件 $149 を選べます。クレジットに有効期限はありません。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com