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

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

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

登録のロジックはサーバーのモジュールに 1 回だけ書き、2 か所から呼びます。ページ内でフォームが呼ぶ `createServerFn` の POST 関数と、JavaScript が読み込まれていないときに同じフォームが送信するサーバールートです。どちらも同意のチェックボックスを確認し、シークレットキー付きで登録をフォームバックエンドに転送します。2 つ目のサーバールートがバックエンドの署名付き Webhook を受け取り、beehiiv で購読を作成します。

[メインガイドの TanStack Start タブ](https://ikiform.com/ja/guides/newsletter-signup.md#form)は、サーバー関数 1 つとコンポーネント 1 つです。それで JavaScript が読み込まれた読者には対応できます。このページでは、広く共有するサイトに必要なものを追加します。ハイドレーション前に送信した読者のための経路、その経路の結果ページ、本物の登録を beehiiv に渡す Webhook のルートです。製品の設定については[サーバー側のドキュメントガイド](https://ikiform.com/ja/docs/guides/server.md)にあります。

## TanStack Start で変わること

サーバー関数はクライアントが行う RPC リクエストで呼ばれるので、`onSubmit` しか知らないフォームはバンドルが動くまで何もしません。TanStack のドキュメントには JavaScript なしでフォームをサーバー関数に送信するための `.url` プロパティが載っていますが、そのレスポンスがどう描画されるかは記載されていません。サーバールートのほうが素直です。通常のフォームの POST を受け取り、通常のリダイレクトを返します。そこでフォームには、`onSubmit` に加えて本物の `action` も持たせます。

図: 1 つの TanStack Start のフォームが、JavaScript の有無にかかわらず同じ登録のコードに届く仕組み

1. 読者がフォームを送信します。JavaScript が読み込まれているかどうかで経路が決まります。
2. ハイドレーション済み：onSubmit が送信を止め、フォームのデータでサーバー関数を呼びます。
3. サーバー関数が forwardSignup を実行し、同意を確認して Ikiform に送信し、結果をコンポーネントに返します。
4. ハイドレーション前：ブラウザーが /api/newsletter のサーバールートに送信し、同じ forwardSignup を実行します。
5. ルートはステータスかエラーコード付きで 303 を返し、/newsletter ページが対応するメッセージを表示します。

Ikiform とやり取りするのは forwardSignup だけなので、どちらの経路でも検証とエラーの報告が同じになります。

## プロジェクトの準備

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

```text
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 のキーを作り、[ピラーのフィールド定義](https://ikiform.com/ja/guides/newsletter-signup.md#fields)をフォームの **Fields** タブに貼り付けます。

`.env`:

```bash
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`:

```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 の[レート制限](https://ikiform.com/ja/docs/rate-limits.md)を選び、`IKIFORM_SECRET_KEY` はサーバー専用の環境変数に置いてください。

### サーバー関数とフォーム

`src/components/NewsletterForm.tsx`:

```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`:

```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`:

```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 のガイド](https://ikiform.com/ja/guides/newsletter-signup/beehiiv.md)で追加しています。

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

```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 のガイド](https://ikiform.com/ja/guides/spam-protection/cloudflare-turnstile.md)のように `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 が得られるトンネル経由でテストしてください。

## 動作を確認する

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 が送られます。 |

## よくある質問

### TanStack Start のサーバー関数は JavaScript なしで動きますか？

TanStack のドキュメントにはフォームをサーバー関数に送信するための .url プロパティが載っていますが、予備としてはサーバールートのほうが簡単です。素のフォームの POST を受け取ってリダイレクトします。このページでは、共通のモジュール 1 つを背後に置いて両方を使っています。

### TanStack Start ではシークレットをどこに置きますか？

サーバー専用の環境変数に置き、サーバー関数とサーバールートの中で process.env から読みます。VITE_ の接頭辞が付いた変数はブラウザーのバンドルに入ってしまいます。

### サーバー関数の中で購読者を beehiiv に追加しないのはなぜですか？

Ikiform はスパムにも本物の登録と同じ成功を返します。Webhook はスパムではない登録にだけ送られるので、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 など任意のツールに渡します。
- [beehiiv](https://ikiform.com/ja/guides/newsletter-signup/beehiiv.md): 自作の登録フォームを beehiiv につなぎます。リレーがダブルオプトインをオンにし、設置場所を UTM ソースに、ページをリファラーにして各購読を作成します。
- [ダブルオプトイン](https://ikiform.com/ja/guides/newsletter-signup/double-opt-in.md): Ikiform は確認メールを送らないので、ダブルオプトインはメールツールで行います。中継が各登録を未確認として追加し、ツールがリンクを送ります。
- [React Router のニュースレターフォーム](https://ikiform.com/ja/guides/newsletter-signup/react-router.md): React Router のフッターのニュースレターフォームです。useFetcher が JavaScript なしでも動く /newsletter ルートに送信し、リソースルートが MailerLite に登録を渡します。
- [静的サイト](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 につなぎます。
