新しい SaaS のウェイトリスト

新しい SaaS のウェイトリスト3 分で読めます

Nuxt でウェイトリストを作る

プリレンダーしたランディングページに Nuxt のウェイトリストを。1 つの Nitro ルートが $fetch と通常のフォーム送信を受け、Zod で確認し、JavaScript なしならリダイレクトします。

Preet SutharIkiform 創業者

routeRules でランディングページをプリレンダーし、フォームを Nitro のサーバールートに送信します。ルートはメールアドレスと役割を Zod で検証し、runtimeConfig のシークレットキーでフォームバックエンドに転送します。ルートは $fetch には JSON で、通常のフォーム送信には 303 リダイレクトで応答するので、ページのハイドレーション前でも登録できます。

メインガイドの Nuxt のタブは、サーバールート、runtimeConfig、$fetch で送信するコンポーネントで構成されています。そのフォームは JavaScript が必要で、メールアドレスだけを送り、リクエストごとにページを配信します。この版では、ランディングページをプリレンダーし、仕分け用の質問を 1 つ尋ね、バンドルが読み込まれていなくても動き続けます。

Nuxt ならではの違い

Nuxt はデフォルトでサーバーレンダリングし、Nitro がページと並ぶ server/ フォルダーを配信します。ウェイトリストに関わる点が 3 つあります:

項目ウェイトリストへの影響
prerender: true を付けた routeRulesランディングページは HTML として 1 回だけビルドされる。サーバールートはリクエストごとに実行される
readBody は urlencoded のボディも解析する素の <form> も $fetch と同じルートに送信できる。他人のサイトのフォームも同様
プリレンダーしたページにはビルド時にクエリ文字列がない?ref= と ?problem= は描画中ではなく、ページのマウント後に読む必要がある

2 行目は両刃の剣です。JavaScript なしの経路が動くのはそのおかげですが、だからこそルートは何よりも先に Sec-Fetch-Site ヘッダーを確認します。そうしないと、他のサイトのフォームがあなたのキーを使って登録を送れてしまいます。

1 つの Nitro ルートが $fetch には JSON、通常の送信にはリダイレクトで応答するプリレンダーした/onMountedで?ref=を読むserver/api/waitlist.post.ts同一サイトの確認、ZodIkiformシークレットキー、保存{joined:true}$fetchで同じページ303/thanksまたは/?problem=emailプリレンダーしたページ結果を表示POSTJSONcontent-type が JSONurlencoded1 つの Nitro ルートが $fetch には JSON、通常の送信にはリダイレクトで応答するプリレンダーした /onMounted で ?ref= を読むserver/api/waitlist.post.ts同一サイトの確認、ZodIkiformシークレットキー、保存{ joined: true }$fetch で同じページ303/thanksまたは/?problem=emailプリレンダーしたページ結果を表示POSTJSONcontent-type が JSONurlencoded
  1. ランディングページは静的な HTML です。マウント後、コンポーネントが ?ref= や ?utm_source= を流入元の値にコピーします。
  2. フォームはサーバールートに送信します。Vue のハイドレーション後は $fetch で JSON として、それ以前は通常の送信で urlencoded として送ります。
  3. ルートはクロスサイトの送信を拒否し、Zod でボディを検証し、シークレットキーで登録を Ikiform に転送します。
  4. JSON の送信には { joined: true } か、コンポーネントがフォームの下に表示するメッセージ付きのエラーが返ります。
  5. 通常の送信には /thanks への 303 か、問題コード付きで / に戻すリダイレクトが返り、ページはマウント後にそのコードをメッセージに変えます。
送信のコンテンツタイプで応答が決まります。ブラウザーが行き着くページはどちらもプリレンダーされています。

プロジェクトを準備する

Nuxt のドキュメントガイドのとおりにフォームとシークレットキーを作成します。Nuxt 4 の app/ レイアウトで、このページで使うファイル:

プロジェクト
app/
  pages/index.vue
  pages/thanks.vue
  components/WaitlistForm.vue
server/
  api/waitlist.post.ts
nuxt.config.ts
.env
nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    ikiformSecretKey: "",
  },
  routeRules: {
    "/": { prerender: true },
    "/thanks": { prerender: true },
  },
});

runtimeConfig の空の ikiformSecretKey は、実行時に NUXT_IKIFORM_SECRET_KEY から埋められます。public の外にあるキーはサーバーにしか存在しません。Zod は npm install zod で追加します。

