新しい SaaS のウェイトリスト3 分で読めます
TanStack Start でウェイトリストを作る
TanStack Start のウェイトリスト。1 つのサーバールートが通常のフォーム送信と fetch の両方に応え、validateSearch が流入元と結果を URL で運びます。
/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 を要求し、通常のフォーム送信は要求しません。
- ランディングのルートは validateSearch で ?ref= を読み、hidden の入力欄に出力します。
- フォームは FormData をサーバールートに送信します。スクリプトが読み込まれていれば fetch で、そうでなければ通常の送信で送ります。
- ルートはメールアドレスと流入元タグを確認し、シークレットキーで登録を Ikiform に転送します。
- fetch の送信は JSON を要求したので { ok } かメッセージを受け取り、コンポーネントがお礼の一文かエラーに切り替えます。
- 通常の送信には、?joined=yes か ?problem= のコードと同じ ref を付けた、ランディングページへの 303 が返ります。validateSearch がそれを読み、ページが結果を表示します。
プロジェクトを準備する
先にフォームとシークレットキーを作成してください。どちらもサーバーガイド で説明しています。編集するファイルは次のとおりです:
src/
routes/
__root.tsx
index.tsx
api/waitlist.ts
waitlist-messages.ts
vite.config.ts
.env.localキーは .env.local に保存します。Start は開発時にこのファイルを読み込み、git には含まれません。サーバーのコードは process.env から読みます。VITE_ の接頭辞は絶対に付けないでください。ブラウザー用のコードにバンドルされてしまいます。
IKIFORM_SECRET_KEY=ik_sk_your_secret_keyIKIFORM_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 } }
]
}作ってみる
エラーの文言ではなくエラーコード
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 に打ち込んだ文章が表示されることはありません。
サーバールート
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 でランディングページを読み込むので、再読み込みしても登録が二重に送信されません。
ランディングのルート
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 |
{
"$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 で失敗します。
動作を確認する
vite devを実行し、http://localhost:3000/?ref=testを開いて登録します。再読み込みなしで、フォームがお礼の一文に置き換わります。- ダッシュボードで Submissions を開きます。
refがtestに設定された登録が入っています。 - JavaScript をオフにし、同じ URL を再読み込みしてもう一度登録します。アドレスバーに
?joined=yesが表示され、2 件目の登録にもrefとしてtestが入っています。 - JavaScript をオフにしたまま
a@bを送信し、メールアドレスのメッセージと?problem=email付きでページが戻ってくることを確認します。 - ハニーポットに値を入れて
_gotcha=botで送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。 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 でプランを選ぶかパックを購入すると、保留中の送信データが届きます。 |
よくある質問
どちらでも登録を転送できます。サーバールートなら、通常のフォーム送信への応答をページへのリダイレクトなどに選べるので、ハイドレーション前でもフォームが動きます。サーバー関数は、React の読み込み後にだけ動くフォームに向いています。
ランディングルートの validateSearch で ?ref= を読み、hidden の入力欄に描画し、転送する前にサーバーで再度確認します。通常の送信の後のリダイレクトで、URL に値を戻します。
クエリ文字列から登録元や結果を描画する場合はできません。プリレンダーされたページはクエリなしでビルドされるからです。そのルートはサーバーレンダリングのままにし、残りをプリレンダーしてください。
いいえ、無料プランはありません。テストは無料で、支払う前に localhost からテスト送信を月 50 件まで送れます。本番運用には、500 件で月 $6 からの Pro か、買い切りで 10,000 件 $49、または 50,000 件 $149 を選べます。クレジットに有効期限はありません。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com