# React Router でニュースレター登録フォームを作る

> React Router のフッターのニュースレターフォームです。useFetcher が JavaScript なしでも動く /newsletter ルートに送信し、リソースルートが MailerLite に登録を渡します。

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

アクションは専用のページを持つ本物の `/newsletter` ルートに置き、`useFetcher` で作ったフッターのフォームからそこへ送信します。JavaScript があれば fetcher がバックグラウンドで送信し、読者は記事にとどまります。なければ同じフォームが素の POST になり、結果付きの `/newsletter` ページに移動します。アクションは同意を確認して登録をフォームバックエンドに転送し、リソースルートが署名付きの Webhook を MailerLite の購読者に変えます。

[メインガイドの React Router タブ](https://ikiform.com/ja/guides/newsletter-signup.md#form)では、`useFetcher` でリソースルートに送信しています。これは JavaScript が読み込まれてからなら動きます。このページでは、読み込まれる前に起きることを直し、登録のたびにページのすべてのローダーが再読み込みされないようにし、MailerLite 向けの Webhook のルートを追加します。製品の設定については[サーバー側のドキュメントガイド](https://ikiform.com/ja/docs/guides/server.md)にあります。

## アクションにページが必要な理由

React Router のドキュメントでは、`fetcher.Form` は JavaScript が読み込まれるまでは通常の HTML フォームだと説明されています。その時点での送信はフォームの `action` の URL へのドキュメントの POST になり、ブラウザーはそのルートが描画するものを表示します。リソースルートは何も描画せず、データを返すだけです。そのため早くクリックした読者は生の JSON を目にします。デフォルトのコンポーネントを持つルートにアクションを置けば、同じ POST がページに着地します。

図: JavaScript のない読者がフッターのフォームを送信したあとに見るもの

1. ハイドレーション前の fetcher.Form は素のフォームなので、ブラウザーは /newsletter に送信してそこへ遷移します。
2. /newsletter がリソースルートなら、ブラウザーはアクションの JSON をページとして表示します。
3. /newsletter にデフォルトのコンポーネントがあれば、React Router はアクションを実行し、その結果を actionData としてページを描画します。
4. JavaScript が読み込まれたあとは、どちらの版も同じように動きます。fetcher がバックグラウンドで送信し、読者はその場にとどまります。

直し方はデフォルトエクスポート 1 つです。アクションとフッターのフォームは変わりません。

## プロジェクトの準備

これはサーバーでの描画をオンにしたフレームワークモードを使います。`react-router.config.ts` の `ssr: true` で、これがデフォルトです。

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

```text
app/
  root.tsx
  routes.ts
  routes/
    home.tsx
    newsletter.tsx
    api.ikiform.ts
  components/newsletter-form.tsx
react-router.config.ts
.env
```

`app/routes.ts`:

```ts
import { type RouteConfig, index, route } from "@react-router/dev/routes";

export default [
  index("routes/home.tsx"),
  route("newsletter", "routes/newsletter.tsx"),
  route("api/ikiform", "routes/api.ikiform.ts"),
] satisfies RouteConfig;
```

`.env`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
MAILERLITE_API_KEY=your_mailerlite_token
MAILERLITE_GROUP_ID=123456789
```

**API keys** で `submissions:write` スコープの Ikiform のキーを作り、[ピラーのフィールド定義](https://ikiform.com/ja/guides/newsletter-signup.md#fields)をフォームの **Fields** タブに貼り付けます。

## 作ってみる

### /newsletter ルート

`app/routes/newsletter.tsx`:

```tsx
import { data } from "react-router";
import { NewsletterForm } from "~/components/newsletter-form";
import type { Route } from "./+types/newsletter";

const PLACEMENTS = ["footer", "post", "newsletter-page"];

export async function action({ request }: Route.ActionArgs) {
  const form = await request.formData();
  if (form.get("consent") !== "true") {
    return data(
      { ok: false as const, field: "consent", message: "Tick the box so we know you want the emails." },
      { status: 422 },
    );
  }

  const source = String(form.get("source") ?? "");
  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: String(form.get("email") ?? "").trim(),
      first_name: String(form.get("first_name") ?? "").trim().slice(0, 80) || undefined,
      consent: true,
      source: PLACEMENTS.includes(source) ? source : "footer",
      _gotcha: String(form.get("_gotcha") ?? ""),
    }),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);

  const result = await response?.json().catch(() => null);
  if (result?.ok) return { ok: true as const };
  const email = result?.error?.fields?.email?.[0];
  return data(
    {
      ok: false as const,
      field: email ? "email" : "form",
      message: String(email ?? result?.error?.message ?? "We couldn't reach the list. Try again."),
    },
    { status: response?.status ?? 502 },
  );
}

export function meta() {
  return [{ title: "Newsletter" }];
}