.env
NUXT_IKIFORM_SECRET_KEY=ik_sk_your_secret_key
サーバー側のバージョンはシークレットキーを付けて送信するので、Ikiform は Allowed domains とその人間確認をスキップします。その場合、すべての送信がサーバーの IP から届くため、訪問者ごとに 1 分あたり 5 件という Standard の制限が全訪問者の合計に適用されてしまいます。サーバー経由で送信するフォームには Relaxed か Custom のレート制限を選び、IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。

このフォームは email、role、ref を送ります。フォームの Fields タブでフィールドを定義する場合は 3 つすべてを含め、role の値は select と同じにしてください:

フィールド
{
  "version": 1,
  "title": "Waitlist",
  "fields": [
    { "id": "email", "type": "email", "label": "Email", "required": true, "placeholder": "you@company.com" },
    { "id": "role", "type": "select", "label": "What do you do?", "options": [{ "value": "founder", "label": "Founder" }, { "value": "engineer", "label": "Engineer" }, { "value": "designer", "label": "Designer" }, { "value": "other", "label": "Something else" }] },
    { "id": "ref", "type": "text", "label": "Source", "validation": { "max_length": 40 } }
  ]
}

作ってみる

サーバールート

server/api/waitlist.post.ts
import { z } from "zod";

const Signup = z.object({
  email: z.email(),
  role: z.enum(["founder", "engineer", "designer", "other"]).optional().catch(undefined),
  ref: z.string().regex(/^[a-z0-9_-]{1,40}$/).catch("direct"),
  _gotcha: z.string().catch(""),
});

const MESSAGES = {
  email: "Enter the email we should send your invite to.",
  busy: "Lots of people are joining. Try again in a minute.",
  failed: "Couldn't join the list. Try again.",
};

type Problem = keyof typeof MESSAGES;

export default defineEventHandler(async (event) => {
  if (getRequestHeader(event, "sec-fetch-site") === "cross-site") {
    throw createError({ status: 403, message: "Posts from other sites aren't accepted." });
  }
  const plain = !getRequestHeader(event, "content-type")?.includes("application/json");

  const answer = (problem: Problem | null) => {
    if (plain) return sendRedirect(event, problem ? `/?problem=${problem}` : "/thanks", 303);
    if (problem) throw createError({ status: problem === "busy" ? 429 : 400, message: MESSAGES[problem] });
    return { joined: true };
  };

  const parsed = Signup.safeParse(await readBody(event));
  if (!parsed.success) return answer("email");

  const config = useRuntimeConfig(event);
  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(parsed.data),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);

  if (!response) return answer("failed");
  if (response.status === 429) return answer("busy");
  const result = await response.json().catch(() => null);
  if (result?.ok) return answer(null);
  return answer(result?.error?.fields?.email ? "email" : "failed");
});
  • 接尾辞 .post.ts でルートを POST に限定します。それ以外のメソッドには 405 が返ります。
  • Sec-Fetch-Site は、自分のページからなら same-origin、他の場所のフォームからなら cross-site になります。ブラウザーが設定し、ページのスクリプトは変更できません。
  • Zod の .catch() は、不正な role や ref で登録を失敗させず、安全な値に置き換えます。表示する価値のあるエラーは、不正なメールアドレスだけです。
  • Ikiform は無料の localhost テスト送信をブラウザーの Origin ヘッダーで判定しますが、ルート自身の fetch はこれを送らないので、引き継いで渡します。
  • useRuntimeConfig(event) はリクエストごとにキーを読むので、ビルド後にホストで設定した値も反映されます。
  • リダイレクトには短い問題コードだけを載せ、メッセージそのものは載せないので、細工されたリンクでページに文字を書き込まれることはありません。

フォームコンポーネント

app/components/WaitlistForm.vue
<script setup lang="ts">
const MESSAGES: Record<string, string> = {
  email: "Enter the email we should send your invite to.",
  busy: "Lots of people are joining. Try again in a minute.",
  failed: "Couldn't join the list. Try again.",
};

const route = useRoute();
const email = ref("");
const role = ref("");
const source = ref("direct");
const trap = ref("");
const status = ref<"idle" | "sending" | "joined">("idle");
const error = ref("");

onMounted(() => {
  const tag = String(route.query.ref ?? route.query.utm_source ?? "").toLowerCase().slice(0, 40);
  if (tag) source.value = tag;
  const problem = String(route.query.problem ?? "");
  if (MESSAGES[problem]) error.value = MESSAGES[problem];
});

async function join() {
  status.value = "sending";
  error.value = "";
  try {
    await $fetch("/api/waitlist", {
      method: "POST",
      body: { email: email.value, role: role.value || undefined, ref: source.value, _gotcha: trap.value },
    });
    status.value = "joined";
  } catch (failure) {
    error.value = (failure as { data?: { message?: string } }).data?.message ?? "Couldn't join the list. Try again.";
    status.value = "idle";
  }
}
</script>

