ニュースレター登録フォーム

ニュースレター登録フォーム3 分で読めます

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

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

Preet SutharIkiform 創業者

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

メインガイドの React Router タブでは、useFetcher でリソースルートに送信しています。これは JavaScript が読み込まれてからなら動きます。このページでは、読み込まれる前に起きることを直し、登録のたびにページのすべてのローダーが再読み込みされないようにし、MailerLite 向けの Webhook のルートを追加します。製品の設定についてはサーバー側のドキュメントガイドにあります。

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

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

JavaScript のない読者がフッターのフォームを送信したあとに見るものリソースルートのアクションUI ルートのアクションフッターのフォームfetcher.Form、ハイドレーション前/newsletterアクション、コンポーネントなし生の JSON{"ok":true}フッターのフォームfetcher.Form、ハイドレーション前/newsletterアクション +デフォルトのコンポーネント結果のページactionData を描画ドキュメントの POSTレスポンスドキュメントの POST描画JavaScript のない読者がフッターのフォームを送信したあとに見るものリソースルートのアクションUI ルートのアクションフッターのフォームfetcher.Form、ハイドレーション前/newsletterアクション、コンポーネントなし生の JSON{"ok":true}フッターのフォームfetcher.Form、ハイドレーション前/newsletterアクション + デフォルトのコンポーネント結果のページactionData を描画ドキュメントの POSTレスポンスドキュメントの POST描画
  1. ハイドレーション前の fetcher.Form は素のフォームなので、ブラウザーは /newsletter に送信してそこへ遷移します。
  2. /newsletter がリソースルートなら、ブラウザーはアクションの JSON をページとして表示します。
  3. /newsletter にデフォルトのコンポーネントがあれば、React Router はアクションを実行し、その結果を actionData としてページを描画します。
  4. JavaScript が読み込まれたあとは、どちらの版も同じように動きます。fetcher がバックグラウンドで送信し、読者はその場にとどまります。
直し方はデフォルトエクスポート 1 つです。アクションとフッターのフォームは変わりません。

プロジェクトの準備

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

プロジェクトの構成
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
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
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 のキーを作り、ピラーのフィールド定義をフォームの Fields タブに貼り付けます。

作ってみる

/newsletter ルート

app/routes/newsletter.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 のレート制限を選び、IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。

フッターのフォーム

app/components/newsletter-form.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
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 のガイドのリレーを使ってください。先にアドレスを検索するので、再登録した確認済みの読者の状態が保たれます。

app/routes/api.ikiform.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 のガイドのように 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 として動きます。変数はサイトの環境設定に設定します。
CloudflareWorkers 向けの 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 が送られます。

よくある質問

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