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

> JavaScript の有無にかかわらず動く Nuxt のニュースレターフォームです。Turnstile をチェックするサーバールートが Ikiform に送信し、Nitro のルートが beehiiv に登録を渡します。

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

フォームに Nuxt のサーバールートを指す本物の `action` を持たせ、`$fetch` で拡張して、JavaScript のある読者がページにとどまれるようにします。ルートは同意のチェックボックスと `@nuxtjs/turnstile` の Turnstile のトークンを確認し、シークレットキー付きで登録をフォームバックエンドに転送して、呼び出し元に応じて JSON かリダイレクトを返します。2 つ目のルートがバックエンドの署名付き Webhook を beehiiv の購読に変えます。

[メインガイドの Nuxt タブ](https://ikiform.com/ja/guides/newsletter-signup.md#form)では `$fetch` で送信し、Vue のハイドレーションが終わってから動きます。このページでは同じフォームをその前でも動くようにし、人間確認を追加して、本物の登録を beehiiv に送ります。製品の設定については [Nuxt のドキュメントガイド](https://ikiform.com/ja/docs/guides/nuxt.md)にあります。

## 1 つのルート、2 種類の呼び出し元

Nuxt のサーバールートには、ブラウザーのフォームと `$fetch` のどちらから呼ばれたかがわからないので、`Accept` ヘッダーで判断します。コンポーネントの `$fetch` は JSON を求め、JSON か理由付きのエラーを受け取ります。ハイドレーション前やスクリプトがオフのときの素のフォーム送信は HTML を求めるので、ルートは結果コード付きで `/newsletter` への `303` を返します。

図: Nuxt のニュースレターのルートが呼び出し元の種類ごとに応答する方法

1. フォームは /api/newsletter に送信します。ハイドレーション後は $fetch で、その前は素のフォーム送信で送ります。
2. ルートは同意と Turnstile のトークンを確認し、登録を Ikiform に転送します。
3. Accept ヘッダーを読んで応答のしかたを決めます。
4. $fetch が JSON を求めた場合：成功なら JSON を返し、問題があればコンポーネントが表示する理由付きのエラーを投げます。
5. 素のフォーム送信には結果コード付きで /newsletter への 303 リダイレクトを返し、ページがコードをメッセージに変えます。

リダイレクトが運ぶのはコードであってメッセージではないので、細工したリンクで自分の文言をあなたのページに表示させることはできません。

## プロジェクトの準備

`npx nuxi@latest module add turnstile` で Turnstile のモジュールをインストールし、次のようにします。

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

```text
app/
  components/NewsletterForm.vue
  pages/newsletter.vue
server/
  api/newsletter.post.ts
  routes/hooks/ikiform.post.ts
nuxt.config.ts
.env
```

`nuxt.config.ts`:

```ts
export default defineNuxtConfig({
  modules: ["@nuxtjs/turnstile"],
  turnstile: { siteKey: "your_turnstile_site_key" },
  runtimeConfig: {
    ikiformSecretKey: "",
    ikiformWebhookSecret: "",
    beehiivApiKey: "",
    beehiivPublicationId: "",
    turnstile: { secretKey: "" },
  },
  routeRules: {
    "/blog/**": { prerender: true },
  },
});
```

`.env`:

```bash
NUXT_IKIFORM_SECRET_KEY=ik_sk_your_secret_key
NUXT_IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
NUXT_BEEHIIV_API_KEY=your_beehiiv_api_key
NUXT_BEEHIIV_PUBLICATION_ID=pub_00000000-0000-0000-0000-000000000000
NUXT_TURNSTILE_SECRET_KEY=your_turnstile_secret_key
```

`runtimeConfig` のキーはすべて、実行時に対応する `NUXT_` の変数で上書きされるので、設定には空の文字列を置き、値はホストに持たせます。`runtimeConfig.public` 以外のものはブラウザーに届きません。Turnstile のサイトキーはもともと公開するものです。**API keys** で `submissions:write` スコープの Ikiform のキーを作り、[ピラーのフィールド定義](https://ikiform.com/ja/guides/newsletter-signup.md#fields)をフォームの **Fields** タブに貼り付けます。

## 作ってみる

### サーバールート

`server/api/newsletter.post.ts`:

```ts
interface Signup {
  email?: string;
  first_name?: string;
  consent?: boolean | string;
  source?: string;
  _gotcha?: string;
  "cf-turnstile-response"?: string;
}

type Result = "subscribed" | "consent" | "check" | "email" | "failed";

const PLACEMENTS = ["footer", "post", "newsletter-page"];

export default defineEventHandler(async (event) => {
  const body = (await readBody<Signup>(event)) ?? {};
  const wantsJson = getRequestHeader(event, "accept")?.includes("application/json") ?? false;
  const config = useRuntimeConfig(event);

  const finish = (result: Result, status = 200, message = "") => {
    if (!wantsJson) return sendRedirect(event, `/newsletter?result=${result}`, 303);
    if (result === "subscribed") return { subscribed: true };
    throw createError({ status, message, data: { reason: result } });
  };

  if (body.consent !== true && body.consent !== "true") {
    return finish("consent", 422, "Tick the box so we know you want the emails.");
  }
  const check = await verifyTurnstileToken(String(body["cf-turnstile-response"] ?? ""), event);
  if (!check.success) return finish("check", 403, "The human check didn't pass. Try once more.");

  const source = PLACEMENTS.includes(String(body.source)) ? String(body.source) : "footer";
  const origin = getRequestHeader(event, "origin");
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${config.ikiformSecretKey}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: JSON.stringify({
      email: String(body.email ?? "").trim(),
      first_name: String(body.first_name ?? "").trim().slice(0, 80) || undefined,
      consent: true,
      source,
      _gotcha: String(body._gotcha ?? ""),
    }),
  }).catch(() => null);

  const result = await response?.json().catch(() => null);
  if (result?.ok) return finish("subscribed");
  const emailError = result?.error?.fields?.email?.[0];
  if (emailError) return finish("email", 422, emailError);
  return finish("failed", response?.status ?? 502, result?.error?.message ?? "We couldn't reach the list. Try again.");
});
```

- `readBody` は `$fetch` からの JSON と、素のフォーム送信の URL エンコードされたボディの両方を解析します。そのため同意は `true` でも `"true"` でも受け付けます。
- `verifyTurnstileToken` は Turnstile のモジュールから来ており、`runtimeConfig.turnstile.secretKey` を読みます。トークンは 1 回限りで、5 分で期限が切れます。
- Ikiform は無料の `localhost` テストをそのヘッダーで判別し、サーバーの `fetch` は Origin を送らないので、`Origin` の行でブラウザーのオリジンを引き継ぎます。
- `useRuntimeConfig(event)` は実行時の `NUXT_` の上書きを適用した値を取得します。Nuxt はサーバールートでこれを使うことを推奨しています。

> サーバー側の版はシークレットキー付きで送信するので、Ikiform は **Allowed domains** と人間確認をスキップします。その場合はすべての送信がサーバーの IP から来るため、訪問者ごとに 1 分あたり 5 件という Standard の制限が、全訪問者をまとめて数えることになります。サーバー経由で送信するフォームには Relaxed か Custom の[レート制限](https://ikiform.com/ja/docs/rate-limits.md)を選び、`IKIFORM_SECRET_KEY` はサーバー専用の環境変数に置いてください。

### フォームコンポーネント

`app/components/NewsletterForm.vue`:

```vue
<script setup lang="ts">
const props = withDefaults(defineProps<{ source?: "footer" | "post" | "newsletter-page" }>(), { source: "footer" });

const turnstile = ref<{ reset: () => void }>();
const token = ref("");
const status = ref<"idle" | "sending" | "done">("idle");
const problem = ref<{ reason: string; message: string } | null>(null);

async function subscribe(event: Event) {
  const fields = Object.fromEntries(new FormData(event.currentTarget as HTMLFormElement));
  status.value = "sending";
  problem.value = null;
  try {
    await $fetch("/api/newsletter", {
      method: "POST",
      headers: { Accept: "application/json" },
      body: { ...fields, consent: fields.consent === "true", "cf-turnstile-response": token.value },
    });
    status.value = "done";
  } catch (failure) {
    const data = (failure as { data?: { message?: string; data?: { reason?: string } } }).data;
    problem.value = { reason: data?.data?.reason ?? "failed", message: data?.message ?? "Couldn't subscribe. Try again." };
    turnstile.value?.reset();
    status.value = "idle";
  }
}
</script>

<template>
  <p v-if="status === 'done'" role="status">Almost done. Open the email we sent you and confirm your address.</p>
  <form v-else action="/api/newsletter" method="post" @submit.prevent="subscribe">
    <label>
      Email
      <input name="email" type="email" required autocomplete="email" :aria-invalid="problem?.reason === 'email'" />
    </label>
    <label>
      First name (optional)
      <input name="first_name" maxlength="80" autocomplete="given-name" />
    </label>
    <label>
      <input name="consent" type="checkbox" value="true" required :aria-invalid="problem?.reason === 'consent'" />
      Email me the newsletter. I can unsubscribe at any time.
    </label>
    <input type="hidden" name="source" :value="props.source" />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <NuxtTurnstile ref="turnstile" v-model="token" />
    <p v-if="problem" role="alert">{{ problem.message }}</p>
    <button :disabled="status === 'sending'">{{ status === "sending" ? "Subscribing" : "Subscribe" }}</button>
  </form>
</template>
```

- フォームは `action` と `method` を持ち続けるので、ハイドレーション後に `@submit.prevent` が引き継ぐまでは素の方法で送信します。
- `<NuxtTurnstile>` がウィジェットを描画し、そのトークンを `token` に入れます。エラーのあとはトークンが使用済みになるので、`reset()` で新しいものを求めます。
- フィールドは、`event.currentTarget` がまだフォームを指している最初の `await` の前に `FormData` から読みます。

### /newsletter ページ

`app/pages/newsletter.vue`:

```vue
<script setup lang="ts">
const MESSAGES: Record<string, string> = {
  subscribed: "Almost done. Open the email we sent you and confirm your address.",
  consent: "Tick the box so we know you want the emails.",
  check: "The human check needs JavaScript. Turn it on and try again.",
  email: "Check the email address and try again.",
  failed: "We couldn't reach the list. Try again.",
};

const route = useRoute();
const message = computed(() => MESSAGES[String(route.query.result)] ?? "");
</script>

<template>
  <main>
    <h1>The monthly newsletter</h1>
    <p v-if="message" :role="route.query.result === 'subscribed' ? 'status' : 'alert'">{{ message }}</p>
    <NewsletterForm v-if="route.query.result !== 'subscribed'" source="newsletter-page" />
  </main>
</template>
```

Turnstile には JavaScript が必要なので、スクリプトがオフの読者には `check` のメッセージが表示されます。読者にとってそのトレードオフがよくないなら、Turnstile の行を削除し、ハニーポットと必須のボックスに頼ってください。

## 購読者を beehiiv に転送する

このルートは `server/routes` にあるので、`/api` の接頭辞はありません。署名はバイト列そのものが対象なので、何かに解析される前に `readRawBody` でボディを読み、beehiiv が確認メールを送るよう `double_opt_override: "on"` 付きで購読を作成します。名と beehiiv 自身のレート制限のヘッダーの扱いは [beehiiv のガイド](https://ikiform.com/ja/guides/newsletter-signup/beehiiv.md)で追加しています。

`server/routes/hooks/ikiform.post.ts`:

```ts
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

export default defineEventHandler(async (event) => {
  const config = useRuntimeConfig(event);
  let delivery;
  try {
    delivery = await verifyWebhook({
      payload: (await readRawBody(event)) ?? "",
      headers: event.headers,
      secret: config.ikiformWebhookSecret,
    });
  } catch (error) {
    if (error instanceof WebhookVerificationError) {
      setResponseStatus(event, 400);
      return error.reason;
    }
    throw error;
  }
  if (delivery.type !== "submission.created") return sendNoContent(event);

  const { email, consent, source } = delivery.data.fields;
  if (consent !== true || typeof email !== "string") return "Skipped";

  const answer = await fetch(
    `https://api.beehiiv.com/v2/publications/${config.beehiivPublicationId}/subscriptions`,
    {
      method: "POST",
      headers: { Authorization: `Bearer ${config.beehiivApiKey}`, "Content-Type": "application/json" },
      body: JSON.stringify({
        email,
        double_opt_override: "on",
        reactivate_existing: false,
        utm_source: typeof source === "string" ? source : "website",
        referring_site: delivery.data.meta.referrer ?? undefined,
      }),
    },
  );
  if (answer.status === 429) {
    setResponseHeader(event, "retry-after", 60);
    setResponseStatus(event, 503);
    return "beehiiv rate limit";
  }
  if (!answer.ok) {
    setResponseStatus(event, 502);
    return `beehiiv answered ${answer.status}`;
  }
  return sendNoContent(event);
});
```

**Webhooks** に `https://your-site.com/hooks/ikiform` を追加し、ニュースレターのフォームを選び、`whsec_` のシークレットを `NUXT_IKIFORM_WEBHOOK_SECRET` にコピーします。

## ハニーポットと人間確認

`_gotcha` 入力欄は転送され、Ikiform は埋まったものを成功の応答付き・Webhook なしでスパムとして保存するので、beehiiv がそれを目にすることはありません。シークレットキーを使うと Ikiform 自身の Turnstile のチェックはスキップされるので、ルートが自分のウィジェットでチェックを実行しています。

## 描画：generate ではなくハイブリッド

`nuxt generate` はサーバーのない静的サイトを書き出すので、その後は `/api/newsletter` も `/hooks/ikiform` も存在しません。`nuxt build` でビルドし、上の設定が `/blog/**` に対して行っているように、静的にできるページは `routeRules` でプリレンダーしてください。プリレンダーされたページにもフォームは残り、その送信はサーバールートに届きます。

## デプロイ

| ホスト | 対処法 |
| --- | --- |
| Vercel | Nitro は Vercel を検出し、サーバールートを関数としてビルドします。`NUXT_` の変数を **Environment Variables** に追加します。 |
| Netlify | Nitro は Netlify も同じように検出します。変数はサイトの環境設定に追加します。 |
| Cloudflare | `nuxt generate` ではなく `nuxt build` でビルドし、`NUXT_` の変数をプロジェクトのシークレットとして設定します。Ikiform は `workers.dev` と `pages.dev` を拒否するので、Webhook の URL には独自ドメインを使ってください。 |

## localhost で無料でテストする

プランのないワークスペースでも `localhost` からのテスト送信を月 50 件まで受け付け、転送される `Origin` によって `nuxt dev` からの各登録はそれに数えられます。手元で常にチェックが通るよう、Cloudflare が公開している Turnstile のテスト用キーを使ってください。Ikiform は `localhost` に Webhook を配信しないので、`/hooks/ikiform` をテストするには HTTPS の URL が得られるトンネルで開発サーバーを公開してください。

## 動作を確認する

1. `nuxt dev` を実行し、フッターから購読します。フォームが「受信トレイを確認してください」の一文に置き換わり、URL は変わりません。
2. JavaScript を無効にして購読します。ウィジェットが動かなかったので、`/newsletter?result=check` に移動します。
3. JavaScript をオンにしたまま、チェックボックスから `required` を外し、チェックせずに送信します。同意のメッセージが表示され、何も保存されません。
4. ダッシュボードで **Submissions** を開きます。送ったすべてのフィールドを含む新しい送信データがあります。
5. Webhook を追加した場合は、**Webhooks** でエンドポイントを開きます。配信が `succeeded` になり、サーバーの応答が表示されます。
6. beehiiv を開きます。購読が確認されるまで pending の状態で存在し、確認メールが受信トレイに届いています。

## ハマりどころ

| 問題 | 対処法 |
| --- | --- |
| 本番環境で `/api/newsletter` が `404` になる | サイトがサーバーのない `nuxt generate` でビルドされています。`nuxt build` でビルドし、ページは `routeRules` でプリレンダーしてください。 |
| 実行時にシークレットキーが空になる | 変数名が設定のキーと一致していません。`ikiformSecretKey` には `NUXT_IKIFORM_SECRET_KEY` が必要です。 |
| 人間確認が 2 回に 1 回失敗する | トークンが再利用されています。コンポーネントのように、エラーのたびにウィジェットで `reset()` を呼んでください。 |
| ローカルのテストで `402 plan_required` が出る | 送信に `localhost` のオリジンがありませんでした。`Origin` の行を確認し、`localhost` でサイトを開いてください。 |
| 送信データはダッシュボードにあるのに、メールも Webhook も届かない | スパムとして保存されています。スパムでは何も送られません。**Spam** フィルターで開いて理由を確認し、スパムではないとマークすると、メールと Webhook が送られます。 |

## よくある質問

### Nuxt のフォームは JavaScript なしで動きますか？

本物の action と method を持っていれば動きます。このページのように、素のフォーム送信にはリダイレクトし、$fetch には JSON を返すサーバールートに向けてください。

### nuxt generate はニュースレターフォーム用のサーバールートに対応していますか？

いいえ。nuxt generate は静的ファイルしか出力しないので、サーバールートがありません。routeRules で静的なページをプリレンダーしつつ nuxt build を使うか、ブラウザーから直接 Ikiform にフォームを送信してください。

### 購読者をサーバールートではなく Webhook から beehiiv に送るのはなぜですか？

サーバールートに見えるのは Ikiform の応答だけで、スパムにも本物の登録と同じ成功が返ります。beehiiv が読者のことを知るのはスパムチェックのあとであるべきで、それが 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 など任意のツールに渡します。
- [beehiiv](https://ikiform.com/ja/guides/newsletter-signup/beehiiv.md): 自作の登録フォームを beehiiv につなぎます。リレーがダブルオプトインをオンにし、設置場所を UTM ソースに、ページをリファラーにして各購読を作成します。
- [ダブルオプトイン](https://ikiform.com/ja/guides/newsletter-signup/double-opt-in.md): Ikiform は確認メールを送らないので、ダブルオプトインはメールツールで行います。中継が各登録を未確認として追加し、ツールがリンクを送ります。
- [SvelteKit のニュースレターフォーム](https://ikiform.com/ja/guides/newsletter-signup/sveltekit.md): レイアウトに置く SvelteKit 3 のニュースレターフォームです。同意と IP ごとの制限を確認するフォームアクション、ページにとどまる use:enhance、Kit 向けの Webhook のルートを組み合わせます。
- [静的サイト](https://ikiform.com/ja/guides/newsletter-signup/static-site.md): Hugo、Jekyll、Eleventy、Astro の静的サイトにニュースレター登録フォームを追加し、メールツールの API キーをページに入れず、確認はツールで行います。
- [Kit (ConvertKit)](https://ikiform.com/ja/guides/newsletter-signup/kit.md): 購読者を作成し、特典メールのために Kit のフォームに追加し、設置場所のタグを付けるリレーで、自作のニュースレターフォームを Kit につなぎます。
