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

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

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

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

Preet SutharIkiform 創業者

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

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

TanStack Start ならではの違い

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

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

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

TanStack Start のサーバールートが送信の種類ごとにどう応答するかランディングのルートvalidateSearchが ?ref=を読むPOST/api/waitlistメールを確認して転送Ikiformシークレットキー、保存JSONスクリプトが結果を表示/ へ 303?joined=yesか?problem=emailランディングのルート結果を表示FormDataJSONAccept: json通常の送信TanStack Start のサーバールートが送信の種類ごとにどう応答するかランディングのルートvalidateSearch が?ref= を読むPOST/api/waitlistメールを確認して転送Ikiformシークレットキー、保存JSONスクリプトが結果を表示/ へ 303?joined=yesか?problem=emailランディングのルート結果を表示FormDataJSONAccept: json通常の送信
  1. ランディングのルートは validateSearch で ?ref= を読み、hidden の入力欄に出力します。
  2. フォームは FormData をサーバールートに送信します。スクリプトが読み込まれていれば fetch で、そうでなければ通常の送信で送ります。
  3. ルートはメールアドレスと流入元タグを確認し、シークレットキーで登録を Ikiform に転送します。
  4. fetch の送信は JSON を要求したので { ok } かメッセージを受け取り、コンポーネントがお礼の一文かエラーに切り替えます。
  5. 通常の送信には、?joined=yes か ?problem= のコードと同じ ref を付けた、ランディングページへの 303 が返ります。validateSearch がそれを読み、ページが結果を表示します。
ハンドラーは 1 つ、応答は 2 種類。JavaScript のない訪問者には URL が結果を運びます。

プロジェクトを準備する

先にフォームとシークレットキーを作成してください。どちらもサーバーガイド で説明しています。編集するファイルは次のとおりです:

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

ルートは email と ref を送ります。フォームの Fields タブでフィールドを定義する場合は、両方を含めてください:

フィールド
{
  "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
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
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
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 を有効にする場合は、/ を除外してください。

ホスト追加するものシークレット
Vercelnitro パッケージの nitro() Vite プラグインプロジェクト設定の Environment Variables
Netlify@netlify/vite-plugin-tanstack-start を Vite プラグインの netlify() としてSite configuration › Environment variables に追加
Cloudflare WorkersviteEnvironment: { name: "ssr" } を付けた @cloudflare/vite-plugin と wrangler.jsoncwrangler secret put IKIFORM_SECRET_KEY
wrangler.jsonc
{
  "$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 のパックを購入してください。パックのクレジットに有効期限はありません。料金を参照してください。

ハマりどころ

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

よくある質問

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