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

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

React Router でウェイトリストを作る

全ページのフッターに置く React Router のウェイトリスト。fetcher が 1 つのルート Action に送信し、登録ごとにページを記録し、JavaScript なしでも動きます。

Preet SutharIkiform 創業者

ウェイトリスト専用のルートを作り、メールアドレスを検証してシークレットキーでフォームバックエンドに転送する 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 と履歴が変わるナビゲーションなし。訪問者はページにとどまる
結果実行されたルートの actionDatafetcher.data。同じ key のすべての fetcher で共有
JavaScript なし通常の送信/waitlist への通常の送信。そのページが結果を表示

フッターの登録には fetcher が合います。リストに登録するために料金ページを離れたい人はいません。劣化も素直です。ハイドレーション前の fetcher.Form は /waitlist に送信する普通の <form> なので、そのルートには結果を表示する専用のページが必要です。

React Router の全ページのフッターからの登録は 1 つのルート Action を通る/pricingref = pricingfetcher.Formkey: waitlist/waitlist のActionIkiform に転送fetcher.data同じページ、ヘッダーも更新/blog/launchref =blog-launch通常のフォームJavaScript未読み込み/waitlist のAction同じコード/waitlistのページactionDataPOSTPOSTページフォームAction結果React Router の全ページのフッターからの登録は 1 つのルート Action を通る/pricingref = pricingfetcher.Formkey: waitlist/waitlist の ActionIkiform に転送fetcher.data同じページ、ヘッダーも更新/blog/launchref = blog-launch通常のフォームJavaScript 未読み込み/waitlist の Action同じコード/waitlist のページactionDataPOSTPOST
  1. レイアウトがすべてのページにウェイトリストフォームを描画し、ページのパスから作った hidden の ref を付けます。
  2. JavaScript があれば、fetcher.Form がページを離れずに /waitlist に送信します。fetcher の key は共有されるので、ヘッダーの表示も同じ結果を受け取ります。
  3. JavaScript がなければ、同じフォームが通常のナビゲーションとして /waitlist に送信します。
  4. どちらの場合も、/waitlist の Action がメールアドレスを確認し、シークレットキーで登録を Ikiform に転送します。
  5. fetcher は fetcher.data から結果を読みます。通常の送信は /waitlist のページに移動し、そこで actionData から結果を表示します。
Action は 1 つ、フォームはいくつでも。パスを見れば、どのページで登録されたかがわかります。

プロジェクトを準備する

フォームとシークレットキーの作成はサーバーガイド で説明しています。このページで使うファイル:

プロジェクト
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 はサーバーでしか実行されないので、キーがクライアントに送られることはありません。コンポーネントには決してインポートしないでください。

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

作ってみる

ルートを登録する

app/routes.ts
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;

ウェイトリストのルート

app/routes/waitlist.tsx
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 の結果とともに同じフォームを描画します。

共有のフォーム

app/components/waitlist-form.tsx
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 が遅い、またはコストのかかるルートには次を追加してください:

app/routes/pricing.tsx
import type { ShouldRevalidateFunctionArgs } from "react-router";

export function shouldRevalidate({ formAction, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
  if (formAction === "/waitlist") return false;
  return defaultShouldRevalidate;
}

レンダリングとデプロイ

マーケティングページは訪問者ごとに変わらないので、プリレンダーし、サーバーは Action のために残します:

react-router.config.ts
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 の残りは同じです。

動作を確認する

  1. react-router dev を実行し、http://localhost:5173/pricing を開いてフッターから登録します。フォームがお礼の一文に置き換わり、ヘッダーの表示も一緒に変わります。
  2. ダッシュボードで Submissions を開きます。登録の ref は pricing になっています。
  3. JavaScript をオフにしてホームページから登録します。お礼の一文がある /waitlist に移動し、登録の ref は home になっています。
  4. フッターから a@b を送信します。入力欄の下にエラーが表示され、アドレスは入力されたまま残ります。
  5. ハニーポットに値を入れて _gotcha=bot で送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。
  6. 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 でプランを選ぶかパックを購入すると、保留中の送信データが届きます。

よくある質問

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