# Next.js でニュースレター登録フォームを作る

> Next.js のフッターのニュースレターフォームです。同意のチェックと Turnstile を行うサーバーアクションが Ikiform に送信し、ルートハンドラーが読者を Kit に追加します。

公開日 2026-10-25 · https://ikiform.com/ja/guides/newsletter-signup/nextjs

ルートレイアウトのフッターにクライアントのフォームを置き、サーバーアクションに向けます。アクションは同意のチェックボックスと Cloudflare Turnstile のトークンを確認し、シークレットキー付きで登録をフォームバックエンドに転送します。別のルートハンドラーがバックエンドの署名付き Webhook を受け取って読者をメールツールに追加するので、スパムがリストに届くことはありません。

[メインガイドの Next.js タブ](https://ikiform.com/ja/guides/newsletter-signup.md#form)には、サーバーアクションとフォームのコンポーネントがあります。このページでは実際のサイト向けに全体を作ります。すべてのページのフッターに置く 1 つのフォーム、シークレットキーで Ikiform の確認がスキップされても機能する人間確認、そして問題のない登録を Kit に渡すルートハンドラーです。製品の設定とキーについては [Next.js のドキュメントガイド](https://ikiform.com/ja/docs/guides/nextjs.md)にあります。

## Next.js でフッターのフォームにすると何が変わるか

フッターのフォームはルートレイアウトにあるので、Next.js がデプロイ時に 1 回だけビルドするページも含め、すべてのページで描画されます。そこから 3 つのことが言えます。

| 懸念 | 何が起きるか | この構成でしていること |
| --- | --- | --- |
| 静的なページ | フォームが描画中に Cookie やヘッダーを読むと、すべてのページが動的になってしまう | フォームは描画時に何も読まない。リクエストに触れるのはアクションだけ |
| 人間確認 | シークレットキー付きの送信は Ikiform の Turnstile のチェックをスキップする | アクションの中で独自に Turnstile のチェックを行う |
| 呼び出せる人 | サーバーアクションは、フォームを通さなくても呼べる公開の POST エンドポイント | 何かを送る前に同意とトークンを検証する |

図: Next.js のフッターからの登録が Ikiform に、そして Kit に届くまで

1. 読者がフッターのフォームに入力します。読んでいるページがどこであれ、サーバーアクションに送信されます。
2. アクションは同意のチェックボックスを確認し、次に Cloudflare で Turnstile のトークンを検証します。
3. どちらかのチェックに失敗すると、メッセージと入力したメールアドレス付きでフォームが戻ります。何も送信されません。
4. そうでなければ、アクションはシークレットキーとブラウザーの Origin を付けて登録を Ikiform に送信します。
5. Ikiform の応答がアクションの状態になり、フォームが「受信トレイを確認してください」の一文に置き換わります。
6. それとは別に、Ikiform はスパムではないすべての登録について署名付きの Webhook を送ります。ルートハンドラーがそれを検証し、読者を Kit のフォームに追加します。そのフォームの特典メールが Kit の確認メールです。一度は自分のアドレスでテストしてください。理由は [Kit のガイド](https://ikiform.com/ja/guides/newsletter-signup/kit.md)で説明しています。

アクション自身が Kit を呼ぶことはありません。Ikiform はスパムにも本物の登録と同じ成功を返すので、どの登録が本物かを知っているのは Webhook だけです。

## プロジェクトの準備

`プロジェクトの構成`:

```text
app/
  layout.tsx
  newsletter/
    actions.ts
    newsletter-form.tsx
    submit-button.tsx
    page.tsx
  api/ikiform/route.ts
lib/
  turnstile.ts
.env.local
```

**API keys** で `submissions:write` スコープのシークレットキーを作ります。Cloudflare のダッシュボードで、自分のドメインと `localhost` 用の Turnstile ウィジェットを作ります。`NEXT_PUBLIC_` の接頭辞を付けてよいのはサイトキーだけで、ほかの値はサーバー専用にしておく必要があります。

`.env.local`:

```bash
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
```

[ピラーのフィールド定義](https://ikiform.com/ja/guides/newsletter-signup.md#fields)をフォームの **Fields** タブに貼り付けると、何が届いても Ikiform が必須の `consent` ボックスを強制します。

## 作ってみる

### Turnstile のチェック

Cloudflare の siteverify エンドポイントは、シークレット、トークン、任意で訪問者の IP を受け取ります。トークンの有効期間は 300 秒で、確認できるのは 1 回だけです。

`lib/turnstile.ts`:

```ts
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;
}
```

### サーバーアクション

`app/newsletter/actions.ts`:

```ts
"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 は **Allowed domains** と人間確認をスキップします。その場合はすべての送信がサーバーの IP から来るため、訪問者ごとに 1 分あたり 5 件という Standard の制限が、全訪問者をまとめて数えることになります。サーバー経由で送信するフォームには Relaxed か Custom の[レート制限](https://ikiform.com/ja/docs/rate-limits.md)を選び、`IKIFORM_SECRET_KEY` はサーバー専用の環境変数に置いてください。

### フォームと送信中の状態

`app/newsletter/submit-button.tsx`:

```tsx
"use client";

import { useFormStatus } from "react-dom";

export function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? "Subscribing" : "Subscribe"}</button>;
}
```

`app/newsletter/newsletter-form.tsx`:

```tsx
"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 回目は必ず失敗します。

### レイアウトに置く

`app/layout.tsx`:

```tsx
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 のガイド](https://ikiform.com/ja/guides/newsletter-signup/kit.md)にあります。

`app/api/ikiform/route.ts`:

```ts
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** を使ってください。

## 動作を確認する

1. `next dev` を実行し、フッターまでスクロールして自分のアドレスで購読します。フォームが「受信トレイを確認してください」の一文に置き換わります。
2. **Submissions** を開きます。登録の `consent` は `true`、`source` は `footer` になっています。
3. 開発者ツールでチェックボックスから `required` を外し、チェックせずに送信します。同意のメッセージが表示され、何も保存されません。
4. 開発者ツールで `challenges.cloudflare.com` をブロックして送信します。人間確認のメッセージが表示され、何も保存されません。
5. Webhook を追加した場合は、**Webhooks** でエンドポイントを開きます。配信が `succeeded` になり、サーバーの応答が表示されます。
6. Kit を開きます。購読者が非アクティブとして存在し、Kit の確認メールが受信トレイに届いています。
7. ハニーポットを埋めて `_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 はスパムではない登録にだけ送られます。

### Next.js のサーバーアクションから Ikiform 自身の人間確認を使えますか？

シークレットキーを使う場合はスキップされるので使えません。このページのようにアクションの中で自分で Turnstile を実行するか、@ikiform/sdk でブラウザーから送信してください。後者なら Ikiform の確認、許可ドメイン、訪問者ごとの制限がそのまま使えます。

### Ikiform は無料ですか？

いいえ、無料プランはありません。テストは無料で、支払う前に localhost から月 50 件のテスト送信ができます。本番運用するには、月 $6 で 500 件からの Pro か、1 回払いで 10,000 件 $49 または 50,000 件 $149 のパックを選べます。クレジットに有効期限はありません。

## その他のガイド

- [ニュースレター登録フォーム](https://ikiform.com/ja/guides/newsletter-signup.md): 自分のマークアップでニュースレター登録フォームを作り、同意を記録し、ボットの登録を防ぎ、購読者を Kit、MailerLite、beehiiv など任意のツールに渡します。
- [Kit (ConvertKit)](https://ikiform.com/ja/guides/newsletter-signup/kit.md): 購読者を作成し、特典メールのために Kit のフォームに追加し、設置場所のタグを付けるリレーで、自作のニュースレターフォームを Kit につなぎます。
- [ダブルオプトイン](https://ikiform.com/ja/guides/newsletter-signup/double-opt-in.md): Ikiform は確認メールを送らないので、ダブルオプトインはメールツールで行います。中継が各登録を未確認として追加し、ツールがリンクを送ります。
- [Astro のニュースレターフォーム](https://ikiform.com/ja/guides/newsletter-signup/astro.md): プリレンダーされたページのままで使える Astro のニュースレターフォームです。Astro Action が同意を確認して Ikiform に送信し、エンドポイントが読者を MailerLite に追加します。
- [静的サイト](https://ikiform.com/ja/guides/newsletter-signup/static-site.md): Hugo、Jekyll、Eleventy、Astro の静的サイトにニュースレター登録フォームを追加し、メールツールの API キーをページに入れず、確認はツールで行います。
- [MailerLite](https://ikiform.com/ja/guides/newsletter-signup/mailerlite.md): 自作の登録フォームを MailerLite につなぎます。リレーが新しい読者をダブルオプトインのために未確認として追加し、購読者に二度確認を求めることはありません。
