ニュースレター登録フォーム

ニュースレター登録フォーム3 分で読めます

Astro でニュースレター登録フォームを作る

プリレンダーされたページのままで使える Astro のニュースレターフォームです。Astro Action が同意を確認して Ikiform に送信し、エンドポイントが読者を MailerLite に追加します。

Preet SutharIkiform 創業者

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 つのルート用にサーバー関数を追加します。

Astro のフッターからの登録が同じアクションに届く 2 つの方法JavaScript ありJavaScript なし記事プリレンダーされたHTMLactions.subscribe()クライアントのスクリプトからアクションのハンドラーzod、次にfetchステータスの行同じページ記事通常のフォーム送信/newsletterprerender=falseアクションのハンドラー同じコード結果のページgetActionResultFormData/_actions/subscribe{ data, error }POST?_action結果Astro のフッターからの登録が同じアクションに届く 2 つの方法JavaScript ありJavaScript なし記事プリレンダーされた HTMLactions.subscribe()クライアントのスクリプトからアクションのハンドラーzod、次に fetchステータスの行同じページ記事通常のフォーム送信/newsletterprerender = falseアクションのハンドラー同じコード結果のページgetActionResultFormData/_actions/subscribe{ data, error }POST?_action結果
  1. JavaScript があると、フッターのスクリプトが通常の送信を止め、フォームのデータでアクションを呼びます。
  2. アクションが入力を検証して Ikiform に送信し、データかエラーをスクリプトに返し、スクリプトがステータスの行を更新します。
  3. JavaScript がないと、ブラウザーはクエリ文字列にアクションを付けて、オンデマンドの /newsletter ページにフォームを送信します。
  4. Astro が同じアクションのハンドラーを実行し、ページが getActionResult で結果を読んで表示します。
ハンドラーは 1 つで、入り口は 2 つ。静的な記事に専用のサーバーは要りません。

プロジェクトの準備

プロジェクトの構成
src/
  actions/index.ts
  components/SignupForm.astro
  layouts/Base.astro
  pages/
    newsletter.astro
    api/ikiform.ts
astro.config.mjs
.env

astro add vercel、astro add netlify、astro add cloudflare のいずれかでアダプターをインストールします。シークレットは import.meta.env で読むのではなく、astro:env で宣言します。変数がなければスキーマがビルドを失敗させ、同じインポートが Cloudflare のランタイムを含むどのアダプターでも動きます。

astro.config.mjs
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" }),
    },
  },
});
.env
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
MAILERLITE_API_KEY=your_mailerlite_token
MAILERLITE_GROUP_ID=123456789

API keys で submissions:write スコープのシークレットキーを作り、ピラーのフィールド定義をフォームの Fields タブに貼り付けます。

作ってみる

Action

src/actions/index.ts
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 は Allowed domains と人間確認をスキップします。その場合はすべての送信がサーバーの IP から来るため、訪問者ごとに 1 分あたり 5 件という Standard の制限が、全訪問者をまとめて数えることになります。サーバー経由で送信するフォームには Relaxed か Custom のレート制限を選び、IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。

フッターのコンポーネント

src/components/SignupForm.astro
---
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 の拒否を区別します。

オンデマンドのページ

src/pages/newsletter.astro
---
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 のガイドのリレーを使ってください。先にアドレスを検索するので、再登録した確認済みの読者の状態が保たれます。

src/pages/api/ikiform.ts
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 には配信しません。

動作を確認する

  1. astro build を実行し、出力で記事がプリレンダー済みとして並び、オンデマンドなのは /newsletter と /api/ikiform だけであることを確認します。
  2. 記事のフッターから購読します。ステータスの行が変わり、URL は変わりません。
  3. JavaScript を無効にしてもう一度購読します。「受信トレイを確認してください」の一文が表示された /newsletter に移動します。
  4. 開発者ツールでボックスのチェックを外して送信します。同意のメッセージが表示され、何も保存されません。
  5. ダッシュボードで Submissions を開きます。送ったすべてのフィールドを含む新しい送信データがあります。
  6. Webhook を追加した場合は、Webhooks でエンドポイントを開きます。配信が succeeded になり、サーバーの応答が表示されます。
  7. 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 が送られます。

よくある質問

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