# TanStack Start でウェイトリストを作る

> TanStack Start のウェイトリスト。1 つのサーバールートが通常のフォーム送信と fetch の両方に応え、validateSearch が流入元と結果を URL で運びます。

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

`/api/waitlist` にサーバールートを追加し、メールアドレスを検証して、シークレットキーで登録をフォームバックエンドに転送し、`fetch` には JSON で、通常のフォーム送信には `303` リダイレクトで応答します。ランディングのルートは `validateSearch` で `?ref=`、`?joined=`、`?problem=` を読むので、JavaScript の有無にかかわらず流入元タグと結果が保たれます。

[メインガイド](https://ikiform.com/ja/guides/waitlist.md#form)には、`createServerFn` と `useServerFn` で作った TanStack Start のタブがあります。短いですが、フォームは React のハイドレーション後にしか動かず、各登録の流入元は固定の文字列です。このページでは、ローンチ当日に共有したい版を作ります。ハイドレーション前でも送信でき、流入元のリンクで各登録にタグを付け、結果を URL に残します。

## TanStack Start ならではの違い

Start にはサーバーでコードを実行する方法が 2 つあり、フォームに対する振る舞いが異なります:

|   | サーバー関数 | サーバールート |
| --- | --- | --- |
| 定義方法 | `createServerFn().handler()` | `server.handlers` 付きの `createFileRoute()` |
| 呼び出し元 | 自分のコンポーネントから、RPC 呼び出しで | 素の `<form>` を含む、あらゆる HTTP クライアント |
| 応答 | 呼び出し元向けにシリアライズされる | 自分で組み立てる `Response` |
| JavaScript なし | `.url` 経由で可能だが、応答は制御できない | 自由に選べる。JSON か、ページへのリダイレクト |

ウェイトリストへの送信は 2 種類のクライアントから来ます。スクリプトを実行しているブラウザーと、まだ読み込んでいないブラウザーです。サーバールートなら 1 つのハンドラーで、それぞれが期待する形で応答できます。両者は `Accept` ヘッダーで見分けます。スクリプトは `application/json` を要求し、通常のフォーム送信は要求しません。

図: TanStack Start のサーバールートが送信の種類ごとにどう応答するか

1. ランディングのルートは validateSearch で ?ref= を読み、hidden の入力欄に出力します。
2. フォームは FormData をサーバールートに送信します。スクリプトが読み込まれていれば fetch で、そうでなければ通常の送信で送ります。
3. ルートはメールアドレスと流入元タグを確認し、シークレットキーで登録を Ikiform に転送します。
4. fetch の送信は JSON を要求したので { ok } かメッセージを受け取り、コンポーネントがお礼の一文かエラーに切り替えます。
5. 通常の送信には、?joined=yes か ?problem= のコードと同じ ref を付けた、ランディングページへの 303 が返ります。validateSearch がそれを読み、ページが結果を表示します。

ハンドラーは 1 つ、応答は 2 種類。JavaScript のない訪問者には URL が結果を運びます。

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

先にフォームとシークレットキーを作成してください。どちらも[サーバーガイド](https://ikiform.com/ja/docs/guides/server.md) で説明しています。編集するファイルは次のとおりです:

`プロジェクト`:

```text
src/
  routes/
    __root.tsx
    index.tsx
    api/waitlist.ts
  waitlist-messages.ts
vite.config.ts
.env.local
```

キーは `.env.local` に保存します。Start は開発時にこのファイルを読み込み、git には含まれません。サーバーのコードは `process.env` から読みます。`VITE_` の接頭辞は絶対に付けないでください。ブラウザー用のコードにバンドルされてしまいます。

`.env.local`:

```bash
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` と `ref` を送ります。フォームの **Fields** タブでフィールドを定義する場合は、両方を含めてください:

`フィールド`:

```json
{
  "version": 1,
  "title": "Waitlist",
  "fields": [
    { "id": "email", "type": "email", "label": "Email", "required": true, "placeholder": "you@company.com" },
    { "id": "ref", "type": "text", "label": "Source", "validation": { "max_length": 40 } }
  ]
}
```

## 作ってみる

### エラーの文言ではなくエラーコード

`src/waitlist-messages.ts`:

```ts
export type Problem = "email" | "busy" | "failed";

export const MESSAGES: Record<Problem, 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.",
};

export function isProblem(value: unknown): value is Problem {
  return typeof value === "string" && value in MESSAGES;
}
```

リダイレクトでは URL に短いコードだけを入れ、メッセージそのものは入れません。そうすれば `?problem=` 付きのリンクは用意した 3 つの文のどれかしか表示できず、他人が URL に打ち込んだ文章が表示されることはありません。

### サーバールート

`src/routes/api/waitlist.ts`:

```ts
import { createFileRoute } from "@tanstack/react-router";
import { MESSAGES, type Problem } from "../../waitlist-messages";

const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const SOURCE = /^[a-z0-9_-]{1,40}$/;

async function forward(request: Request, form: FormData): Promise<Problem | null> {
  const email = String(form.get("email") ?? "").trim();
  if (!EMAIL.test(email)) return "email";
  const ref = String(form.get("ref") ?? "");
  const origin = request.headers.get("origin");

  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: JSON.stringify({
      email,
      ref: SOURCE.test(ref) ? ref : "direct",
      _gotcha: String(form.get("_gotcha") ?? ""),
    }),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);

  if (!response) return "failed";
  if (response.status === 429) return "busy";
  const result = await response.json().catch(() => null);
  if (result?.ok) return null;
  return result?.error?.fields?.email ? "email" : "failed";
}

export const Route = createFileRoute("/api/waitlist")({
  server: {
    handlers: {
      POST: async ({ request }) => {
        const form = await request.formData();
        const problem = await forward(request, form);

        if (request.headers.get("accept")?.includes("application/json")) {
          return problem
            ? Response.json({ ok: false, message: MESSAGES[problem] }, { status: problem === "busy" ? 429 : 400 })
            : Response.json({ ok: true });
        }

        const back = new URL("/", request.url);
        back.searchParams.set("ref", String(form.get("ref") ?? ""));
        if (problem) back.searchParams.set("problem", problem);
        else back.searchParams.set("joined", "yes");
        return Response.redirect(back, 303);
      },
    },
  },
});
```

- `request.formData()` は両方のエンコーディングを読めます。`fetch` が `FormData` のボディで送る `multipart/form-data` と、通常の送信の urlencoded のボディです。
- `process.env` はハンドラーの中、リクエスト時に読みます。Cloudflare Workers でシークレットが入るのは、下の `wrangler.jsonc` のように `nodejs_compat` を有効にし、互換性日付を 2025-04-01 以降にした場合だけです。
- Ikiform は無料の `localhost` テスト送信をブラウザーの `Origin` ヘッダーで判定しますが、ルート自身の `fetch` はこれを送らないので、引き継いで渡します。
- `ref` は、定義の `max_length` に合わせて、英小文字、数字、`-`、`_` からなる 40 文字以内でなければなりません。それ以外は `direct` として保存されます。
- `Response.redirect(back, 303)` でブラウザーは GET でランディングページを読み込むので、再読み込みしても登録が二重に送信されません。

### ランディングのルート

`src/routes/index.tsx`:

```tsx
import { createFileRoute } from "@tanstack/react-router";
import { type FormEvent, useState } from "react";
import { isProblem, MESSAGES, type Problem } from "../waitlist-messages";

interface Search {
  ref?: string;
  joined?: boolean;
  problem?: Problem;
}

export const Route = createFileRoute("/")({
  validateSearch: (search: Record<string, unknown>): Search => ({
    ref: typeof search.ref === "string" ? search.ref.toLowerCase().slice(0, 40) : undefined,
    joined: search.joined === "yes" ? true : undefined,
    problem: isProblem(search.problem) ? search.problem : undefined,
  }),
  component: Home,
});

function Home() {
  const search = Route.useSearch();
  const [joined, setJoined] = useState(Boolean(search.joined));
  const [error, setError] = useState(search.problem ? MESSAGES[search.problem] : "");
  const [pending, setPending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setPending(true);
    setError("");
    try {
      const response = await fetch("/api/waitlist", {
        method: "POST",
        headers: { Accept: "application/json" },
        body: new FormData(event.currentTarget),
      });
      const outcome = await response.json();
      if (outcome.ok) setJoined(true);
      else setError(outcome.message);
    } catch {
      setError("Couldn't reach the server. Try again.");
    } finally {
      setPending(false);
    }
  }

  if (joined) return <p role="status">You're on the list. We'll email you on launch day.</p>;

  return (
    <form method="post" action="/api/waitlist" onSubmit={onSubmit}>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required placeholder="you@company.com" />
      <input type="hidden" name="ref" value={search.ref ?? "direct"} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      <p role="alert">{error}</p>
      <button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
    </form>
  );
}
```

- `validateSearch` は生のクエリを型付きのオブジェクトに変換します。未知の値は `undefined` になるので、壊れたリンクでは通常のフォームが表示されます。
- hidden の `ref` 入力欄は、サーバーで検索パラメーターから埋められます。通常の送信なら、スクリプトなしでもこの値が送られます。
- フォームには本物の `method` と `action` があります。バンドルが読み込まれていなければ、ブラウザーはサーバールートに送信してリダイレクトに従います。
- ハイドレーション後は `onSubmit` が処理を引き継ぎ、`Accept: application/json` を送るので、訪問者はページにとどまります。

ハニーポットは同じ `FormData` に入ってそのまま Ikiform に届くので、ハニーポットを埋めたボットはスパムとして保存されます。シークレットキー付きの送信は Ikiform の人間確認をスキップするため、ここで **Spam check** をオンにしても意味はありません。関門になるのはこのルートです。

## レンダリングとデプロイ

ランディングのルートは、Start のデフォルトであるサーバーレンダリングのままにします。クエリ文字列を読んで `ref` と結果を表示するためで、プリレンダーしたページはクエリなしで 1 回だけビルドされます。他のページのために `tanstackStart()` プラグインで `prerender` を有効にする場合は、`/` を除外してください。

| ホスト | 追加するもの | シークレット |
| --- | --- | --- |
| Vercel | `nitro` パッケージの `nitro()` Vite プラグイン | プロジェクト設定の **Environment Variables** |
| Netlify | `@netlify/vite-plugin-tanstack-start` を Vite プラグインの `netlify()` として | **Site configuration › Environment variables** に追加 |
| Cloudflare Workers | `viteEnvironment: { name: "ssr" }` を付けた `@cloudflare/vite-plugin` と `wrangler.jsonc` | `wrangler secret put IKIFORM_SECRET_KEY` |

`wrangler.jsonc`:

```json
{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "acme-waitlist",
  "compatibility_date": "2025-09-02",
  "compatibility_flags": ["nodejs_compat"],
  "main": "@tanstack/react-start/server-entry"
}
```

`nodejs_compat` を有効にすると、Workers で `process.env` が使えるようになります。有効でないとキーが見つからず、すべての送信が `failed` で失敗します。

## 動作を確認する

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

## ハマりどころ

| 問題 | 対処法 |
| --- | --- |
| 通常の送信で生の JSON が表示される | ブラウザーが `Accept` ヘッダーで `application/json` を送っていますが、通常のフォーム送信ではありえません。拡張機能やプロキシがヘッダーを書き換えていないか確認するか、`fetch` から独自のヘッダーを送ってそれで判定してください。 |
| 本番ですべての送信が `failed` になる | ホストに `IKIFORM_SECRET_KEY` が設定されていません。Workers では、`nodejs_compat` が有効で、互換性の日付が 2025-04-01 以降であることも確認してください。そうでないと `process.env` は空のままです。 |
| 2 つのファイルが `/api/waitlist` を奪い合う | Start ではパスごとにハンドラーのファイルは 1 つだけです。`api/waitlist.ts` と `api/waitlist/index.ts` はどちらも同じパスになるので、片方だけ残してください。 |
| `unknown_field` 付きの `422` | フィールド定義に `ref` がありません。上の定義のように追加してください。 |
| `402 plan_required` | ワークスペースにプランがなく、送信が本番のサイトから来ています。送信データは失われず、保留されています。**Settings › Plan and usage** でプランを選ぶかパックを購入すると、保留中の送信データが届きます。 |

## よくある質問

### TanStack Start のフォームでは、サーバー関数とサーバールートのどちらを使うべきですか？

どちらでも登録を転送できます。サーバールートなら、通常のフォーム送信への応答をページへのリダイレクトなどに選べるので、ハイドレーション前でもフォームが動きます。サーバー関数は、React の読み込み後にだけ動くフォームに向いています。

### TanStack Start で登録元を残すには？

ランディングルートの validateSearch で ?ref= を読み、hidden の入力欄に描画し、転送する前にサーバーで再度確認します。通常の送信の後のリダイレクトで、URL に値を戻します。

### TanStack Start のランディングページはプリレンダーできますか？

クエリ文字列から登録元や結果を描画する場合はできません。プリレンダーされたページはクエリなしでビルドされるからです。そのルートはサーバーレンダリングのままにし、残りをプリレンダーしてください。

### 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、楽観的なサンクス表示、訪問者ごとの制限を扱います。
- [React Router のウェイトリスト](https://ikiform.com/ja/guides/waitlist/react-router.md): 全ページのフッターに置く React Router のウェイトリスト。fetcher が 1 つのルート Action に送信し、登録ごとにページを記録し、JavaScript なしでも動きます。
- [プレーンな HTML のウェイトリスト](https://ikiform.com/ja/guides/waitlist/html.md): ビルド不要でプレーンな HTML だけのウェイトリストページ。JavaScript がオフでも動き、読み込めばその場でお礼を表示し、必要なときはフォールバックします。
- [Product Hunt ローンチリスト](https://ikiform.com/ja/guides/waitlist/product-hunt.md): Product Hunt の数週間前からローンチリストを集め、登録をチャネルごとにタグ付けし、太平洋時間のリセットに合わせてローンチ日のメールを 2 回に分けて送ります。
- [選考付きのベータ登録](https://ikiform.com/ja/guides/waitlist/beta-signup.md): サーバーが必須を強制する選考質問付きのベータ応募フォームと、応募者を採点してリストから各コホートを選ぶスクリプトを紹介します。
