ニュースレター登録フォーム3 分で読めます
TanStack Start でニュースレター登録フォームを作る
TanStack Start のニュースレターフォームです。ページ内での登録にはサーバー関数を、JavaScript なしの予備にはサーバールートを使い、本物の登録を beehiiv に送ります。
登録のロジックはサーバーのモジュールに 1 回だけ書き、2 か所から呼びます。ページ内でフォームが呼ぶ createServerFn の POST 関数と、JavaScript が読み込まれていないときに同じフォームが送信するサーバールートです。どちらも同意のチェックボックスを確認し、シークレットキー付きで登録をフォームバックエンドに転送します。2 つ目のサーバールートがバックエンドの署名付き Webhook を受け取り、beehiiv で購読を作成します。
メインガイドの TanStack Start タブは、サーバー関数 1 つとコンポーネント 1 つです。それで JavaScript が読み込まれた読者には対応できます。このページでは、広く共有するサイトに必要なものを追加します。ハイドレーション前に送信した読者のための経路、その経路の結果ページ、本物の登録を beehiiv に渡す Webhook のルートです。製品の設定についてはサーバー側のドキュメントガイドにあります。
TanStack Start で変わること
サーバー関数はクライアントが行う RPC リクエストで呼ばれるので、onSubmit しか知らないフォームはバンドルが動くまで何もしません。TanStack のドキュメントには JavaScript なしでフォームをサーバー関数に送信するための .url プロパティが載っていますが、そのレスポンスがどう描画されるかは記載されていません。サーバールートのほうが素直です。通常のフォームの POST を受け取り、通常のリダイレクトを返します。そこでフォームには、onSubmit に加えて本物の action も持たせます。
- 読者がフォームを送信します。JavaScript が読み込まれているかどうかで経路が決まります。
- ハイドレーション済み:onSubmit が送信を止め、フォームのデータでサーバー関数を呼びます。
- サーバー関数が forwardSignup を実行し、同意を確認して Ikiform に送信し、結果をコンポーネントに返します。
- ハイドレーション前:ブラウザーが /api/newsletter のサーバールートに送信し、同じ forwardSignup を実行します。
- ルートはステータスかエラーコード付きで 303 を返し、/newsletter ページが対応するメッセージを表示します。
プロジェクトの準備
src/
server/newsletter.ts
components/NewsletterForm.tsx
routes/
__root.tsx
newsletter.tsx
api/newsletter.ts
api/ikiform.ts
.envサーバーのコードはシークレットを process.env から読みます。VITE_ の接頭辞は決して付けないでください。Vite は VITE_ の変数をクライアントのバンドルに入れます。API keys で submissions:write スコープの Ikiform のキーを作り、ピラーのフィールド定義をフォームの Fields タブに貼り付けます。
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
BEEHIIV_API_KEY=your_beehiiv_api_key
BEEHIIV_PUBLICATION_ID=pub_00000000-0000-0000-0000-000000000000作ってみる
登録のモジュール
export interface Signup {
email: string;
first_name?: string;
consent: boolean;
source: string;
_gotcha: string;
}
export type Outcome = { ok: true } | { ok: false; field: "email" | "consent" | "failed"; message: string };
const PLACEMENTS = new Set(["footer", "post", "newsletter-page"]);
export function readSignup(form: FormData): Signup {
const source = String(form.get("source") ?? "");
return {
email: String(form.get("email") ?? "").trim(),
first_name: String(form.get("first_name") ?? "").trim().slice(0, 80) || undefined,
consent: form.get("consent") === "true",
source: PLACEMENTS.has(source) ? source : "footer",
_gotcha: String(form.get("_gotcha") ?? ""),
};
}
export async function forwardSignup(signup: Signup, origin: string | undefined): Promise<Outcome> {
if (!signup.consent) {
return { ok: false, field: "consent", message: "Tick the box so we know you want the emails." };
}
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 ? { Origin: origin } : {}),
},
body: JSON.stringify({ ...signup, consent: true }),
signal: AbortSignal.timeout(8000),
}).catch(() => null);
const result = await response?.json().catch(() => null);
if (result?.ok) return { ok: true };
const email = result?.error?.fields?.email?.[0];
if (email) return { ok: false, field: "email", message: email };
return { ok: false, field: "failed", message: result?.error?.message ?? "We couldn't reach the list. Try again." };
}readSignupはFormDataを普通のオブジェクトに変えます。両方の入り口がこれを使うので、フィールド名は 1 か所で管理できます。sourceは 3 つの設置場所のどれかでなければならないので、細工した送信で新しい設置場所をでっち上げることはできません。- Ikiform は無料の
localhostテストをそのヘッダーで判別し、サーバーのfetchは Origin を送らないので、Originの行でブラウザーのオリジンを引き継ぎます。 - どの失敗も例外を投げずに
Outcomeを返すので、コンポーネントでもルートでも表示できます。
IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。サーバー関数とフォーム
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";
import { forwardSignup, type Outcome, readSignup } from "../server/newsletter";
const subscribe = createServerFn({ method: "POST" })
.validator((data: unknown) => {
if (!(data instanceof FormData)) throw new Error("Expected form data");
return readSignup(data);
})
.handler(({ data }) => forwardSignup(data, getRequestHeader("origin")));
export function NewsletterForm({ source = "footer" }: { source?: string }) {
const send = useServerFn(subscribe);
const [outcome, setOutcome] = useState<Outcome | null>(null);
const [pending, setPending] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
setPending(true);
try {
setOutcome(await send({ data }));
} catch {
setOutcome({ ok: false, field: "failed", message: "Couldn't subscribe. Try again." });
} finally {
setPending(false);
}
}
if (outcome?.ok) return <p role="status">Almost done. Open the email we sent you and confirm your address.</p>;
const error = outcome?.ok === false ? outcome : null;
return (
<form method="post" action="/api/newsletter" onSubmit={onSubmit}>
<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={pending}>{pending ? "Subscribing" : "Subscribe"}</button>
</form>
);
}.validatorはクライアントが送ったものをそのまま受け取ります。そこでFormDataかどうかを確認すれば、細工した JSON の呼び出しはハンドラーが動く前に失敗します。getRequestHeaderは@tanstack/react-start/serverから来ており、RPC の呼び出しを運んだリクエストを読みます。そのリクエストはブラウザーがページのオリジン付きで送ったものです。FormDataは、event.currentTargetがまだフォームを指している最初のawaitの前に作ります。useServerFnを使うと、呼び出しがリダイレクトやエラーをルーター経由でたどれます。あとでハンドラーからredirect()を throw する場合に重要になります。
JavaScript なしのときのルートとページ
import { createFileRoute } from "@tanstack/react-router";
import { forwardSignup, readSignup } from "../../server/newsletter";
export const Route = createFileRoute("/api/newsletter")({
server: {
handlers: {
POST: async ({ request }) => {
const signup = readSignup(await request.formData());
const outcome = await forwardSignup(signup, request.headers.get("origin") ?? undefined);
const target = new URL("/newsletter", request.url);
target.searchParams.set(outcome.ok ? "status" : "error", outcome.ok ? "subscribed" : outcome.field);
return Response.redirect(target, 303);
},
},
},
});import { createFileRoute } from "@tanstack/react-router";
import { NewsletterForm } from "../components/NewsletterForm";
const MESSAGES = {
consent: "Tick the box so we know you want the emails.",
email: "Check the email address and try again.",
failed: "We couldn't reach the list. Try again.",
};
export const Route = createFileRoute("/newsletter")({
validateSearch: (search: Record<string, unknown>) => ({
status: search.status === "subscribed" ? ("subscribed" as const) : undefined,
error: typeof search.error === "string" && search.error in MESSAGES ? (search.error as keyof typeof MESSAGES) : undefined,
}),
component: NewsletterPage,
});
function NewsletterPage() {
const { status, error } = Route.useSearch();
if (status === "subscribed") return <p role="status">Almost done. Open the email we sent you and confirm your address.</p>;
return (
<main>
<h1>The newsletter</h1>
{error ? <p role="alert">{MESSAGES[error]}</p> : null}
<NewsletterForm source="newsletter-page" />
</main>
);
}ルートは 303 でリダイレクトするので、結果のページを再読み込みしてもフォームは再送信されません。渡すのは Ikiform のメッセージではなくエラーコードで、validateSearch が 3 つのコード以外を捨てるので、誰かがリンク経由で自分の文言をあなたのページに表示させることはできません。
購読者を beehiiv に転送する
このサーバールートは Ikiform の署名付き Webhook を受け取り、double_opt_override: "on" 付きで beehiiv の購読を作成するので、beehiiv が確認メールを送ります。署名はバイト列そのものが対象なので、何よりも先に request.text() でボディを読みます。名、リファラー、beehiiv のレート制限のヘッダーの扱いは beehiiv のガイドで追加しています。
import { createFileRoute } from "@tanstack/react-router";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
export const Route = createFileRoute("/api/ikiform")({
server: {
handlers: {
POST: async ({ 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, consent, source } = event.data.fields;
if (consent !== true || typeof email !== "string") return new Response("Skipped", { status: 200 });
const answer = await fetch(
`https://api.beehiiv.com/v2/publications/${process.env.BEEHIIV_PUBLICATION_ID}/subscriptions`,
{
method: "POST",
headers: { Authorization: `Bearer ${process.env.BEEHIIV_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({
email,
double_opt_override: "on",
reactivate_existing: false,
utm_source: typeof source === "string" ? source : "website",
utm_medium: "signup_form",
}),
},
);
if (answer.status === 429) return new Response("beehiiv rate limit", { status: 503, headers: { "retry-after": "60" } });
if (!answer.ok) return new Response(`beehiiv answered ${answer.status}`, { status: 502 });
return new Response(null, { status: 204 });
},
},
},
});Webhooks に https://your-site.com/api/ikiform を追加し、ニュースレターのフォームを選び、whsec_ のシークレットを IKIFORM_WEBHOOK_SECRET にコピーします。
ハニーポットと人間確認
_gotcha 入力欄は登録と一緒に送られ、Ikiform は埋まったものを成功の応答付き・Webhook なしでスパムとして保存するので、beehiiv がそれを目にすることはありません。シークレットキーを使うと Ikiform の Turnstile のチェックはスキップされます。ボットがハニーポットと必須のボックスをすり抜けてくるなら、Turnstile のガイドのように forwardSignup で Turnstile のトークンを検証してください。そうすれば両方の入り口に適用されます。
描画
Start はデフォルトでルートをサーバーで描画するので、action も含めたフォームの HTML は最初のレスポンスに入っています。ページを静的ファイルとしてプリレンダーしても、フォームはそこで動きます。サーバー関数と 2 つのサーバールートは、ホスト上でオンデマンドで動きます。
デプロイ
| ホスト | 対処法 |
|---|---|
| Vercel | Nitro を追加します。nitro をインストールし、nitro/vite の nitro() を Vite のプラグインに入れます。4 つの変数を Environment Variables に設定します。 |
| Netlify | @netlify/vite-plugin-tanstack-start を追加し、netlify() を Vite のプラグインに入れます。変数はサイトの環境設定に設定します。 |
| Cloudflare | @cloudflare/vite-plugin をプラグインの先頭に追加し、nodejs_compat をオンにする wrangler.jsonc を用意します。このフラグと新しい互換性の日付があれば、Workers は変数とシークレットを process.env で公開します。Ikiform は workers.dev を拒否するので、Webhook の URL には独自ドメインを使ってください。 |
localhost で無料でテストする
プランのないワークスペースでも localhost からのテスト送信を月 50 件まで受け付け、転送される Origin によって開発サーバーからの各登録はそれに数えられます。両方の経路をテストしてください。1 回は通常どおり、もう 1 回は開発者ツールで JavaScript を無効にしてです。Ikiform は localhost に Webhook を配信しないので、/api/ikiform は HTTPS の URL が得られるトンネル経由でテストしてください。
動作を確認する
- 開発サーバーを起動し、フッターから購読します。フォームが「受信トレイを確認してください」の一文に置き換わり、URL は変わりません。
- JavaScript を無効にしてもう一度購読します。
/newsletter?status=subscribedに移動します。 - JavaScript をオフにしたまま、
requiredを外してボックスのチェックを外して送信します。同意のメッセージ付きで/newsletter?error=consentに移動します。 - ダッシュボードで Submissions を開きます。送ったすべてのフィールドを含む新しい送信データがあります。
- Webhook を追加した場合は、Webhooks でエンドポイントを開きます。配信が
succeededになり、サーバーの応答が表示されます。 - beehiiv を開きます。購読が確認されるまで pending の状態で存在し、確認メールが受信トレイに届いています。
ハマりどころ
| 問題 | 対処法 |
|---|---|
本番環境でシークレットキーが undefined になる | ホストに変数が渡っていないか、ブラウザーで動くコードで読まれています。シークレットはサーバー関数とサーバールートの中でだけ読んでください。 |
| JavaScript なしで送信すると JSON のエラーページが表示される | フォームの action がサーバールートではなくサーバー関数を指しています。/api/newsletter に送信してください。 |
ローカルのテストで 402 plan_required が出る | 送信が localhost のオリジンなしで Ikiform に届いています。forwardSignup が両方の呼び出し元からオリジンを受け取っていることを確認してください。 |
Webhook の配信が 400 で失敗する | シークレットがエンドポイントのものと一致していないか、request.text() より前に何かがボディを解析しています。先に生のテキストを読んでください。 |
| 送信データはダッシュボードにあるのに、メールも Webhook も届かない | スパムとして保存されています。スパムでは何も送られません。Spam フィルターで開いて理由を確認し、スパムではないとマークすると、メールと Webhook が送られます。 |
よくある質問
TanStack のドキュメントにはフォームをサーバー関数に送信するための .url プロパティが載っていますが、予備としてはサーバールートのほうが簡単です。素のフォームの POST を受け取ってリダイレクトします。このページでは、共通のモジュール 1 つを背後に置いて両方を使っています。
サーバー専用の環境変数に置き、サーバー関数とサーバールートの中で process.env から読みます。VITE_ の接頭辞が付いた変数はブラウザーのバンドルに入ってしまいます。
Ikiform はスパムにも本物の登録と同じ成功を返します。Webhook はスパムではない登録にだけ送られるので、Webhook から購読を作成すればボットをリストに入れずに済みます。
いいえ、無料プランはありません。テストは無料で、支払う前に localhost から月 50 件のテスト送信ができます。本番運用するには、月 $6 で 500 件からの Pro か、1 回払いで 10,000 件 $49 または 50,000 件 $149 のパックを選べます。クレジットに有効期限はありません。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com