ニュースレター登録フォーム3 分で読めます
Astro でニュースレター登録フォームを作る
プリレンダーされたページのままで使える Astro のニュースレターフォームです。Astro Action が同意を確認して Ikiform に送信し、エンドポイントが読者を MailerLite に追加します。
Astro のページはプリレンダーのままにし、登録フォームをフッターのコンポーネントに置きます。JavaScript がなければ、Astro Action を実行するオンデマンドの /newsletter ページ 1 つに送信し、JavaScript があれば小さなスクリプトが同じアクションを呼ぶので、読者は記事から離れません。アクションは astro/zod でメールアドレスと同意を検証し、シークレットキー付きでフォームバックエンドに送信し、API エンドポイントが検証済みの登録を MailerLite に転送します。
メインガイドの Astro タブでは、オンデマンドで描画するページで Action を使っています。Astro のサイトの多くはすべてのページがプリレンダーされたブログやドキュメントで、フッターのフォームはそのすべてに置く必要があります。このページでは、それらのページを静的なままにしつつ、登録はサーバーで処理します。キーと許可ドメインについては Astro のドキュメントガイドにあります。
静的なページで変わること
Astro のドキュメントによると、フォームの action 属性経由でアクションを呼ぶには、ページをオンデマンドで描画する必要があります。プリレンダーされた記事には背後にサーバーがないので、その POST を処理できません。回避策は 2 つあり、この構成では両方を使います。
| 経路 | 動くとき | 読者に見えるもの |
|---|---|---|
スクリプトが actions.subscribe() を呼ぶ | JavaScript が読み込み済み。通常のケース | フォームの下のステータスの行が変わる。記事にとどまる |
フォームが /newsletter?_action=... に送信する | JavaScript がオフか、まだ読み込まれていない | 結果かエラーを表示するオンデマンドの /newsletter ページ |
オンデマンドで動くのは /newsletter と Webhook のエンドポイントだけです。記事はすべて静的ファイルのままで、アダプターがその 2 つのルート用にサーバー関数を追加します。
- JavaScript があると、フッターのスクリプトが通常の送信を止め、フォームのデータでアクションを呼びます。
- アクションが入力を検証して Ikiform に送信し、データかエラーをスクリプトに返し、スクリプトがステータスの行を更新します。
- JavaScript がないと、ブラウザーはクエリ文字列にアクションを付けて、オンデマンドの /newsletter ページにフォームを送信します。
- Astro が同じアクションのハンドラーを実行し、ページが getActionResult で結果を読んで表示します。
プロジェクトの準備
src/
actions/index.ts
components/SignupForm.astro
layouts/Base.astro
pages/
newsletter.astro
api/ikiform.ts
astro.config.mjs
.envastro add vercel、astro add netlify、astro add cloudflare のいずれかでアダプターをインストールします。シークレットは import.meta.env で読むのではなく、astro:env で宣言します。変数がなければスキーマがビルドを失敗させ、同じインポートが Cloudflare のランタイムを含むどのアダプターでも動きます。
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" }),
IKIFORM_WEBHOOK_SECRET: envField.string({ context: "server", access: "secret" }),
MAILERLITE_API_KEY: envField.string({ context: "server", access: "secret" }),
MAILERLITE_GROUP_ID: envField.string({ context: "server", access: "secret" }),
},
},
});IKIFORM_SECRET_KEY=ik_sk_your_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
MAILERLITE_API_KEY=your_mailerlite_token
MAILERLITE_GROUP_ID=123456789API keys で submissions:write スコープのシークレットキーを作り、ピラーのフィールド定義をフォームの Fields タブに貼り付けます。
作ってみる
Action
import { ActionError, defineAction } from "astro:actions";
import { IKIFORM_SECRET_KEY } from "astro:env/server";
import { z } from "astro/zod";
export const server = {
subscribe: defineAction({
accept: "form",
input: z.object({
email: z.email({ error: "Enter an email address we can send the newsletter to." }),
first_name: z.string().trim().max(80).nullish(),
consent: z.boolean().refine((ticked) => ticked, { error: "Tick the box so we know you want the emails." }),
source: z.enum(["footer", "post", "newsletter-page"]).catch("footer"),
_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,
first_name: input.first_name || undefined,
consent: true,
source: input.source,
_gotcha: input._gotcha ?? "",
}),
}).catch(() => null);
const result = await response?.json().catch(() => null);
if (result?.ok) return { subscribed: true };
throw new ActionError({
code: response?.status === 429 ? "TOO_MANY_REQUESTS" : "BAD_REQUEST",
message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "We couldn't reach the list. Try again.",
});
},
}),
};accept: "form"で Astro がFormDataを解析します。チェックボックスは真偽値に変換されるので、チェックありはtrue、なしはfalseになり、refineがそれを同意のメッセージに変えます。- 空のテキスト入力は
nullで届くので、任意のフィールドにはoptional()ではなくnullish()を使います。 sourceは一覧にない値ならfooterにフォールバックするので、細工した送信で設置場所をでっち上げることはできません。- Ikiform は無料の
localhostテストをそのヘッダーで判別し、サーバーのfetchは Origin を送らないので、Originの行でブラウザーのオリジンを引き継ぎます。 - Ikiform のエラーは
ActionErrorになります。429はTOO_MANY_REQUESTSとしてその意味を保ちます。
IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。フッターのコンポーネント
---
import { actions } from "astro:actions";
interface Props {
source?: "footer" | "post" | "newsletter-page";
}
const { source = "footer" } = Astro.props;
const target = new URL("/newsletter", Astro.url);
target.search = actions.subscribe.queryString;
---
<form class="signup" method="POST" action={target.pathname + target.search}>
<label>Email <input name="email" type="email" required autocomplete="email" /></label>
<label>First name (optional) <input name="first_name" maxlength="80" autocomplete="given-name" /></label>
<label>
<input name="consent" type="checkbox" required />
Email me the newsletter. I can unsubscribe at any time.
</label>
<input type="hidden" name="source" value={source} />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p class="signup-status" role="status"></p>
<button>Subscribe</button>
</form>
<script>
import { actions, isInputError } from "astro:actions";
for (const form of document.querySelectorAll<HTMLFormElement>("form.signup")) {
const status = form.querySelector(".signup-status") as HTMLElement;
const button = form.querySelector("button") as HTMLButtonElement;
form.addEventListener("submit", async (event) => {
event.preventDefault();
button.disabled = true;
status.textContent = "Subscribing";
const { error } = await actions.subscribe(new FormData(form));
button.disabled = false;
if (!error) {
form.replaceChildren(status);
status.textContent = "Almost done. Open the email we sent you and confirm your address.";
return;
}
const fields = isInputError(error) ? error.fields : {};
status.textContent = fields.email?.[0] ?? fields.consent?.[0] ?? error.message;
});
}
</script>queryStringは、アクションの URL を組み立てるためのドキュメントに記載された方法です。/newsletterの URL に付ければ、JavaScript なしの送信は元の静的なページではなくオンデマンドのページに送られます。- Astro はコンポーネントの
<script>を、コンポーネントが何回出てきても 1 回だけバンドルします。ループで、フッターのフォームと記事の最後のフォームのような、すべてのコピーに処理を結びつけます。 isInputErrorは、zod のフィールドごとのメッセージと、普通のメッセージとして届く Ikiform の拒否を区別します。
オンデマンドのページ
---
import { actions, isInputError } from "astro:actions";
import SignupForm from "../components/SignupForm.astro";
import Base from "../layouts/Base.astro";
export const prerender = false;
const result = Astro.getActionResult(actions.subscribe);
const fields = isInputError(result?.error) ? result.error.fields : {};
const problem = fields.email?.[0] ?? fields.consent?.[0] ?? result?.error?.message;
---
<Base title="Newsletter">
<h1>The monthly newsletter</h1>
{result?.data?.subscribed ? (
<p role="status">Almost done. Open the email we sent you and confirm your address.</p>
) : (
<>
{problem && <p role="alert">{problem}</p>}
<SignupForm source="newsletter-page" />
</>
)}
</Base>Astro がアクションの結果を表示するのは、その POST に対してだけです。ページをもう一度開くと消えますが、登録ならそれで問題ありません。サンクスページの URL にリダイレクトしたいなら、Astro のドキュメントにミドルウェアで getActionContext を使うセッションベースのパターンがあります。
購読者を MailerLite に転送する
このエンドポイントは Ikiform の署名付き Webhook を受け取り、各登録を unconfirmed として MailerLite のグループに追加するので、Double opt-in for API and integrations がオンなら MailerLite が独自の確認メールを送ります。署名はバイト列そのものが対象なので、まずボディをテキストとして読みます。すべての登録を unconfirmed として送るので、新しいリストに向いています。すでに確認済みの読者がいるリストなら、代わりに MailerLite のガイドのリレーを使ってください。先にアドレスを検索するので、再登録した確認済みの読者の状態が保たれます。
import type { APIRoute } from "astro";
import { IKIFORM_WEBHOOK_SECRET, MAILERLITE_API_KEY, MAILERLITE_GROUP_ID } from "astro:env/server";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
let event;
try {
event = await verifyWebhook({ payload: await request.text(), headers: request.headers, secret: IKIFORM_WEBHOOK_SECRET });
} catch (error) {
if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
throw error;
}
if (event.type !== "submission.created") return new Response(null, { status: 204 });
const { email, first_name, consent } = event.data.fields;
if (consent !== true || typeof email !== "string") return new Response("Skipped", { status: 200 });
const answer = await fetch("https://connect.mailerlite.com/api/subscribers", {
method: "POST",
headers: {
Authorization: `Bearer ${MAILERLITE_API_KEY}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
email,
fields: typeof first_name === "string" && first_name ? { name: first_name } : {},
groups: [MAILERLITE_GROUP_ID],
status: "unconfirmed",
}),
});
if (answer.status === 429) return new Response("MailerLite rate limit", { status: 503, headers: { "retry-after": "60" } });
if (!answer.ok) return new Response(`MailerLite answered ${answer.status}`, { status: 502 });
return new Response(null, { status: 204 });
};デフォルトでオンの Astro の security.checkOrigin は、オンデマンドのルートへのクロスサイトのフォーム送信を拒否します。Ikiform の Webhook は application/json で、このチェックの対象外の型なので、エンドポイントはそのまま受け取れます。URL を Webhooks に追加し、whsec_ のシークレットを IKIFORM_WEBHOOK_SECRET にコピーします。
ハニーポットと人間確認
_gotcha 入力欄は転送され、Ikiform は埋まったものを成功の応答付き・Webhook なしでスパムとして保存するので、MailerLite に届くことはありません。シークレットキーを使うと Ikiform の Turnstile のチェックはスキップされます。ボットが通り抜けてくるなら、選択肢は 2 つです。Turnstile のガイドのようにハンドラーで Turnstile のトークンを検証するか、アクションをやめて @ikiform/sdk でブラウザーから送信するかです。後者なら Ikiform の確認、許可ドメイン、訪問者ごとの制限がそのまま使え、アダプターもまったく要りません。
デプロイ
| ホスト | 対処法 |
|---|---|
| Vercel | @astrojs/vercel は 2 つのオンデマンドのルートを関数にします。4 つの変数を Environment Variables に追加します。 |
| Netlify | @astrojs/netlify は Netlify Functions で同じことをします。変数はサイトの環境設定に追加します。 |
| Cloudflare | @astrojs/cloudflare は Workers が対象で、このアダプターはもう Pages に対応していません。シークレットは wrangler secret put で設定し、ローカルでは .dev.vars を使います。Ikiform は workers.dev の Webhook URL を拒否するので、Worker に独自ドメインを付けてください。 |
localhost で無料でテストする
プランのないワークスペースでも、localhost からのテスト送信を月 50 件まで受け付けます。astro dev を実行すると転送される Origin は http://localhost:4321 なので、各登録はテストとして数えられます。両方の経路を試してください。1 回は通常どおり、もう 1 回は JavaScript を無効にして /newsletter ページを確認します。エンドポイントをテストするには、HTTPS の URL が得られるトンネルで公開してください。Ikiform は localhost には配信しません。
動作を確認する
astro buildを実行し、出力で記事がプリレンダー済みとして並び、オンデマンドなのは/newsletterと/api/ikiformだけであることを確認します。- 記事のフッターから購読します。ステータスの行が変わり、URL は変わりません。
- JavaScript を無効にしてもう一度購読します。「受信トレイを確認してください」の一文が表示された
/newsletterに移動します。 - 開発者ツールでボックスのチェックを外して送信します。同意のメッセージが表示され、何も保存されません。
- ダッシュボードで Submissions を開きます。送ったすべてのフィールドを含む新しい送信データがあります。
- Webhook を追加した場合は、Webhooks でエンドポイントを開きます。配信が
succeededになり、サーバーの応答が表示されます。 - MailerLite を開きます。購読者が未確認としてグループに入っていて、確認メールが受信トレイに届いています。
ハマりどころ
| 問題 | 対処法 |
|---|---|
| JavaScript なしの送信で 404 か静的なページが表示される | フォームが、元のプリレンダーされたページに送信しています。action は /newsletter に queryString を付けて組み立て、そのページでは prerender = false を維持してください。 |
| ページの最初のフォームしか動かない | スクリプトが 1 つのフォームに querySelector を使っています。Astro はスクリプトをページごとに 1 回だけ実行するので、すべての form.signup をループで処理してください。 |
| 環境変数がないというエラーでビルドが失敗する | astro:env はビルド時にスキーマをチェックします。ホストで、またはローカルなら .env で変数を設定してください。 |
ローカルのテストで 402 plan_required が出る | 送信に localhost のオリジンがありませんでした。ハンドラーの Origin の行を確認し、localhost でサイトを開いてください。 |
| 送信データはダッシュボードにあるのに、メールも Webhook も届かない | スパムとして保存されています。スパムでは何も送られません。Spam フィルターで開いて理由を確認し、スパムではないとマークすると、メールと Webhook が送られます。 |
よくある質問
プロジェクトにアダプターがあれば、クライアントのスクリプトから呼ぶ場合は実行できます。アクションは専用のオンデマンドのルートで動きます。素の HTML フォームの送信には受け止めるオンデマンドのページが必要なので、この構成では /newsletter に送っています。
このページのサーバー側の版でだけ必要です。完全に静的な Astro サイトなら、フォームをブラウザーから直接 Ikiform に送信できます。その場合は Ikiform の人間確認がそのまま使え、サーバーも要りません。
アクションではボットと読者を区別できません。Ikiform はどちらにも同じ成功の応答を返すからです。Webhook はスパムチェックを通った登録にだけ送られ、MailerLite が障害を起こしても、訪問者の送信が失敗するのではなく、Webhook のリトライで購読者の追加が遅れるだけで済みます。
いいえ、無料プランはありません。テストは無料で、支払う前に localhost から月 50 件のテスト送信ができます。本番運用するには、月 $6 で 500 件からの Pro か、1 回払いで 10,000 件 $49 または 50,000 件 $149 のパックを選べます。クレジットに有効期限はありません。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com