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

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

サーバーアクションで Next.js のウェイトリストを作る方法

Next.js App Router のウェイトリスト。シークレットキーで登録を転送するサーバーアクション、useActionState、楽観的なサンクス表示、訪問者ごとの制限を扱います。

Preet SutharIkiform 創業者

メールアドレスを確認し、訪問者ごとの制限をかけ、シークレットキーで登録をフォームバックエンドに転送するサーバーアクションを書きます。エラーが状態として返るよう useActionState から呼び出し、useOptimistic で包めば、ボタンを押した瞬間にサンクスメッセージが表示され、送信に失敗したら元に戻ります。

メインのガイドには、サーバーアクションと useActionState を使った短い Next.js のタブがあります。静かなランディングページならそれで十分です。このページでは、これから広く共有するウェイトリスト向けにもう一歩進めます。即座のサンクス表示、正直なエラーメッセージ、そしてサーバー側からの送信では失われてしまう訪問者ごとの制限です。

サーバーアクションで変わること

サーバーアクションを使うと、シークレットキーを自分のサーバーに置いたまま、アプリから何かを送る前にメールアドレスを確認できます。また、すべての送信が自分のサーバーの IP アドレスから行われるようになり、それには 3 つの影響があります:

Itemブラウザーから Ikiform に送信シークレットキーを使うサーバーアクション
Ikiform から見える送信元各訪問者の IPすべての訪問者について自分のサーバーの IP
訪問者ごとのレート制限Ikiform の制限、IP ごと自分で追加(下記参照)
許可ドメインと人間確認実行されるスキップされる。キーが自分の送信であることを証明するため
呼び出せる人許可ドメインからなら誰でもページに POST できる人なら誰でも

最後の行が重要です。Next.js はサーバーアクションを公開エンドポイントとして扱います。自分のフォームだけでなく、細工した POST を送れば誰でも呼び出せます。シークレットキーで Ikiform の送信元チェックがスキップされるので、関門はアクションだけです。そのため、アクション独自の制限が必要です。

Next.js のサーバーアクションを通る登録の流れフォームuseOptimisticでお礼を表示サーバーアクションメールを確認、IPで制限Ikiformシークレットキー、登録を保存アクションの状態登録完了またはエラーエラー状態何も送信されず、フォームが戻るFormDataJSON結果上限超過Next.js のサーバーアクションを通る登録の流れフォームuseOptimistic でお礼を表示サーバーアクションメールを確認、IP で制限Ikiformシークレットキー、登録を保存アクションの状態登録完了またはエラーエラー状態何も送信されず、フォームが戻るFormDataJSON結果上限超過
  1. 訪問者がボタンを押します。useOptimistic がすぐにフォームをサンクスメッセージに置き換えます。
  2. サーバーアクションがメールアドレスを確認し、その訪問者の最近の試行回数を IP ごとに数えます。
  3. 制限内であれば、シークレットキーを付けて登録を Ikiform に送信します。
  4. 結果がアクションの状態になります。成功ならサンクスメッセージがそのまま残り、エラーならメッセージと入力済みのメールアドレス付きでフォームが戻ります。
  5. 制限を超えている場合は何も送信せず、すぐにエラーの状態を返します。
楽観的な表示はアクションの実行中だけ続きます。画面に何を残すかはアクションの状態が決めます。

作ってみる

シークレットキー

ダッシュボードで API keys を開き、submissions:write スコープを持つシークレットキーを作成して、サーバー専用の変数に保存します。NEXT_PUBLIC_ を先頭に付けてはいけません。

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

訪問者ごとの上限

IP ごとに 1 分あたり 5 件の登録は、人には十分な量で、スクリプトには役に立たない量です。このカウンターはサーバーのメモリに置かれます。server-only を付けると、クライアントコンポーネントがインポートしたときにビルドが失敗します。

lib/rate-limit.ts
import "server-only";

