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

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

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

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

Preet SutharIkiform 創業者

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

メインガイドの Nuxt タブでは $fetch で送信し、Vue のハイドレーションが終わってから動きます。このページでは同じフォームをその前でも動くようにし、人間確認を追加して、本物の登録を beehiiv に送ります。製品の設定については Nuxt のドキュメントガイドにあります。

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

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

Nuxt のニュースレターのルートが呼び出し元の種類ごとに応答する方法NewsletterFormaction="/api/newsletter"newsletter.post.ts同意、Turnstile、IkiformAccept:JSON?呼び出し元JSONかエラー$fetchで同じページ303/newsletter?result=...newsletter.vueメッセージを表示$fetch かフォームの POST結果はいいいえNuxt のニュースレターのルートが呼び出し元の種類ごとに応答する方法NewsletterFormaction="/api/newsletter"newsletter.post.ts同意、Turnstile、IkiformAccept:JSON?呼び出し元JSON かエラー$fetch で同じページ303/newsletter?result=...newsletter.vueメッセージを表示$fetch かフォームの POST結果はいいいえ
  1. フォームは /api/newsletter に送信します。ハイドレーション後は $fetch で、その前は素のフォーム送信で送ります。
  2. ルートは同意と Turnstile のトークンを確認し、登録を Ikiform に転送します。
  3. Accept ヘッダーを読んで応答のしかたを決めます。
  4. $fetch が JSON を求めた場合:成功なら JSON を返し、問題があればコンポーネントが表示する理由付きのエラーを投げます。
  5. 素のフォーム送信には結果コード付きで /newsletter への 303 リダイレクトを返し、ページがコードをメッセージに変えます。
リダイレクトが運ぶのはコードであってメッセージではないので、細工したリンクで自分の文言をあなたのページに表示させることはできません。

プロジェクトの準備

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

プロジェクトの構成
app/
  components/NewsletterForm.vue
  pages/newsletter.vue
server/
  api/newsletter.post.ts
  routes/hooks/ikiform.post.ts
nuxt.config.ts
.env
nuxt.config.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
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 のキーを作り、ピラーのフィールド定義をフォームの Fields タブに貼り付けます。

作ってみる

サーバールート

server/api/newsletter.post.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 のレート制限を選び、IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。

フォームコンポーネント

app/components/NewsletterForm.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
<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 のガイドで追加しています。

server/routes/hooks/ikiform.post.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 でプリレンダーしてください。プリレンダーされたページにもフォームは残り、その送信はサーバールートに届きます。

デプロイ

ホスト対処法
VercelNitro は Vercel を検出し、サーバールートを関数としてビルドします。NUXT_ の変数を Environment Variables に追加します。
NetlifyNitro は Netlify も同じように検出します。変数はサイトの環境設定に追加します。
Cloudflarenuxt 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 が送られます。

よくある質問

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