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

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

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

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

[メインガイド](https://ikiform.com/ja/guides/waitlist.md#form)の 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、通常の送信にはリダイレクトで応答する

1. ランディングページは静的な HTML です。マウント後、コンポーネントが ?ref= や ?utm_source= を流入元の値にコピーします。
2. フォームはサーバールートに送信します。Vue のハイドレーション後は $fetch で JSON として、それ以前は通常の送信で urlencoded として送ります。
3. ルートはクロスサイトの送信を拒否し、Zod でボディを検証し、シークレットキーで登録を Ikiform に転送します。
4. JSON の送信には { joined: true } か、コンポーネントがフォームの下に表示するメッセージ付きのエラーが返ります。
5. 通常の送信には /thanks への 303 か、問題コード付きで / に戻すリダイレクトが返り、ページはマウント後にそのコードをメッセージに変えます。

送信のコンテンツタイプで応答が決まります。ブラウザーが行き着くページはどちらもプリレンダーされています。

## プロジェクトを準備する

[Nuxt のドキュメントガイド](https://ikiform.com/ja/docs/guides/nuxt.md)のとおりにフォームとシークレットキーを作成します。Nuxt 4 の `app/` レイアウトで、このページで使うファイル:

`プロジェクト`:

```text
app/
  pages/index.vue
  pages/thanks.vue
  components/WaitlistForm.vue
server/
  api/waitlist.post.ts
nuxt.config.ts
.env
```

`nuxt.config.ts`:

```ts
export default defineNuxtConfig({
  runtimeConfig: {
    ikiformSecretKey: "",
  },
  routeRules: {
    "/": { prerender: true },
    "/thanks": { prerender: true },
  },
});
```

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

`.env`:

```bash
NUXT_IKIFORM_SECRET_KEY=ik_sk_your_secret_key
```

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

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

`フィールド`:

```json
{
  "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`:

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

```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 Pages | Git 連携なら設定不要。直接アップロードする場合は `--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 のパックを購入してください。パックのクレジットに有効期限はありません。[料金](https://ikiform.com/ja/pricing)を参照してください。

## ハマりどころ

| 問題 | 対処法 |
| --- | --- |
| 本番で `/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** でプランを選ぶかパックを購入すると、保留中の送信データが届きます。 |

## よくある質問

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

はい。フォームに、サーバールートを指す method と action を指定します。Nitro の readBody が urlencoded の送信を解析し、ルートはリダイレクトで応答できます。ハイドレーション後のページから $fetch で送った場合は JSON が返ります。

### ウェイトリストフォームのある Nuxt のページをプリレンダーできますか？

はい。routeRules でページをプリレンダーし、フォームのサーバールートは動くままにして、nuxt build でビルドします。ref などのクエリの値は、ページのマウント後に読んでください。

### Nuxt ではシークレットキーをどこに置けばよいですか？

非公開の runtimeConfig のキーに置き、ホストの NUXT_ 環境変数から埋めます。非公開のキーは、サーバーで useRuntimeConfig を通してしか読めません。

### Ikiform は無料ですか？

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

## その他のガイド

- [新しい SaaS のウェイトリスト](https://ikiform.com/ja/guides/waitlist.md): 自分のランディングページにローンチ前のウェイトリストを作ります。フォーム、スパム対策、重複、確認メール、ローンチ日のきれいなエクスポートまで扱います。
- [Next.js のウェイトリスト](https://ikiform.com/ja/guides/waitlist/nextjs.md): Next.js App Router のウェイトリスト。シークレットキーで登録を転送するサーバーアクション、useActionState、楽観的なサンクス表示、訪問者ごとの制限を扱います。
- [SvelteKit のウェイトリスト](https://ikiform.com/ja/guides/waitlist/sveltekit.md): SvelteKit 3 のウェイトリスト。フックが最初の ?ref= を Cookie に保存し、名前付きのフォーム Action が登録を転送し、再訪者には登録済みと表示します。
- [選考付きのベータ登録](https://ikiform.com/ja/guides/waitlist/beta-signup.md): サーバーが必須を強制する選考質問付きのベータ応募フォームと、応募者を採点してリストから各コホートを選ぶスクリプトを紹介します。
- [Product Hunt ローンチリスト](https://ikiform.com/ja/guides/waitlist/product-hunt.md): Product Hunt の数週間前からローンチリストを集め、登録をチャネルごとにタグ付けし、太平洋時間のリセットに合わせてローンチ日のメールを 2 回に分けて送ります。
- [モバイルアプリのウェイトリスト](https://ikiform.com/ja/guides/waitlist/mobile-app.md): 使っているスマホを尋ね、iPhone のテスターを TestFlight に送り、Play のクローズドテストを埋める、iOS・Android アプリ向けの公開前ウェイトリストです。
