新しい SaaS のウェイトリスト3 分で読めます
React Router でウェイトリストを作る
全ページのフッターに置く React Router のウェイトリスト。fetcher が 1 つのルート Action に送信し、登録ごとにページを記録し、JavaScript なしでも動きます。
ウェイトリスト専用のルートを作り、メールアドレスを検証してシークレットキーでフォームバックエンドに転送する action を持たせます。フォームは useFetcher でレイアウトに描画するので、どのページから登録してもそのページにとどまり、パスが記録されます。JavaScript がなければ同じフォームがウェイトリストのルートに送信され、そのページに結果が表示されます。
メインガイドの React Router のタブでは、フォームとその action をホームのルートに置いています。1 ページのサイトならそれで十分です。ただ、ローンチ前の SaaS サイトの多くは料金、ブログ記事、ホームなどいくつかのページを持ち、そのすべてに登録フォームを置いて、どのページが効いたかを知りたいものです。このページでは、フォームをレイアウトに置き、すべてのコピーを 1 つの Action に送信します。
React Router ならではの違い
フレームワークモードでは、Action はルートに属し、<Form> は自分が置かれたルートに送信します。レイアウトにあるフォームはすべてのルートに置かれているので、どのコンポーネントを選ぶかで送信時の動きが決まります:
| Item | <Form method="post"> | <fetcher.Form action="/waitlist"> |
|---|---|---|
| 送信先 | action を指定しなければ現在のルート | /waitlist ルートの Action |
| 送信後 | ナビゲーション。URL と履歴が変わる | ナビゲーションなし。訪問者はページにとどまる |
| 結果 | 実行されたルートの actionData | fetcher.data。同じ key のすべての fetcher で共有 |
| JavaScript なし | 通常の送信 | /waitlist への通常の送信。そのページが結果を表示 |
フッターの登録には fetcher が合います。リストに登録するために料金ページを離れたい人はいません。劣化も素直です。ハイドレーション前の fetcher.Form は /waitlist に送信する普通の <form> なので、そのルートには結果を表示する専用のページが必要です。
- レイアウトがすべてのページにウェイトリストフォームを描画し、ページのパスから作った hidden の ref を付けます。
- JavaScript があれば、fetcher.Form がページを離れずに /waitlist に送信します。fetcher の key は共有されるので、ヘッダーの表示も同じ結果を受け取ります。
- JavaScript がなければ、同じフォームが通常のナビゲーションとして /waitlist に送信します。
- どちらの場合も、/waitlist の Action がメールアドレスを確認し、シークレットキーで登録を Ikiform に転送します。
- fetcher は fetcher.data から結果を読みます。通常の送信は /waitlist のページに移動し、そこで actionData から結果を表示します。
プロジェクトを準備する
フォームとシークレットキーの作成はサーバーガイド で説明しています。このページで使うファイル:
app/
root.tsx
routes.ts
routes/home.tsx
routes/pricing.tsx
routes/waitlist.tsx
components/waitlist-form.tsx
react-router.config.ts
.envキーは .env に入れ、Action の中で process.env から読みます。ルートモジュールはブラウザー向けに分割されますが、action はサーバーでしか実行されないので、キーがクライアントに送られることはありません。コンポーネントには決してインポートしないでください。
IKIFORM_SECRET_KEY=ik_sk_your_secret_keyIKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。Action は 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 } }
]
}作ってみる
ルートを登録する
import { index, type RouteConfig, route } from "@react-router/dev/routes";
export default [
index("routes/home.tsx"),
route("pricing", "routes/pricing.tsx"),
route("waitlist", "routes/waitlist.tsx"),
] satisfies RouteConfig;ウェイトリストのルート
import { data } from "react-router";
import { WaitlistForm } from "../components/waitlist-form";
import type { Route } from "./+types/waitlist";
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const SOURCE = /^[a-z0-9_-]{1,40}$/;
export async function action({ request }: Route.ActionArgs) {
const form = await request.formData();
const email = String(form.get("email") ?? "").trim();
if (!EMAIL.test(email)) {
return data({ ok: false, message: "Enter the email we should send your invite to.", email }, { status: 400 });
}
const ref = String(form.get("ref") ?? "");
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: request.headers.get("origin") ?? "",
},
body: JSON.stringify({
email,
ref: SOURCE.test(ref) ? ref : "direct",
_gotcha: String(form.get("_gotcha") ?? ""),
}),
signal: AbortSignal.timeout(8000),
}).catch(() => null);
const result = await response?.json().catch(() => null);
if (result?.ok) return { ok: true, message: "You're on the list. We'll email you on launch day.", email };
return data(
{
ok: false,
message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "Couldn't join the list. Try again.",
email,
},
{ status: response?.status === 429 ? 429 : 400 },
);
}
export default function Waitlist({ actionData }: Route.ComponentProps) {
return (
<main>
<h1>Join the Acme waitlist</h1>
<WaitlistForm initial={actionData} />
</main>
);
}data()はエラーのステータスを設定しつつ、例外として投げるレスポンスにはしないので、コンポーネントは引き続きメッセージを描画できます。- どの応答にもメールアドレスを含めて返すので、エラーの後も入力欄に再表示できます。
- ブラウザーの
Originヘッダーを引き継いで渡します。Ikiform は無料のlocalhostテスト送信をこのヘッダーで判定しますが、Action 自身のfetchはこれを送らないからです。 refは英小文字、数字、-、_からなる 40 文字以内で、定義のmax_lengthと同じです。それ以外はdirectとして保存されます。- デフォルトエクスポートは JavaScript なしの場合のフォールバックです。Action の結果とともに同じフォームを描画します。
共有のフォーム
import { useFetcher, useLocation } from "react-router";
interface Outcome {
ok: boolean;
message: string;
email: string;
}
export function sourceFor(pathname: string) {
const slug = pathname.replace(/^\/+|\/+$/g, "").replace(/\//g, "-").toLowerCase();
return (slug || "home").slice(0, 40);
}
export function WaitlistForm({ initial }: { initial?: Outcome }) {
const fetcher = useFetcher<Outcome>({ key: "waitlist" });
const { pathname } = useLocation();
const outcome = fetcher.data ?? initial;
const pending = fetcher.state !== "idle";
if (outcome?.ok) return <p role="status">{outcome.message}</p>;
return (
<fetcher.Form method="post" action="/waitlist">
<label>
Email
<input name="email" type="email" autoComplete="email" required defaultValue={outcome?.email} />
</label>
<input type="hidden" name="ref" value={sourceFor(pathname)} />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
<p role="alert">{outcome?.message}</p>
<button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
</fetcher.Form>
);
}
export function JoinedNote() {
const fetcher = useFetcher<Outcome>({ key: "waitlist" });
return fetcher.data?.ok ? <span>You're on the list</span> : <a href="#waitlist">Join the waitlist</a>;
}key: "waitlist"により、そのキーを持つ fetcher はすべて 1 つの共有 fetcher になります。ヘッダーのJoinedNoteは同じfetcher.dataを読むので、フッターのフォームが成功した瞬間に表示が変わります。sourceForは/blog/launchをblog-launchに、/をhomeに変換します。パスはサーバーでわかるので、プリレンダーしたページでも動きます。fetcher.stateは、送信中はsubmitting、その後 React Router が再検証する間はloadingになるので、ボタンは両方が終わるまで無効のままです。- ラベルが入力欄を包んでいるので、同じページのヒーローとフッターのコピーが
idを共有することはありません。
app/root.tsx の Layout で、id="waitlist" を付けたフッターの中に <WaitlistForm /> を、ヘッダーに <JoinedNote /> を描画します。ハニーポットも一緒に送られ、そのまま Ikiform に届きます。シークレットキー付きの送信は Ikiform の人間確認をスキップするので、このフォームでは Spam check をオンにしても意味はありません。
すべての loader の再読み込みを省く
Action の後、React Router はページ上のすべてのルートのデータを再読み込みします。登録はそのどれも変えません。loader が遅い、またはコストのかかるルートには次を追加してください:
import type { ShouldRevalidateFunctionArgs } from "react-router";
export function shouldRevalidate({ formAction, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
if (formAction === "/waitlist") return false;
return defaultShouldRevalidate;
}レンダリングとデプロイ
マーケティングページは訪問者ごとに変わらないので、プリレンダーし、サーバーは Action のために残します:
import type { Config } from "@react-router/dev/config";
export default {
ssr: true,
prerender: ["/", "/pricing"],
} satisfies Config;ssr: true のままにしてください。ssr: false では実行時のサーバーがなく、action をエクスポートするルートがあると React Router はビルドを失敗させます。ssr: true なら、/ と /pricing はビルド時に HTML として書き出され、/waitlist は引き続きサーバーで動きます。
| ホスト | 追加するもの | キーの読み方 |
|---|---|---|
| Vercel | @vercel/react-router。react-router.config.ts に presets: [vercelPreset()] を指定 | process.env。Environment Variables で設定 |
| Netlify | @netlify/vite-plugin-react-router。Vite のプラグインに netlifyReactRouter() として追加 | process.env。Environment variables で設定 |
| Cloudflare Workers | @cloudflare/vite-plugin。reactRouter() の前に cloudflare() として追加 | cloudflare:workers の env.IKIFORM_SECRET_KEY。wrangler secret put で設定 |
Cloudflare では import { env } from "cloudflare:workers" とし、process.env ではなくそこからキーを読みます。React Router 8 では Action の context は RouterContextProvider なので、v7 の context.cloudflare.env のパターンはもう使えません。Action の残りは同じです。
動作を確認する
react-router devを実行し、http://localhost:5173/pricingを開いてフッターから登録します。フォームがお礼の一文に置き換わり、ヘッダーの表示も一緒に変わります。- ダッシュボードで Submissions を開きます。登録の
refはpricingになっています。 - JavaScript をオフにしてホームページから登録します。お礼の一文がある
/waitlistに移動し、登録のrefはhomeになっています。 - フッターから
a@bを送信します。入力欄の下にエラーが表示され、アドレスは入力されたまま残ります。 - ハニーポットに値を入れて
_gotcha=botで送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。 react-router devからの送信はlocalhostのオリジンを持つので、テスト送信として数えられます。Ikiform に無料プランはありません。テストは無料で、プランのないワークスペースはlocalhostからのテスト送信を月 50 件まで受け付けます。本番運用するには、月 $6 から 500 件の Pro を選ぶか、買い切りで 10,000 件 $49 または 50,000 件 $149 のパックを購入してください。パックのクレジットに有効期限はありません。料金を参照してください。
ハマりどころ
| 問題 | 対処法 |
|---|---|
JavaScript がオンなのに、フッターから登録すると /waitlist に移動する | フォームが fetcher.Form ではなく <Form> か素の <form> になっています。fetcher を使い、Action から redirect() を返さないでください。fetcher の Action からのリダイレクトはナビゲーションを起こします。 |
action のエクスポートに関するエラーでビルドが失敗する | react-router.config.ts で ssr が false になっています。true にしてください。そうしないとウェイトリストを動かすサーバーがありません。 |
すべての登録の ref が waitlist になる | 送信が /waitlist のページ自体から来ています。それはフォールバック用のページです。リンクは実際のページに向けてください。 |
unknown_field 付きの 422 | フィールド定義に ref がありません。上の定義のように追加してください。 |
402 plan_required | ワークスペースにプランがなく、送信が本番のサイトから来ています。送信データは失われず、保留されています。Settings › Plan and usage でプランを選ぶかパックを購入すると、保留中の送信データが届きます。 |
よくある質問
useFetcher を使い、処理するルートの action を指定して fetcher.Form を描画します。訪問者はページにとどまり、結果は fetcher.data に届きます。ハイドレーション前は通常のフォームとして送信されます。
ssr が true なら動きます。プリレンダーしたページは HTML として配信され、そのフォームはサーバーで動く Action に送信されます。ssr を false にすると Action は使えません。
上の sourceFor のように、ページのパスを hidden の ref 入力欄に入れます。パスはサーバーでもクライアントでもわかるので、プリレンダーしたページでもハイドレーション前でも動きます。
いいえ、無料プランはありません。テストは無料で、支払う前に localhost からテスト送信を月 50 件まで送れます。本番運用には、500 件で月 $6 からの Pro か、買い切りで 10,000 件 $49、または 50,000 件 $149 を選べます。クレジットに有効期限はありません。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com