新しい SaaS のウェイトリスト2 分で読めます
サーバーアクションで Next.js のウェイトリストを作る方法
Next.js App Router のウェイトリスト。シークレットキーで登録を転送するサーバーアクション、useActionState、楽観的なサンクス表示、訪問者ごとの制限を扱います。
メールアドレスを確認し、訪問者ごとの制限をかけ、シークレットキーで登録をフォームバックエンドに転送するサーバーアクションを書きます。エラーが状態として返るよう useActionState から呼び出し、useOptimistic で包めば、ボタンを押した瞬間にサンクスメッセージが表示され、送信に失敗したら元に戻ります。
メインのガイドには、サーバーアクションと useActionState を使った短い Next.js のタブがあります。静かなランディングページならそれで十分です。このページでは、これから広く共有するウェイトリスト向けにもう一歩進めます。即座のサンクス表示、正直なエラーメッセージ、そしてサーバー側からの送信では失われてしまう訪問者ごとの制限です。
サーバーアクションで変わること
サーバーアクションを使うと、シークレットキーを自分のサーバーに置いたまま、アプリから何かを送る前にメールアドレスを確認できます。また、すべての送信が自分のサーバーの IP アドレスから行われるようになり、それには 3 つの影響があります:
| Item | ブラウザーから Ikiform に送信 | シークレットキーを使うサーバーアクション |
|---|---|---|
| Ikiform から見える送信元 | 各訪問者の IP | すべての訪問者について自分のサーバーの IP |
| 訪問者ごとのレート制限 | Ikiform の制限、IP ごと | 自分で追加(下記参照) |
| 許可ドメインと人間確認 | 実行される | スキップされる。キーが自分の送信であることを証明するため |
| 呼び出せる人 | 許可ドメインからなら誰でも | ページに POST できる人なら誰でも |
最後の行が重要です。Next.js はサーバーアクションを公開エンドポイントとして扱います。自分のフォームだけでなく、細工した POST を送れば誰でも呼び出せます。シークレットキーで Ikiform の送信元チェックがスキップされるので、関門はアクションだけです。そのため、アクション独自の制限が必要です。
- 訪問者がボタンを押します。useOptimistic がすぐにフォームをサンクスメッセージに置き換えます。
- サーバーアクションがメールアドレスを確認し、その訪問者の最近の試行回数を IP ごとに数えます。
- 制限内であれば、シークレットキーを付けて登録を Ikiform に送信します。
- 結果がアクションの状態になります。成功ならサンクスメッセージがそのまま残り、エラーならメッセージと入力済みのメールアドレス付きでフォームが戻ります。
- 制限を超えている場合は何も送信せず、すぐにエラーの状態を返します。
作ってみる
シークレットキー
ダッシュボードで API keys を開き、submissions:write スコープを持つシークレットキーを作成して、サーバー専用の変数に保存します。NEXT_PUBLIC_ を先頭に付けてはいけません。
IKIFORM_SECRET_KEY=ik_sk_your_secret_keyIKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。訪問者ごとの上限
IP ごとに 1 分あたり 5 件の登録は、人には十分な量で、スクリプトには役に立たない量です。このカウンターはサーバーのメモリに置かれます。server-only を付けると、クライアントコンポーネントがインポートしたときにビルドが失敗します。
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を読みます。訪問者が自分で設定できるヘッダーでは、まったく制限になりません。
サーバーアクション
"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を使います。
楽観的なお礼表示を備えたフォーム
"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 を外してください。残りのコードは同じです。ページ
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 を読むとページはリクエストごとにレンダリングされます。
動作を確認する
next devを実行し、/waitlist?ref=testを開いて登録します。リクエストが終わる前にお礼の表示が出ます。- ダッシュボードで Submissions を開きます。
refがtestに設定された登録が入っています。 - 1 分以内に 6 回送信します。6 回目には上限のメッセージが表示され、保存される送信データは 5 件だけです。
IKIFORM_SECRET_KEYに間違った値を設定して送信します。楽観的な表示が元に戻り、メールアドレスが入ったままのフォームにエラーが表示されます。- 通知の受信者それぞれの受信箱を確認します。各自にフィールドを載せたメールが届き、フォームに
emailフィールドがあれば Reply は送信者宛てになります。 - ハニーポットに値を入れて
_gotcha=botで送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。
ハマりどころ
| 問題 | 対処法 |
|---|---|
Ikiform から 403 forbidden | キーが別のワークスペースのもの、失効済み、または submissions:write がありません。そのスコープを持つシークレットキーを作成してください。 |
429 rate_limited | 1 人の訪問者が、フォームのレート制限を超えて送信しました。Standard では訪問者ごとに 1 分あたり 5 件までです。Retry-After まで待つか、共有の 1 つの IP から送信するフォームには Relaxed か Custom を選んでください。 |
| すべての訪問者が一斉に上限に達する | IP ヘッダーがないため、全員が unknown として数えられています。Cloudflare の背後なら cf-connecting-ip など、ホストが設定するヘッダーを読んでください。 |
| お礼が一瞬表示され、メッセージなしでフォームが戻る | アクションが状態を返さずに例外を投げています。サンプルのように、失敗する経路はすべて JoinState を返すようにしてください。 |
| 楽観的な更新がトランジションの外で行われたという警告 | showJoined がイベントハンドラーから呼ばれています。join のように、フォームのアクションの中で呼んでください。 |
よくある質問
どちらでも動きます。ブラウザーからの送信なら、Ikiform の訪問者ごとの制限、許可ドメイン、人間確認がそのまま使えます。サーバーアクションはエンドポイントを隠し、先に入力をチェックでき、ハイドレーション前でも動きますが、すべての送信が自分のサーバーから来るので、訪問者ごとの制限は自分で追加します。
はい。フォームの action prop など同じアクションの中で、楽観的な値を設定し、useActionState の dispatch 関数を呼びます。楽観的な値はアクションの実行中に表示され、終わるとアクションの状態に置き換わります。
いいえ。サーバーアクションが各登録を Ikiform に転送し、Ikiform が保存、スパムの除外、メール通知、Webhook の送信を行います。アプリが持つ状態はレート制限のカウンターだけです。
いいえ、無料プランはありません。テストは無料で、支払う前に localhost からテスト送信を月 50 件まで送れます。本番運用には、500 件で月 $6 からの Pro か、買い切りで 10,000 件 $49、または 50,000 件 $149 を選べます。クレジットに有効期限はありません。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com