export default function Newsletter({ actionData }: Route.ComponentProps) {
  return (
    <main>
      <h1>The monthly newsletter</h1>
      <NewsletterForm source="newsletter-page" initial={actionData} />
    </main>
  );
}
```

- エラーは throw せず、`data()` と `4xx` のステータスで返すので、エラーバウンダリーではなくフォームに届きます。
- 同意のチェックはネットワークの呼び出しより前に行います。ボックスにチェックがなくてもコストはかかりません。
- Ikiform は無料の `localhost` テストをそのヘッダーで判別し、サーバーの `fetch` は Origin を送らないので、`Origin` の行でブラウザーのオリジンを引き継ぎます。
- デフォルトのコンポーネントは `actionData` を渡した同じフォームを描画するので、JavaScript なしの結果とページ内の結果が同じ見た目になります。

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

### フッターのフォーム

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

```tsx
import { useFetcher } from "react-router";
import type { Route } from "../routes/+types/newsletter";
import type { action } from "../routes/newsletter";

interface Props {
  source: "footer" | "post" | "newsletter-page";
  initial?: Route.ComponentProps["actionData"];
}

export function NewsletterForm({ source, initial }: Props) {
  const fetcher = useFetcher<typeof action>({ key: `newsletter-${source}` });
  const result = fetcher.data ?? initial;
  const busy = fetcher.state !== "idle";

  if (result?.ok) return <p role="status">Almost done. Open the email we sent you and confirm your address.</p>;
  const error = result?.ok === false ? result : null;

  return (
    <fetcher.Form method="post" action="/newsletter">
      <label>
        Email
        <input name="email" type="email" required autoComplete="email" aria-invalid={error?.field === "email"} />
      </label>
      <label>
        First name (optional)
        <input name="first_name" maxLength={80} autoComplete="given-name" />
      </label>
      <label>
        <input name="consent" type="checkbox" value="true" required aria-invalid={error?.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 />
      {error ? <p role="alert">{error.message}</p> : null}
      <button disabled={busy}>{busy ? "Subscribing" : "Subscribe"}</button>
    </fetcher.Form>
  );
}
```

- バックグラウンドでの送信後は `fetcher.data` に、ドキュメントの POST のあとは `initial` にアクションの応答が入ります。存在するほうが使われます。
- `fetcher.state` は `submitting`、次に `loading` を経て `idle` に戻るので、その両方の間ボタンは無効のままです。
- 設置場所ごとの `key` によって、フッターのフォームと `/newsletter` ページのフォームが同時に画面にあっても、別々の状態を持てます。

### ページのデータを再読み込みせずにマウントする

`app/root.tsx`:

```tsx
import type { ShouldRevalidateFunctionArgs } from "react-router";
import { Outlet } from "react-router";
import { NewsletterForm } from "~/components/newsletter-form";

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

export default function App() {
  return (
    <>
      <Outlet />
      <footer>
        <p>One email a month. Unsubscribe from any issue.</p>
        <NewsletterForm source="footer" />
      </footer>
    </>
  );
}
```

fetcher がアクションに送信すると、React Router はデフォルトで一致するすべてのルートのローダーを再検証します。ニュースレターの登録はローダーが読むものを何も変えないので、ルート（root）の `shouldRevalidate` で、送信先が `/newsletter` のときはその処理を飛ばします。ローダーの重いルートにも同じエクスポートを追加してください。`Layout` はエラーバウンダリーも包むので、フォームは `Layout` ではなくデフォルトの `App` コンポーネントに置きます。

## 購読者を MailerLite に転送する

Webhook はアプリの外から来るので、これはリソースルートにします。コンポーネントのない `action` で、外部の呼び出し元向けに React Router のドキュメントが勧めるとおり、素の `Response` オブジェクトを返します。署名はバイト列そのものが対象なので、まずボディをテキストとして読みます。すべての登録を `unconfirmed` として送るので、新しいリストに向いています。すでに確認済みの読者がいるリストなら、代わりに [MailerLite のガイド](https://ikiform.com/ja/guides/newsletter-signup/mailerlite.md)のリレーを使ってください。先にアドレスを検索するので、再登録した確認済みの読者の状態が保たれます。

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

```ts
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
import type { Route } from "./+types/api.ikiform";

export async function action({ request }: Route.ActionArgs) {
  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 answer = await fetch("https://connect.mailerlite.com/api/subscribers", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.MAILERLITE_API_KEY}`,
      "Content-Type": "application/json",
      Accept: "application/json",
    },
    body: JSON.stringify({
      email,
      fields: typeof first_name === "string" && first_name ? { name: first_name } : {},
      groups: [process.env.MAILERLITE_GROUP_ID],
      status: "unconfirmed",
    }),
  });

  if (answer.status === 429) return new Response("MailerLite rate limit", { status: 503, headers: { "retry-after": "60" } });
  if (!answer.ok) return new Response(`MailerLite answered ${answer.status}`, { status: 502 });
  return new Response(null, { status: 204 });
}
```

**Webhooks** に `https://your-site.com/api/ikiform` を追加し、ニュースレターのフォームを選び、`whsec_` のシークレットをコピーします。

## ハニーポットと人間確認

`_gotcha` 入力欄は転送され、Ikiform は埋まったものを成功の応答付き・Webhook なしでスパムとして保存するので、MailerLite がそれを目にすることはありません。シークレットキーを使うと Ikiform の Turnstile のチェックはスキップされ、すべての送信がサーバーの IP から来るようになります。ボットが通り抜けてくるなら、[Turnstile のガイド](https://ikiform.com/ja/guides/spam-protection/cloudflare-turnstile.md)のように `fetch` の前にアクションで Turnstile のトークンを検証してください。

## 描画とプリレンダー

`ssr: true` のまま `prerender` オプションで記事をプリレンダーできます。サーバーは引き続き `/newsletter` と Webhook のルートをオンデマンドで動かします。SPA モード（`ssr: false`）はここでは使えません。React Router のドキュメントによると、実行時のサーバーがなければアクションは禁止され、アクションを持つルートがあるとビルドが失敗します。完全に静的な React Router のサイトでは、代わりに `@ikiform/sdk` でブラウザーから送信してください。

## デプロイ

| ホスト | 対処法 |
| --- | --- |
| Vercel | 設定なしでデプロイできます。Vercel は、`react-router.config.ts` の `presets` に `@vercel/react-router/vite` の `vercelPreset()` を追加することを推奨しています。変数は **Environment Variables** に設定します。 |
| Netlify | `@netlify/vite-plugin-react-router` をインストールして Vite のプラグインに追加します。SSR は Netlify Functions として動きます。変数はサイトの環境設定に設定します。 |
| Cloudflare | Workers 向けの React Router のテンプレートから始めます。シークレットは `process.env` ではなく `import { env } from "cloudflare:workers"` で読みます。React Router 8 ではもう `context.cloudflare.env` には入りません。Ikiform は `workers.dev` を拒否するので、Webhook には独自ドメインを使ってください。 |

## localhost で無料でテストする

プランのないワークスペースでも `localhost` からのテスト送信を月 50 件まで受け付け、転送される `Origin` によって開発サーバーからの各登録はそれに数えられます。早くクリックしたときの経路は、開発者ツールで JavaScript を無効にしてテストします。Ikiform は `localhost` に Webhook を配信しないので、`/api/ikiform` をテストするには HTTPS の URL が得られるトンネルで開発サーバーを公開してください。

## 動作を確認する

1. `react-router dev` を実行し、記事を開いてフッターから購読します。「受信トレイを確認してください」の一文が表示され、URL は変わりません。
2. ネットワークタブを開き、新しいページからもう一度購読します。`/newsletter` へのリクエストが 1 つだけで、そのあとにローダーのリクエストはありません。
3. JavaScript を無効にして購読します。JSON ではなく、同じ一文が表示された `/newsletter` に移動します。
4. ダッシュボードで **Submissions** を開きます。送ったすべてのフィールドを含む新しい送信データがあります。
5. Webhook を追加した場合は、**Webhooks** でエンドポイントを開きます。配信が `succeeded` になり、サーバーの応答が表示されます。
6. MailerLite を開きます。購読者が未確認としてグループに入っていて、確認メールが受信トレイに届いています。

## ハマりどころ

| 問題 | 対処法 |
| --- | --- |
| アクションに関するエラーでビルドが失敗する | `ssr` が `false` になっています。アクションには実行時のサーバーが必要です。SSR をオンにし、静的なページには `prerender` を使ってください。 |
| 登録のたびにページのデータが再読み込みされる | ローダーはどのアクションのあとでも再検証されます。`formAction` が `/newsletter` のときに `false` を返す `shouldRevalidate` を追加してください。 |
| Cloudflare でシークレットキーが `undefined` になる | Workers はデフォルトでは `process.env` を埋めません。アクションの中で `cloudflare:workers` の `env` からシークレットを読んでください。 |
| ローカルのテストで `402 plan_required` が出る | 送信に `localhost` のオリジンがありませんでした。アクションの `Origin` の行を確認し、`localhost` でアプリを開いてください。 |
| 送信データはダッシュボードにあるのに、メールも Webhook も届かない | スパムとして保存されています。スパムでは何も送られません。**Spam** フィルターで開いて理由を確認し、スパムではないとマークすると、メールと Webhook が送られます。 |

## よくある質問

### useFetcher は JavaScript なしで動きますか？

はい。fetcher.Form は通常の HTML フォームを描画するので、ハイドレーション前は送信してアクションの URL へ遷移します。その URL をコンポーネントのあるルートにしないと、読者にはアクションの生のデータが表示されます。

### ニュースレターのアクションはリソースルートにすべきですか？

人が送信するフォームならすべきではありません。JavaScript なしの送信でページが表示されるよう、デフォルトのコンポーネントを持つルートを使ってください。リソースルートは、Webhook やブラウザー以外の呼び出し元のために取っておきます。

### 購読者をアクションではなく Webhook から MailerLite に追加するのはなぜですか？

理由は 2 つあります。アクションはスパムにも本物の登録と同じ成功の応答を受け取るので、ボットも追加してしまいます。また、アクションの中で MailerLite がエラーを起こすと、Ikiform がすでに保存した登録なのに失敗と表示されてしまいますが、失敗した Webhook の配信はリトライされます。

### Ikiform は無料ですか？

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

## その他のガイド

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