const WINDOW_MS = 60_000;
const LIMIT = 5;
const hits = new Map<string, number[]>();

export function allow(key: string, now = Date.now()) {
  const recent = (hits.get(key) ?? []).filter((time) => now - time < WINDOW_MS);
  const allowed = recent.length < LIMIT;
  if (allowed) recent.push(now);
  if (hits.size > 10_000) hits.clear();
  hits.set(key, recent);
  return allowed;
}
  • メモリはサーバーのインスタンス間で共有されません。単一の Node サーバーやコンテナなら正確です。サーバーレスのホスティングでは各インスタンスが別々に数えるので、減速帯程度と考え、トラフィックが増えたら Redis などの共有ストアにカウンターを移してください。
  • 信頼するのは、ホストが設定する IP ヘッダーだけにしてください。Vercel は x-forwarded-for を設定し、Cloudflare の背後では cf-connecting-ip を読みます。訪問者が自分で設定できるヘッダーでは、まったく制限になりません。

サーバーアクション

app/waitlist/actions.ts
"use server";

import { headers } from "next/headers";
import { allow } from "@/lib/rate-limit";

export interface JoinState {
  status: "idle" | "joined" | "error";
  message: string;
  email?: string;
}

const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

export async function joinWaitlist(_previous: JoinState, formData: FormData): Promise<JoinState> {
  const email = String(formData.get("email") ?? "").trim();
  if (!EMAIL.test(email)) return { status: "error", message: "Enter the email we should send your invite to.", email };

  const incoming = await headers();
  const visitor = incoming.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
  if (!allow(visitor)) return { status: "error", message: "Too many tries from this connection. Wait a minute.", email };

  let response: Response;
  try {
    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: incoming.get("origin") ?? "",
      },
      body: JSON.stringify({
        email,
        ref: String(formData.get("ref") ?? "homepage").slice(0, 40),
        _gotcha: String(formData.get("_gotcha") ?? ""),
      }),
      signal: AbortSignal.timeout(8000),
    });
  } catch {
    return { status: "error", message: "We couldn't reach the list. Try again.", email };
  }

  if (response.status === 429) {
    const wait = response.headers.get("Retry-After") ?? "60";
    return { status: "error", message: `The list is busy. Try again in ${wait} seconds.`, email };
  }
  const result = await response.json().catch(() => null);
  if (result?.ok) return { status: "joined", message: "You're on the list. We'll email you on launch day." };
  return {
    status: "error",
    message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "Couldn't join the list. Try again.",
    email,
  };
}
  • コストの低いチェックを先に実行します。不正なメールアドレスでリクエストが発生することはありません。
  • AbortSignal.timeout によって、遅いネットワークのせいでボタンが送信中のまま止まり続けることを防ぎます。
  • Ikiform の 429 は Retry-After とあわせて別に扱います。すべての訪問者が 1 つの IP の背後にいるので、混み合っているのはこの人ではなくフォーム全体だからです。
  • どのエラーでもメールアドレスを返すので、入力欄にもう一度表示できます。React はアクションのあとにフォームをリセットしますが、defaultValue でアドレスを戻します。
  • Ikiform は無料のテスト送信をブラウザーの Origin ヘッダーで数えるので、それを引き継ぎます。サーバー自身の fetch は何も送りません。
  • SDK の submit() は Authorization ヘッダーを送らないので、許可ドメインをスキップできません。ここではキーを付けて fetch を使います。

楽観的なお礼表示を備えたフォーム

app/waitlist/waitlist-form.tsx
"use client";

import { useActionState, useOptimistic } from "react";
import { type JoinState, joinWaitlist } from "./actions";

const initial: JoinState = { status: "idle", message: "" };

