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

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

TanStack Start でニュースレター登録フォームを作る

TanStack Start のニュースレターフォームです。ページ内での登録にはサーバー関数を、JavaScript なしの予備にはサーバールートを使い、本物の登録を beehiiv に送ります。

Preet SutharIkiform 創業者

登録のロジックはサーバーのモジュールに 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 も持たせます。

1 つの TanStack Start のフォームが、JavaScript の有無にかかわらず同じ登録のコードに届く仕組みNewsletterFormaction="/api/newsletter"ハイドレーション済み?onSubmitを設定済みサーバー関数RPC、同じページforwardSignup()同意、次にIkiform結果その場で描画/api/newsletterサーバールート、フォームのPOST/newsletterへ 303?statusか ?error送信はいデータ結果まだ1 つの TanStack Start のフォームが、JavaScript の有無にかかわらず同じ登録のコードに届く仕組みNewsletterFormaction="/api/newsletter"ハイドレーション済み?onSubmit を設定済みサーバー関数RPC、同じページforwardSignup()同意、次に Ikiform結果その場で描画/api/newsletterサーバールート、フォームのPOST/newsletterへ 303?status か?error送信はいデータ結果まだ
  1. 読者がフォームを送信します。JavaScript が読み込まれているかどうかで経路が決まります。
  2. ハイドレーション済み:onSubmit が送信を止め、フォームのデータでサーバー関数を呼びます。
  3. サーバー関数が forwardSignup を実行し、同意を確認して Ikiform に送信し、結果をコンポーネントに返します。
  4. ハイドレーション前:ブラウザーが /api/newsletter のサーバールートに送信し、同じ forwardSignup を実行します。
  5. ルートはステータスかエラーコード付きで 303 を返し、/newsletter ページが対応するメッセージを表示します。
Ikiform とやり取りするのは forwardSignup だけなので、どちらの経路でも検証とエラーの報告が同じになります。

プロジェクトの準備

プロジェクトの構成
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 タブに貼り付けます。

.env
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

作ってみる

登録のモジュール

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

サーバー関数とフォーム

src/components/NewsletterForm.tsx
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 なしのときのルートとページ

src/routes/api/newsletter.ts
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);
      },
    },
  },
});
src/routes/newsletter.tsx
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 のガイドで追加しています。

src/routes/api/ikiform.ts
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 つのサーバールートは、ホスト上でオンデマンドで動きます。

デプロイ

ホスト対処法
VercelNitro を追加します。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 が得られるトンネル経由でテストしてください。

動作を確認する

  1. 開発サーバーを起動し、フッターから購読します。フォームが「受信トレイを確認してください」の一文に置き換わり、URL は変わりません。
  2. JavaScript を無効にしてもう一度購読します。/newsletter?status=subscribed に移動します。
  3. JavaScript をオフにしたまま、required を外してボックスのチェックを外して送信します。同意のメッセージ付きで /newsletter?error=consent に移動します。
  4. ダッシュボードで Submissions を開きます。送ったすべてのフィールドを含む新しい送信データがあります。
  5. Webhook を追加した場合は、Webhooks でエンドポイントを開きます。配信が succeeded になり、サーバーの応答が表示されます。
  6. beehiiv を開きます。購読が確認されるまで pending の状態で存在し、確認メールが受信トレイに届いています。

ハマりどころ

問題対処法
本番環境でシークレットキーが undefined になるホストに変数が渡っていないか、ブラウザーで動くコードで読まれています。シークレットはサーバー関数とサーバールートの中でだけ読んでください。
JavaScript なしで送信すると JSON のエラーページが表示されるフォームの action がサーバールートではなくサーバー関数を指しています。/api/newsletter に送信してください。
ローカルのテストで 402 plan_required が出る送信が localhost のオリジンなしで Ikiform に届いています。forwardSignup が両方の呼び出し元からオリジンを受け取っていることを確認してください。
Webhook の配信が 400 で失敗するシークレットがエンドポイントのものと一致していないか、request.text() より前に何かがボディを解析しています。先に生のテキストを読んでください。
送信データはダッシュボードにあるのに、メールも Webhook も届かないスパムとして保存されています。スパムでは何も送られません。Spam フィルターで開いて理由を確認し、スパムではないとマークすると、メールと Webhook が送られます。

よくある質問

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