新しい SaaS のウェイトリスト3 分で読めます
Nuxt でウェイトリストを作る
プリレンダーしたランディングページに Nuxt のウェイトリストを。1 つの Nitro ルートが $fetch と通常のフォーム送信を受け、Zod で確認し、JavaScript なしならリダイレクトします。
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 ヘッダーを確認します。そうしないと、他のサイトのフォームがあなたのキーを使って登録を送れてしまいます。
- ランディングページは静的な HTML です。マウント後、コンポーネントが ?ref= や ?utm_source= を流入元の値にコピーします。
- フォームはサーバールートに送信します。Vue のハイドレーション後は $fetch で JSON として、それ以前は通常の送信で urlencoded として送ります。
- ルートはクロスサイトの送信を拒否し、Zod でボディを検証し、シークレットキーで登録を Ikiform に転送します。
- JSON の送信には { joined: true } か、コンポーネントがフォームの下に表示するメッセージ付きのエラーが返ります。
- 通常の送信には /thanks への 303 か、問題コード付きで / に戻すリダイレクトが返り、ページはマウント後にそのコードをメッセージに変えます。
プロジェクトを準備する
Nuxt のドキュメントガイドのとおりにフォームとシークレットキーを作成します。Nuxt 4 の app/ レイアウトで、このページで使うファイル:
app/
pages/index.vue
pages/thanks.vue
components/WaitlistForm.vue
server/
api/waitlist.post.ts
nuxt.config.ts
.envexport default defineNuxtConfig({
runtimeConfig: {
ikiformSecretKey: "",
},
routeRules: {
"/": { prerender: true },
"/thanks": { prerender: true },
},
});runtimeConfig の空の ikiformSecretKey は、実行時に NUXT_IKIFORM_SECRET_KEY から埋められます。public の外にあるキーはサーバーにしか存在しません。Zod は npm install zod で追加します。
NUXT_IKIFORM_SECRET_KEY=ik_sk_your_secret_keyIKIFORM_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 } }
]
}作ってみる
サーバールート
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)はリクエストごとにキーを読むので、ビルド後にホストで設定した値も反映されます。- リダイレクトには短い問題コードだけを載せ、メッセージそのものは載せないので、細工されたリンクでページに文字を書き込まれることはありません。
フォームコンポーネント
<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 Pages | Git 連携なら設定不要。直接アップロードする場合は --preset=cloudflare_pages でビルドします | プロジェクト設定の暗号化された変数として NUXT_IKIFORM_SECRET_KEY |
動作を確認する
nuxt devを実行し、http://localhost:3000/?ref=testを開いて、役割を選んで登録します。フォームがお礼の一文に置き換わります。- ダッシュボードで Submissions を開きます。登録の
refはtestで、選んだ役割が入っています。 - JavaScript をオフにし、役割を選ばずに登録します。
/thanksに移動し、送信データにroleはありません。 - JavaScript をオフにしたまま
a@bを送信します。/?problem=emailに移動し、JavaScript をオンに戻して再読み込みするとメッセージが表示されます。 - ハニーポットに値を入れて
_gotcha=botで送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。 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 でプランを選ぶかパックを購入すると、保留中の送信データが届きます。 |
よくある質問
はい。フォームに、サーバールートを指す method と action を指定します。Nitro の readBody が urlencoded の送信を解析し、ルートはリダイレクトで応答できます。ハイドレーション後のページから $fetch で送った場合は JSON が返ります。
はい。routeRules でページをプリレンダーし、フォームのサーバールートは動くままにして、nuxt build でビルドします。ref などのクエリの値は、ページのマウント後に読んでください。
非公開の runtimeConfig のキーに置き、ホストの NUXT_ 環境変数から埋めます。非公開のキーは、サーバーで useRuntimeConfig を通してしか読めません。
いいえ、無料プランはありません。テストは無料で、支払う前に localhost からテスト送信を月 50 件まで送れます。本番運用には、500 件で月 $6 からの Pro か、買い切りで 10,000 件 $49、または 50,000 件 $149 を選べます。クレジットに有効期限はありません。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com