ニュースレター登録フォーム3 分で読めます
React Router でニュースレター登録フォームを作る
React Router のフッターのニュースレターフォームです。useFetcher が JavaScript なしでも動く /newsletter ルートに送信し、リソースルートが MailerLite に登録を渡します。
アクションは専用のページを持つ本物の /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 がページに着地します。
- ハイドレーション前の fetcher.Form は素のフォームなので、ブラウザーは /newsletter に送信してそこへ遷移します。
- /newsletter がリソースルートなら、ブラウザーはアクションの JSON をページとして表示します。
- /newsletter にデフォルトのコンポーネントがあれば、React Router はアクションを実行し、その結果を actionData としてページを描画します。
- JavaScript が読み込まれたあとは、どちらの版も同じように動きます。fetcher がバックグラウンドで送信し、読者はその場にとどまります。
プロジェクトの準備
これはサーバーでの描画をオンにしたフレームワークモードを使います。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
.envimport { 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;IKIFORM_SECRET_KEY=ik_sk_your_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
MAILERLITE_API_KEY=your_mailerlite_token
MAILERLITE_GROUP_ID=123456789API keys で submissions:write スコープの Ikiform のキーを作り、ピラーのフィールド定義をフォームの Fields タブに貼り付けます。
作ってみる
/newsletter ルート
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_SECRET_KEY はサーバー専用の環境変数に置いてください。フッターのフォーム
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ページのフォームが同時に画面にあっても、別々の状態を持てます。
ページのデータを再読み込みせずにマウントする
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 のガイドのリレーを使ってください。先にアドレスを検索するので、再登録した確認済みの読者の状態が保たれます。
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 として動きます。変数はサイトの環境設定に設定します。 |
| 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 が得られるトンネルで開発サーバーを公開してください。
動作を確認する
react-router devを実行し、記事を開いてフッターから購読します。「受信トレイを確認してください」の一文が表示され、URL は変わりません。- ネットワークタブを開き、新しいページからもう一度購読します。
/newsletterへのリクエストが 1 つだけで、そのあとにローダーのリクエストはありません。 - JavaScript を無効にして購読します。JSON ではなく、同じ一文が表示された
/newsletterに移動します。 - ダッシュボードで Submissions を開きます。送ったすべてのフィールドを含む新しい送信データがあります。
- Webhook を追加した場合は、Webhooks でエンドポイントを開きます。配信が
succeededになり、サーバーの応答が表示されます。 - 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 が送られます。 |
よくある質問
はい。fetcher.Form は通常の HTML フォームを描画するので、ハイドレーション前は送信してアクションの URL へ遷移します。その URL をコンポーネントのあるルートにしないと、読者にはアクションの生のデータが表示されます。
人が送信するフォームならすべきではありません。JavaScript なしの送信でページが表示されるよう、デフォルトのコンポーネントを持つルートを使ってください。リソースルートは、Webhook やブラウザー以外の呼び出し元のために取っておきます。
理由は 2 つあります。アクションはスパムにも本物の登録と同じ成功の応答を受け取るので、ボットも追加してしまいます。また、アクションの中で MailerLite がエラーを起こすと、Ikiform がすでに保存した登録なのに失敗と表示されてしまいますが、失敗した Webhook の配信はリトライされます。
いいえ、無料プランはありません。テストは無料で、支払う前に localhost から月 50 件のテスト送信ができます。本番運用するには、月 $6 で 500 件からの Pro か、1 回払いで 10,000 件 $49 または 50,000 件 $149 のパックを選べます。クレジットに有効期限はありません。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com