export function WaitlistForm({ source }: { source: string }) {
  const [state, dispatch, pending] = useActionState(joinWaitlist, initial);
  const [shown, showJoined] = useOptimistic(
    state,
    (_current, email: string): JoinState => ({ status: "joined", message: `Adding ${email} to the list` }),
  );

  function join(formData: FormData) {
    showJoined(String(formData.get("email") ?? ""));
    dispatch(formData);
  }

  if (shown.status === "joined") {
    return (
      <p role="status" aria-busy={pending}>
        {shown.message}
      </p>
    );
  }

  return (
    <form action={join}>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required defaultValue={state.email} />
      <input type="hidden" name="ref" value={source} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {shown.status === "error" ? <p role="alert">{shown.message}</p> : null}
      <button disabled={pending}>Join the waitlist</button>
    </form>
  );
}
  • useOptimistic はアクションの中で設定する必要があります。join はフォームのアクションなので、React はそれをトランジションの中で実行し、楽観的な表示は dispatch が終わるまで残ります。
  • 成功すると、アクションの状態が joined になり、表示は最終的なメッセージに変わって残ります。エラーのときは楽観的な値が消え、state.email とメッセージ付きでフォームが戻ります。
  • aria-busy は、表示がまだ確認中であることをスクリーンリーダーに伝えます。
アクションを join で包むとクライアント関数になるので、このフォームには JavaScript が必要です。即座のお礼表示よりハイドレーション前に送信できることが大事なら、dispatch を直接 action に渡して useOptimistic を外してください。残りのコードは同じです。

ページ

app/waitlist/page.tsx
import { WaitlistForm } from "./waitlist-form";

export default async function Page({ searchParams }: PageProps<"/waitlist">) {
  const { ref } = await searchParams;
  const source = typeof ref === "string" ? ref.slice(0, 40) : "homepage";

  return (
    <main>
      <h1>Acme is almost here</h1>
      <p>Leave your email and we'll send you an invite the day it opens.</p>
      <WaitlistForm source={source} />
    </main>
  );
}

ページは URL から ?ref= を読んでフォームに渡すので、/waitlist?ref=newsletter のようなリンクで登録ごとに流入元のタグが付きます。PageProps は Next.js が生成するルート対応の型で、searchParams を読むとページはリクエストごとにレンダリングされます。

動作を確認する

  1. next dev を実行し、/waitlist?ref=test を開いて登録します。リクエストが終わる前にお礼の表示が出ます。
  2. ダッシュボードで Submissions を開きます。ref が test に設定された登録が入っています。
  3. 1 分以内に 6 回送信します。6 回目には上限のメッセージが表示され、保存される送信データは 5 件だけです。
  4. IKIFORM_SECRET_KEY に間違った値を設定して送信します。楽観的な表示が元に戻り、メールアドレスが入ったままのフォームにエラーが表示されます。
  5. 通知の受信者それぞれの受信箱を確認します。各自にフィールドを載せたメールが届き、フォームに email フィールドがあれば Reply は送信者宛てになります。
  6. ハニーポットに値を入れて _gotcha=bot で送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。

ハマりどころ

問題対処法
Ikiform から 403 forbiddenキーが別のワークスペースのもの、失効済み、または submissions:write がありません。そのスコープを持つシークレットキーを作成してください。
429 rate_limited1 人の訪問者が、フォームのレート制限を超えて送信しました。Standard では訪問者ごとに 1 分あたり 5 件までです。Retry-After まで待つか、共有の 1 つの IP から送信するフォームには Relaxed か Custom を選んでください。
すべての訪問者が一斉に上限に達するIP ヘッダーがないため、全員が unknown として数えられています。Cloudflare の背後なら cf-connecting-ip など、ホストが設定するヘッダーを読んでください。
お礼が一瞬表示され、メッセージなしでフォームが戻るアクションが状態を返さずに例外を投げています。サンプルのように、失敗する経路はすべて JoinState を返すようにしてください。
楽観的な更新がトランジションの外で行われたという警告showJoined がイベントハンドラーから呼ばれています。join のように、フォームのアクションの中で呼んでください。

よくある質問

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