ニュースレター登録フォーム3 分で読めます
Next.js でニュースレター登録フォームを作る
Next.js のフッターのニュースレターフォームです。同意のチェックと Turnstile を行うサーバーアクションが Ikiform に送信し、ルートハンドラーが読者を Kit に追加します。
ルートレイアウトのフッターにクライアントのフォームを置き、サーバーアクションに向けます。アクションは同意のチェックボックスと Cloudflare Turnstile のトークンを確認し、シークレットキー付きで登録をフォームバックエンドに転送します。別のルートハンドラーがバックエンドの署名付き Webhook を受け取って読者をメールツールに追加するので、スパムがリストに届くことはありません。
メインガイドの Next.js タブには、サーバーアクションとフォームのコンポーネントがあります。このページでは実際のサイト向けに全体を作ります。すべてのページのフッターに置く 1 つのフォーム、シークレットキーで Ikiform の確認がスキップされても機能する人間確認、そして問題のない登録を Kit に渡すルートハンドラーです。製品の設定とキーについては Next.js のドキュメントガイドにあります。
Next.js でフッターのフォームにすると何が変わるか
フッターのフォームはルートレイアウトにあるので、Next.js がデプロイ時に 1 回だけビルドするページも含め、すべてのページで描画されます。そこから 3 つのことが言えます。
| 懸念 | 何が起きるか | この構成でしていること |
|---|---|---|
| 静的なページ | フォームが描画中に Cookie やヘッダーを読むと、すべてのページが動的になってしまう | フォームは描画時に何も読まない。リクエストに触れるのはアクションだけ |
| 人間確認 | シークレットキー付きの送信は Ikiform の Turnstile のチェックをスキップする | アクションの中で独自に Turnstile のチェックを行う |
| 呼び出せる人 | サーバーアクションは、フォームを通さなくても呼べる公開の POST エンドポイント | 何かを送る前に同意とトークンを検証する |
- 読者がフッターのフォームに入力します。読んでいるページがどこであれ、サーバーアクションに送信されます。
- アクションは同意のチェックボックスを確認し、次に Cloudflare で Turnstile のトークンを検証します。
- どちらかのチェックに失敗すると、メッセージと入力したメールアドレス付きでフォームが戻ります。何も送信されません。
- そうでなければ、アクションはシークレットキーとブラウザーの Origin を付けて登録を Ikiform に送信します。
- Ikiform の応答がアクションの状態になり、フォームが「受信トレイを確認してください」の一文に置き換わります。
- それとは別に、Ikiform はスパムではないすべての登録について署名付きの Webhook を送ります。ルートハンドラーがそれを検証し、読者を Kit のフォームに追加します。そのフォームの特典メールが Kit の確認メールです。一度は自分のアドレスでテストしてください。理由は Kit のガイドで説明しています。
プロジェクトの準備
app/
layout.tsx
newsletter/
actions.ts
newsletter-form.tsx
submit-button.tsx
page.tsx
api/ikiform/route.ts
lib/
turnstile.ts
.env.localAPI keys で submissions:write スコープのシークレットキーを作ります。Cloudflare のダッシュボードで、自分のドメインと localhost 用の Turnstile ウィジェットを作ります。NEXT_PUBLIC_ の接頭辞を付けてよいのはサイトキーだけで、ほかの値はサーバー専用にしておく必要があります。
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
NEXT_PUBLIC_TURNSTILE_SITE_KEY=your_turnstile_site_key
TURNSTILE_SECRET_KEY=your_turnstile_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
KIT_API_KEY=your_kit_v4_api_key
KIT_FORM_ID=1234567ピラーのフィールド定義をフォームの Fields タブに貼り付けると、何が届いても Ikiform が必須の consent ボックスを強制します。
作ってみる
Turnstile のチェック
Cloudflare の siteverify エンドポイントは、シークレット、トークン、任意で訪問者の IP を受け取ります。トークンの有効期間は 300 秒で、確認できるのは 1 回だけです。
import "server-only";
const SITEVERIFY = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
export async function passesTurnstile(token: string, visitor?: string) {
if (!token) return false;
const body = new URLSearchParams({ secret: process.env.TURNSTILE_SECRET_KEY ?? "", response: token });
if (visitor) body.set("remoteip", visitor);
const response = await fetch(SITEVERIFY, { method: "POST", body }).catch(() => null);
const outcome = (await response?.json().catch(() => null)) as { success?: boolean } | null;
return outcome?.success === true;
}サーバーアクション
"use server";
import { headers } from "next/headers";
import { passesTurnstile } from "@/lib/turnstile";
export interface SignupState {
status: "idle" | "subscribed" | "error";
field?: "email" | "consent" | "check";
message: string;
email: string;
firstName: string;
}
const PLACEMENTS = ["footer", "post", "newsletter-page"];
export async function subscribe(_previous: SignupState, formData: FormData): Promise<SignupState> {
const email = String(formData.get("email") ?? "").trim();
const firstName = String(formData.get("first_name") ?? "").trim().slice(0, 80);
const kept = { email, firstName };
if (formData.get("consent") !== "true") {
return { ...kept, status: "error", field: "consent", message: "Tick the box so we know you want the emails." };
}
const request = await headers();
const visitor = request.get("x-forwarded-for")?.split(",")[0]?.trim();
if (!(await passesTurnstile(String(formData.get("cf-turnstile-response") ?? ""), visitor))) {
return { ...kept, status: "error", field: "check", message: "The human check didn't pass. Try once more." };
}
const source = String(formData.get("source") ?? "");
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: request.get("origin") ?? "",
},
body: JSON.stringify({
email,
first_name: firstName || undefined,
consent: true,
source: PLACEMENTS.includes(source) ? source : "footer",
_gotcha: String(formData.get("_gotcha") ?? ""),
}),
signal: AbortSignal.timeout(8000),
});
} catch {
return { ...kept, status: "error", message: "We couldn't reach the list. Try again in a moment." };
}
const result = await response.json().catch(() => null);
if (result?.ok) {
return { status: "subscribed", message: "Almost done. Open the email we sent you and confirm your address.", email: "", firstName: "" };
}
const emailError = result?.error?.fields?.email?.[0];
if (emailError) return { ...kept, status: "error", field: "email", message: emailError };
return { ...kept, status: "error", message: result?.error?.message ?? "Couldn't subscribe. Try again." };
}- 同意はコストがかからないので先に確認します。Turnstile の呼び出しは 2 番目です。
sourceは固定の一覧と照合します。細工した送信で設置場所のレポートをゴミで埋めることはできません。- Ikiform は無料の
localhostテストをそのヘッダーで判別し、サーバーのfetchは Origin を送らないので、Originの行でブラウザーのオリジンを引き継ぎます。 - どのエラーでも、入力されたメールアドレスと名前を返します。React はアクションのあとにフォームをリセットしますが、
defaultValueで元に戻します。 - ハニーポットはそのまま転送されます。Ikiform は埋まったものをスパムとして保存して成功を返すので、ボットは何も学べません。
IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。フォームと送信中の状態
"use client";
import { useFormStatus } from "react-dom";
export function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? "Subscribing" : "Subscribe"}</button>;
}"use client";
import Script from "next/script";
import { useActionState, useEffect, useId, useRef } from "react";
import { type SignupState, subscribe } from "./actions";
import { SubmitButton } from "./submit-button";
declare global {
interface Window {
turnstile?: { render(box: HTMLElement, options: { sitekey: string }): string; reset(id: string): void };
}
}
const initial: SignupState = { status: "idle", message: "", email: "", firstName: "" };
export function NewsletterForm({ source }: { source: "footer" | "post" | "newsletter-page" }) {
const [state, action] = useActionState(subscribe, initial);
const id = useId();
const box = useRef<HTMLDivElement>(null);
const widget = useRef<string | null>(null);
function mountCheck() {
if (!box.current || !window.turnstile || widget.current) return;
widget.current = window.turnstile.render(box.current, {
sitekey: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ?? "",
});
}
useEffect(mountCheck, []);
useEffect(() => {
if (state.status === "error" && widget.current) window.turnstile?.reset(widget.current);
}, [state]);
if (state.status === "subscribed") return <p role="status">{state.message}</p>;
return (
<form action={action}>
<Script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" onLoad={mountCheck} />
<label htmlFor={`${id}-email`}>Email</label>
<input
id={`${id}-email`}
name="email"
type="email"
required
autoComplete="email"
defaultValue={state.email}
aria-invalid={state.field === "email"}
/>
<label htmlFor={`${id}-name`}>First name (optional)</label>
<input id={`${id}-name`} name="first_name" maxLength={80} autoComplete="given-name" defaultValue={state.firstName} />
<label>
<input name="consent" type="checkbox" value="true" required aria-invalid={state.field === "consent"} />
Email me the newsletter. I can unsubscribe at any time.
</label>
<input type="hidden" name="source" value={source} />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
<div ref={box} />
{state.status === "error" ? <p role="alert">{state.message}</p> : null}
<SubmitButton />
</form>
);
}useFormStatusは<form>の子で読む必要があるので、ボタンを独立したコンポーネントにしています。useIdによって、フッターのフォームと/newsletterページのフォームが同時に画面にあっても、入力欄の id が重複しません。- Turnstile のスクリプトは
?render=explicit付きで読み込みます。renderがウィジェットをフォームの中に置き、フォームは非表示のcf-turnstile-response入力欄を送信します。明示的な描画なら、クライアント側のナビゲーションのあとにマウントされたフォームでも動きます。 - トークンを確認できるのは 1 回だけなので、エラーのたびにウィジェットをリセットします。リセットしないと、2 回目は必ず失敗します。
レイアウトに置く
import { NewsletterForm } from "./newsletter/newsletter-form";
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html lang="en">
<body>
{children}
<footer>
<p>One email a month about what we shipped. No tracking pixels.</p>
<NewsletterForm source="footer" />
</footer>
</body>
</html>
);
}記事の最後では source="post" を、専用のページでは source="newsletter-page" を使います。LayoutProps は、Next.js がレイアウト用に生成するルートを認識した型です。
購読者を Kit に転送する
下のルートハンドラーが Ikiform の Webhook を受け取ります。署名はバイト列そのものが対象なので、まずボディをテキストとして読みます。その URL を Webhooks に追加し、ニュースレターのフォームを選び、whsec_ のシークレットをコピーします。タグと Kit の各応答の意味については Kit のガイドにあります。
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
function kit(path: string, body: Record<string, unknown>) {
return fetch(`https://api.kit.com/v4${path}`, {
method: "POST",
headers: {
"X-Kit-Api-Key": process.env.KIT_API_KEY ?? "",
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(body),
});
}
export async function POST(request: Request) {
let event;
try {
event = await verifyWebhook({
payload: await request.text(),
headers: request.headers,
secret: process.env.IKIFORM_WEBHOOK_SECRET ?? "",
});
} catch (error) {
if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
throw error;
}
if (event.type !== "submission.created") return new Response(null, { status: 204 });
const { email, first_name, consent } = event.data.fields;
if (consent !== true || typeof email !== "string") return new Response("Skipped", { status: 200 });
const created = await kit("/subscribers", {
email_address: email,
first_name: typeof first_name === "string" ? first_name : null,
state: "inactive",
});
const added = created.ok
? await kit(`/forms/${process.env.KIT_FORM_ID}/subscribers`, { email_address: email })
: created;
if (added.status === 429) return new Response("Kit rate limit", { status: 503, headers: { "retry-after": "60" } });
if (!added.ok) return new Response(`Kit answered ${added.status}`, { status: 502 });
return new Response(null, { status: 204 });
}- セッションのない訪問者をリダイレクトする
proxy.ts(旧middleware.ts)があるなら、/api/ikiformをそのmatcherから外してください。そうしないと、すべての配信がハンドラーではなくサインインへのリダイレクトを受け取ります。 next.configでtrailingSlash: trueにしていると、Next.js は/api/ikiformを/api/ikiform/にリダイレクトします。URL はスラッシュ付きで登録してください。Retry-After付きの503なら Ikiform は待ってからリトライし、502なら Kit のステータスが配信ログに残ります。
描画とプログレッシブエンハンスメント
フォームは描画中にリクエストを何も読まないので、レイアウトのせいで静的なページが動的になることはありません。アクションはページの描画モードにかかわらずリクエストごとに動きます。アクションを action に直接渡しているので React は本物のフォーム送信を描画し、JavaScript の読み込み前に送信した読者もアクションに届きます。Turnstile のウィジェットには JavaScript が必要なので、スクリプトがオフだと読者には登録ではなく人間確認のメッセージが表示されます。読者にとってそのトレードオフがよくないなら、チェックをやめ、ハニーポットと必須のボックスに頼ってください。
デプロイ
| ホスト | 対処法 |
|---|---|
| Vercel | 6 つの変数をプロジェクトの Environment Variables に追加します。Ikiform の Webhook は本番のドメインに向けてください。プレビューのデプロイは Vercel の Deployment Protection の背後にあることがあり、Webhook を拒否します。 |
| Netlify | Netlify は独自のアダプターで Next.js をビルドします。サイトの環境設定で Functions をスコープにして変数を追加し、再デプロイします。 |
| Cloudflare | OpenNext で Next.js を Workers 上で動かします。変数は Worker の設定で設定し、Worker に独自ドメインを付けてください。Ikiform は workers.dev の Webhook URL を拒否します。 |
localhost で無料でテストする
プランのないワークスペースでも、localhost からのテスト送信を月 50 件まで受け付けます。Origin を転送しているので、http://localhost:3000 の next dev もそれに数えられます。手元で常にチェックが通るよう、Cloudflare が公開している Turnstile のテスト用キーを .env.local で使ってください。Webhook は localhost に届きません。ルートハンドラーを試すには、HTTPS の URL が得られるトンネルで公開するか、デプロイしてからダッシュボードの Send test を使ってください。
動作を確認する
next devを実行し、フッターまでスクロールして自分のアドレスで購読します。フォームが「受信トレイを確認してください」の一文に置き換わります。- Submissions を開きます。登録の
consentはtrue、sourceはfooterになっています。 - 開発者ツールでチェックボックスから
requiredを外し、チェックせずに送信します。同意のメッセージが表示され、何も保存されません。 - 開発者ツールで
challenges.cloudflare.comをブロックして送信します。人間確認のメッセージが表示され、何も保存されません。 - Webhook を追加した場合は、Webhooks でエンドポイントを開きます。配信が
succeededになり、サーバーの応答が表示されます。 - Kit を開きます。購読者が非アクティブとして存在し、Kit の確認メールが受信トレイに届いています。
- ハニーポットを埋めて
_gotcha=botで送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。
ハマりどころ
| 問題 | 対処法 |
|---|---|
ローカルのテストがすべて 402 plan_required になる | 送信が localhost のオリジンなしで Ikiform に届いています。アクションの Origin の行を確認し、ネットワークのアドレスではなく localhost でアプリを開いてください。 |
| 人間確認が毎回失敗する | サイトキーとシークレットが別々のウィジェットのものか、ウィジェットがこのホスト名向けに設定されていません。テスト用のキーは、テスト用のシークレットとの組み合わせでしか通りません。 |
| フッターを追加したら、すべての静的なページが動的になった | フォームかレイアウトの何かが、描画中に headers() か cookies() を呼んでいます。リクエストはアクションの中でだけ読んでください。 |
| デプロイ直後に Failed to find Server Action が出る | タブがまだ以前のビルドを動かしていて、そのアクション id が変わっています。読者が再読み込みすれば動きます。 |
Webhook の配信に 307 や 308 が表示される | プロキシのリダイレクトか末尾スラッシュの設定が /api/ikiform を捕まえています。matcher から除外するか、スラッシュ付きで URL を登録してください。 |
| 送信データはダッシュボードにあるのに、メールも Webhook も届かない | スパムとして保存されています。スパムでは何も送られません。Spam フィルターで開いて理由を確認し、スパムではないとマークすると、メールと Webhook が送られます。 |
よくある質問
いいえ。Ikiform はスパムにも本物の登録と同じ成功を返すので、アクションでは両者を区別できません。メールツールは Ikiform の Webhook で動かしてください。Webhook はスパムではない登録にだけ送られます。
シークレットキーを使う場合はスキップされるので使えません。このページのようにアクションの中で自分で Turnstile を実行するか、@ikiform/sdk でブラウザーから送信してください。後者なら Ikiform の確認、許可ドメイン、訪問者ごとの制限がそのまま使えます。
いいえ、無料プランはありません。テストは無料で、支払う前に localhost から月 50 件のテスト送信ができます。本番運用するには、月 $6 で 500 件からの Pro か、1 回払いで 10,000 件 $49 または 50,000 件 $149 のパックを選べます。クレジットに有効期限はありません。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com