<template>
  <p v-if="status === 'joined'" role="status">You're on the list. We'll email you on launch day.</p>
  <form v-else method="post" action="/api/waitlist" @submit.prevent="join">
    <label for="email">Email</label>
    <input id="email" v-model="email" name="email" type="email" autocomplete="email" required />
    <label for="role">What do you do?</label>
    <select id="role" v-model="role" name="role">
      <option value="">Choose one</option>
      <option value="founder">Founder</option>
      <option value="engineer">Engineer</option>
      <option value="designer">Designer</option>
      <option value="other">Something else</option>
    </select>
    <input type="hidden" name="ref" :value="source" />
    <input v-model="trap" name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <p role="alert">{{ error }}</p>
    <button :disabled="status === 'sending'">{{ status === "sending" ? "Joining" : "Join the waitlist" }}</button>
  </form>
</template>
  • フォームは method と action を保持します。ハイドレーション前はブラウザーが urlencoded で送信し、ハイドレーション後は @submit.prevent が $fetch に渡し、JSON で送信します。
  • クエリは onMounted で読みます。描画中に読むと、ビルド時の HTML では空の値、ブラウザーでは別の値になり、ハイドレーションの不一致が起きます。
  • 役割が空なら undefined として送られ、$fetch はそれを JSON のボディから省きます。
  • $fetch のエラーは data.message にルートのメッセージを持つので、コンポーネントはサーバーの文言をそのまま表示します。

app/pages/index.vue で <WaitlistForm /> を使い、お礼の一文を書いた app/pages/thanks.vue を作成します。ハニーポットはそのまま転送されるので、埋めたボットはスパムとして保存されます。シークレットキー付きの送信は Ikiform の人間確認をスキップするので、ここで Spam check をオンにしても意味はありません。

デプロイの注意点

nuxt build でビルドしてください。nuxt generate はすべてのページをサーバーのない静的サイトとしてプリレンダーするので、/api/waitlist が実行されることはありません。上の routeRules なら、1 回のビルドで静的なページと動くルートの両方が得られます。

ホスト設定シークレット
Vercel設定不要。Vercel がインポート時に Nitro を検出しますEnvironment Variables に NUXT_IKIFORM_SECRET_KEY
Netlify設定不要。Nuxt が Netlify のビルドを検出しますEnvironment variables に NUXT_IKIFORM_SECRET_KEY
Cloudflare PagesGit 連携なら設定不要。直接アップロードする場合は --preset=cloudflare_pages でビルドしますプロジェクト設定の暗号化された変数として NUXT_IKIFORM_SECRET_KEY

動作を確認する

  1. nuxt dev を実行し、http://localhost:3000/?ref=test を開いて、役割を選んで登録します。フォームがお礼の一文に置き換わります。
  2. ダッシュボードで Submissions を開きます。登録の ref は test で、選んだ役割が入っています。
  3. JavaScript をオフにし、役割を選ばずに登録します。/thanks に移動し、送信データに role はありません。
  4. JavaScript をオフにしたまま a@b を送信します。/?problem=email に移動し、JavaScript をオンに戻して再読み込みするとメッセージが表示されます。
  5. ハニーポットに値を入れて _gotcha=bot で送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。
  6. nuxt dev からの送信は localhost のオリジンを持つので、テスト送信として数えられます。Ikiform に無料プランはありません。テストは無料で、プランのないワークスペースは localhost からのテスト送信を月 50 件まで受け付けます。本番運用するには、月 $6 から 500 件の Pro を選ぶか、買い切りで 10,000 件 $49 または 50,000 件 $149 のパックを購入してください。パックのクレジットに有効期限はありません。料金を参照してください。

ハマりどころ

問題対処法
本番で /api/waitlist が 404 になるサイトがサーバーを含まない nuxt generate でビルドされています。nuxt build でビルドし、routeRules の prerender はそのままにしてください。
hidden の ref 入力欄でハイドレーションの不一致の警告が出る描画中にクエリを読んでいます。上のように onMounted で読んでください。プリレンダーされた HTML にクエリ文字列はありません。
すべての送信が failed になるキーが空です。runtimeConfig.ikiformSecretKey を埋めるには、変数名を NUXT_IKIFORM_SECRET_KEY にする必要があります。
role や ref で 422 と unknown_field が返るフィールドの定義にそれらが含まれていません。上の定義のように両方を追加してください。
402 plan_requiredワークスペースにプランがなく、送信が本番のサイトから来ています。送信データは失われず、保留されています。Settings › Plan and usage でプランを選ぶかパックを購入すると、保留中の送信データが届きます。

よくある質問

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