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

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

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

Astro のページはプリレンダーのままにし、登録フォームをフッターのコンポーネントに置きます。JavaScript がなければ、Astro Action を実行するオンデマンドの `/newsletter` ページ 1 つに送信し、JavaScript があれば小さなスクリプトが同じアクションを呼ぶので、読者は記事から離れません。アクションは `astro/zod` でメールアドレスと同意を検証し、シークレットキー付きでフォームバックエンドに送信し、API エンドポイントが検証済みの登録を MailerLite に転送します。

[メインガイドの Astro タブ](https://ikiform.com/ja/guides/newsletter-signup.md#form)では、オンデマンドで描画するページで Action を使っています。Astro のサイトの多くはすべてのページがプリレンダーされたブログやドキュメントで、フッターのフォームはそのすべてに置く必要があります。このページでは、それらのページを静的なままにしつつ、登録はサーバーで処理します。キーと許可ドメインについては [Astro のドキュメントガイド](https://ikiform.com/ja/docs/guides/astro.md)にあります。

## 静的なページで変わること

Astro のドキュメントによると、フォームの `action` 属性経由でアクションを呼ぶには、ページをオンデマンドで描画する必要があります。プリレンダーされた記事には背後にサーバーがないので、その POST を処理できません。回避策は 2 つあり、この構成では両方を使います。

| 経路 | 動くとき | 読者に見えるもの |
| --- | --- | --- |
| スクリプトが `actions.subscribe()` を呼ぶ | JavaScript が読み込み済み。通常のケース | フォームの下のステータスの行が変わる。記事にとどまる |
| フォームが `/newsletter?_action=...` に送信する | JavaScript がオフか、まだ読み込まれていない | 結果かエラーを表示するオンデマンドの `/newsletter` ページ |

オンデマンドで動くのは `/newsletter` と Webhook のエンドポイントだけです。記事はすべて静的ファイルのままで、アダプターがその 2 つのルート用にサーバー関数を追加します。

図: Astro のフッターからの登録が同じアクションに届く 2 つの方法

1. JavaScript があると、フッターのスクリプトが通常の送信を止め、フォームのデータでアクションを呼びます。
2. アクションが入力を検証して Ikiform に送信し、データかエラーをスクリプトに返し、スクリプトがステータスの行を更新します。
3. JavaScript がないと、ブラウザーはクエリ文字列にアクションを付けて、オンデマンドの /newsletter ページにフォームを送信します。
4. Astro が同じアクションのハンドラーを実行し、ページが getActionResult で結果を読んで表示します。

ハンドラーは 1 つで、入り口は 2 つ。静的な記事に専用のサーバーは要りません。

## プロジェクトの準備

`プロジェクトの構成`:

```text
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`:

```js
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`:

```bash
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` スコープのシークレットキーを作り、[ピラーのフィールド定義](https://ikiform.com/ja/guides/newsletter-signup.md#fields)をフォームの **Fields** タブに貼り付けます。

## 作ってみる

### 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";

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 の[レート制限](https://ikiform.com/ja/docs/rate-limits.md)を選び、`IKIFORM_SECRET_KEY` はサーバー専用の環境変数に置いてください。

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

`src/components/SignupForm.astro`:

```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`:

```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 のガイド](https://ikiform.com/ja/guides/newsletter-signup/mailerlite.md)のリレーを使ってください。先にアドレスを検索するので、再登録した確認済みの読者の状態が保たれます。

`src/pages/api/ikiform.ts`:

```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 のガイド](https://ikiform.com/ja/guides/spam-protection/cloudflare-turnstile.md)のようにハンドラーで 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 が送られます。 |

## よくある質問

### プリレンダーされたページで Astro Action を実行できますか？

プロジェクトにアダプターがあれば、クライアントのスクリプトから呼ぶ場合は実行できます。アクションは専用のオンデマンドのルートで動きます。素の HTML フォームの送信には受け止めるオンデマンドのページが必要なので、この構成では /newsletter に送っています。

### Astro のニュースレターフォームにアダプターは必要ですか？

このページのサーバー側の版でだけ必要です。完全に静的な Astro サイトなら、フォームをブラウザーから直接 Ikiform に送信できます。その場合は Ikiform の人間確認がそのまま使え、サーバーも要りません。

### 登録をアクションの中ではなく Webhook で MailerLite に転送するのはなぜですか？

アクションではボットと読者を区別できません。Ikiform はどちらにも同じ成功の応答を返すからです。Webhook はスパムチェックを通った登録にだけ送られ、MailerLite が障害を起こしても、訪問者の送信が失敗するのではなく、Webhook のリトライで購読者の追加が遅れるだけで済みます。

### Ikiform は無料ですか？

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

## その他のガイド

- [ニュースレター登録フォーム](https://ikiform.com/ja/guides/newsletter-signup.md): 自分のマークアップでニュースレター登録フォームを作り、同意を記録し、ボットの登録を防ぎ、購読者を Kit、MailerLite、beehiiv など任意のツールに渡します。
- [静的サイト](https://ikiform.com/ja/guides/newsletter-signup/static-site.md): Hugo、Jekyll、Eleventy、Astro の静的サイトにニュースレター登録フォームを追加し、メールツールの API キーをページに入れず、確認はツールで行います。
- [MailerLite](https://ikiform.com/ja/guides/newsletter-signup/mailerlite.md): 自作の登録フォームを MailerLite につなぎます。リレーが新しい読者をダブルオプトインのために未確認として追加し、購読者に二度確認を求めることはありません。
- [Next.js のニュースレターフォーム](https://ikiform.com/ja/guides/newsletter-signup/nextjs.md): Next.js のフッターのニュースレターフォームです。同意のチェックと Turnstile を行うサーバーアクションが Ikiform に送信し、ルートハンドラーが読者を Kit に追加します。
- [ダブルオプトイン](https://ikiform.com/ja/guides/newsletter-signup/double-opt-in.md): Ikiform は確認メールを送らないので、ダブルオプトインはメールツールで行います。中継が各登録を未確認として追加し、ツールがリンクを送ります。
- [Kit (ConvertKit)](https://ikiform.com/ja/guides/newsletter-signup/kit.md): 購読者を作成し、特典メールのために Kit のフォームに追加し、設置場所のタグを付けるリレーで、自作のニュースレターフォームを Kit につなぎます。
