# TanStack Start でアプリ内フィードバックフォームを作る

> TanStack Start のアプリ内フィードバックフォーム。セッションのミドルウェア付きの POST サーバー関数、文脈としてのルート ID、リクエストヘッダーから読むページを組み合わせます。

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

フィードバック用の POST サーバー関数を書き、セッションを読んでユーザーを `context` に入れる関数ミドルウェアを付けます。関数は、そのユーザー、リクエストの `Referer` から取ったページ、一致したルート ID を評価に加え、シークレットキーを付けてフォームバックエンドに転送します。ログイン後のレイアウトルートにフォームを 1 回マウントすれば、どの画面にいるかを自分で把握します。

[メインガイドの TanStack Start タブ](https://ikiform.com/ja/guides/feedback-form.md#form)では、誰からでもページのフィードバックを送信し、ページはブラウザーで設定します。ログインが必要なアプリの中なら、もっとうまくできます。誰が送っているかはサーバーが、どの画面が開いているかはルーターが知っています。アカウントの準備、キー、許可ドメインについては[サーバーのドキュメントガイド](https://ikiform.com/ja/docs/guides/server.md)にまとめてあります。

## どの値を誰が用意するか

Start のサーバー関数は `/_serverFn/` 以下の HTTP エンドポイントで、コンポーネントは生成されたクライアントを通じてそれを呼び出します。入力に含まれるものは、呼び出す側が誰でも書き換えられます。そのため入力にはユーザーが選んだものだけを入れ、本人に関する情報はすべて、ハンドラーより前にサーバーで動くミドルウェアから取ります。

| 値 | 流入元 | 信頼 |
| --- | --- | --- |
| `rating`, `comment` | フォームの `FormData` | ユーザー自身の言葉 |
| `user_id`, `email` | セッションのミドルウェアが設定する `context.user` | サーバーで封印された Cookie から読む |
| `page` | ハンドラー内の `getRequestHeader("referer")` | ブラウザーは開いているページから関数を呼ぶので、ヘッダーにはそのページの URL が入る |
| `screen` | 最も深い一致の `routeId`。例: `/_app/projects/$projectId/settings` | まとめるためのラベル。保存前にパターンで確認 |

図: TanStack Start のサーバー関数がフィードバックの回答にユーザーとページを加える流れ

1. フォームがスコアとコメントを集め、現在のルート ID を加えて、サーバー関数を呼び出します。
2. サーバーでは、ミドルウェアがセッションを読み、ユーザーを context として渡します。
3. バリデーターが FormData を型付きのオブジェクトにし、形の合わない画面名を捨てます。
4. ハンドラーはユーザーがいなければ拒否し、ユーザーと Referer ヘッダーのページを加えます。
5. シークレットキーを付けて Ikiform に送信し、sent か、フォームが表示するエラーを返します。

ユーザーはサーバー上の Cookie から来ます。フォームのデータをどう変えても、回答の送り主は変えられません。

## プロジェクトの準備

このツリーは、ログイン後のすべての画面を包むパスなしの `_app` レイアウトを使ったファイルベースのルーティングを前提にしています。`session.ts` は Start の認証ガイドにある iron-session のヘルパーです。Cookie を読むセッションライブラリなら何でも同じように動きます。`users.ts` の `findUser` と `server/user.ts` の `getCurrentUserFn` は、自分で用意するユーザーの取得処理です。

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

```text
src/
  components/feedback-form.tsx
  lib/
    session.ts
    user-middleware.ts
    users.ts
  routes/
    _app.tsx
    _app/projects/$projectId/settings.tsx
  server/
    feedback.ts
    user.ts
.env
```

Start は接頭辞のない変数を、サーバー上でだけ `process.env` から読みます。`VITE_` の接頭辞が付いたものはブラウザー向けにバンドルされるので、キーには絶対に付けないでください。キーはモジュールの先頭ではなく、ハンドラーの中で読みます。

`.env`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
SESSION_SECRET=at-least-32-characters-from-your-auth-setup
```

フィールド定義をフォームの **Fields** タブに貼り付けます。フォームに表示されるのはそのうち 2 つで、残りの 4 つはサーバー関数が埋めます。`email` フィールドがあるので、各通知の **Reply** はユーザーに届きます。

フォーム: In-app feedback

- **この画面の使い心地はどうですか？** (`rating`, rating, 必須)
- **どうすればもっと良くなりますか？** (`comment`, textarea)
- **ページ** (`page`, url)
- **画面** (`screen`, text)
- **ユーザー** (`user_id`, text)
- **メールアドレス** (`email`, email)

`rating: 6` や定義にないフィールドを送ると、サーバー関数が受け取るのと同じ `422` がプレビューに表示されます。

## 作ってみる

### セッションのミドルウェア

関数ミドルウェアは、それを列挙したサーバー関数を包みます。`next({ context })` に渡したものは、型付きでハンドラーに届きます。例外を投げずに `null` を渡すので、ハンドラーはフォームが表示するメッセージで応答できます。

`src/lib/user-middleware.ts`:

```ts
import { createMiddleware } from "@tanstack/react-start";
import { getAppSession } from "./session";
import { findUser } from "./users";

export const userMiddleware = createMiddleware({ type: "function" }).server(async ({ next }) => {
  const session = await getAppSession();
  const user = session.userId ? await findUser(session.userId) : null;
  return next({ context: { user: user ? { id: user.id, email: user.email } : null } });
});
```

### サーバー関数

`src/server/feedback.ts`:

```ts
import { createServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { userMiddleware } from "../lib/user-middleware";

export type FeedbackResult = { status: "sent" } | { status: "error"; message: string };

const ROUTE_ID = /^\/[A-Za-z0-9/$_.-]{0,119}$/;

function pageFrom(referer: string | undefined) {
  try {
    const url = new URL(referer ?? "");
    return url.origin + url.pathname;
  } catch {
    return "";
  }
}

export const sendFeedback = createServerFn({ method: "POST" })
  .middleware([userMiddleware])
  .validator((data: unknown) => {
    if (!(data instanceof FormData)) throw new Error("Expected form data");
    const rating = Number(data.get("rating"));
    const screen = String(data.get("screen") ?? "");
    return {
      rating: Number.isInteger(rating) && rating >= 1 && rating <= 5 ? rating : null,
      comment: String(data.get("comment") ?? "").trim().slice(0, 4000),
      screen: ROUTE_ID.test(screen) ? screen : "unknown",
      trap: String(data.get("_gotcha") ?? ""),
    };
  })
  .handler(async ({ data, context }): Promise<FeedbackResult> => {
    if (!context.user) return { status: "error", message: "You're signed out. Sign in and send it again." };
    if (data.rating === null) return { status: "error", message: "Choose a score first." };

    const origin = getRequestHeader("origin");
    let response: Response;
    try {
      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({
          rating: data.rating,
          comment: data.comment,
          page: pageFrom(getRequestHeader("referer")),
          screen: data.screen,
          user_id: context.user.id,
          email: context.user.email,
          _gotcha: data.trap,
        }),
        signal: AbortSignal.timeout(8000),
      });
    } catch {
      return { status: "error", message: "Feedback is unreachable right now. Try again shortly." };
    }

    const result = await response.json().catch(() => null);
    if (result?.ok) return { status: "sent" };
    return { status: "error", message: result?.error?.message ?? "That didn't go through. Try again." };
  });
```

- `.validator` は生の `FormData` を受け取り、普通のオブジェクトを返します。不正なスコアは例外ではなく `null` になります。例外を投げると、クライアントには汎用的な失敗として届いてしまいます。
- `getRequestHeader` は、リクエストオブジェクトを受け渡さなくても、ハンドラー内のどこからでも受信したリクエストを読めます。
- `Origin` はブラウザーからの呼び出しから転送します。Ikiform が送信をローカルのテストとして扱うかどうかはこれで決まります。詳しくは下で説明します。
- context に入れるのはユーザーのレコード全体ではなく、ID とメールアドレスだけです。ハンドラーが返すものはすべてシリアライズされてブラウザーに送られます。

### フォーム

`src/components/feedback-form.tsx`:

```tsx
import { useMatches } from "@tanstack/react-router";
import { useServerFn } from "@tanstack/react-start";
import { type FormEvent, useState } from "react";
import { type FeedbackResult, sendFeedback } from "../server/feedback";

const SCORES = ["Badly", "Poorly", "Okay", "Well", "Very well"];

export function FeedbackForm() {
  const send = useServerFn(sendFeedback);
  const screen = useMatches({ select: (matches) => matches.at(-1)?.routeId ?? "unknown" });
  const [result, setResult] = useState<FeedbackResult | null>(null);
  const [pending, setPending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    data.set("screen", screen);
    setPending(true);
    try {
      setResult(await send({ data }));
    } catch {
      setResult({ status: "error", message: "We lost the connection. Try again." });
    } finally {
      setPending(false);
    }
  }

  if (result?.status === "sent") return <p role="status">Thanks. We read every one.</p>;

  return (
    <form method="post" onSubmit={onSubmit} aria-busy={pending}>
      <fieldset>
        <legend>How is this screen working for you?</legend>
        {SCORES.map((label, index) => (
          <label key={label}>
            <input type="radio" name="rating" value={index + 1} required /> {label}
          </label>
        ))}
      </fieldset>
      <label htmlFor="feedback-comment">What would make it better?</label>
      <textarea id="feedback-comment" name="comment" rows={4} maxLength={4000} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {result?.status === "error" ? <p role="alert">{result.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send feedback"}</button>
    </form>
  );
}
```

- `select` を付けた `useMatches` は最後の一致の `routeId` だけを返すので、コンポーネントが再描画されるのはルーターが更新されるたびではなく、画面が変わったときだけです。
- `onSubmit` はネイティブの送信を止めて関数を呼び出します。フォームをリセットするものはないので、エラーのあともコメントは入力欄に残ります。
- `pending` は呼び出しのあいだボタンを無効にし、二重送信を防ぎます。

### レイアウトルートにマウントする

`src/routes/_app.tsx`:

```tsx
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
import { FeedbackForm } from "../components/feedback-form";
import { getCurrentUserFn } from "../server/user";

export const Route = createFileRoute("/_app")({
  beforeLoad: async () => {
    const user = await getCurrentUserFn();
    if (!user) throw redirect({ to: "/login" });
    return { user };
  },
  component: () => (
    <>
      <Outlet />
      <aside>
        <FeedbackForm />
      </aside>
    </>
  ),
});
```

`beforeLoad` はログアウトしたユーザーを画面から遠ざけますが、フィードバックを守っているのはそれではありません。`/_serverFn/` は誰でも直接呼び出せるので、ミドルウェアでもう一度確認しています。ルート ID にはパスなしのレイアウトも含まれるため、`screen` には `/_app` の接頭辞が付きます。短い名前がよければ、保存前に取り除いてください。

## ハニーポット、人間確認、制限

`_gotcha` の入力欄はそのままバリデーターを通り、埋まっていれば Ikiform がスパムとして振り分けます。シークレットキー付きの送信では人間確認がスキップされますが、セッションの背後なら問題ありません。ボットはセッションを持っていないからです。残るのはログイン済みのアカウントが繰り返し送信するケースで、Ikiform はそれらをすべてサーバーのアドレスに対して数えます。

サーバー側の版はシークレットキー付きで送信するので、Ikiform は **Allowed domains** と人間確認をスキップします。その場合はすべての送信がサーバーの IP から来るため、訪問者ごとに 1 分あたり 5 件という Standard の制限が、全訪問者をまとめて数えることになります。サーバー経由で送信するフォームには Relaxed か Custom の[レート制限](https://ikiform.com/ja/docs/rate-limits.md)を選び、`IKIFORM_SECRET_KEY` はサーバー専用の環境変数に置いてください。

## SSR、SPA モード、プリレンダー

フォームは完全な SSR でも、`tanstackStart()` のオプションで `spa: { enabled: true }` にしても同じように動きます。SPA モードではルートのシェルだけがプリレンダーされ、画面はブラウザーで描画されますが、サーバー関数には動いているサーバーがやはり必要です。ホストがすべてのパスをシェルに書き換える場合は、`/_serverFn/*` を通過させるパスに追加してください。ログイン後の画面はプリレンダーしないでください。ビルド時にはユーザーがいません。

Start のドキュメントには、素の HTML フォームからサーバー関数を呼ぶための `.url` プロパティも載っています。このページでは使いません。ログイン後の画面は誰かがコメントを入力する前にハイドレーションされますし、スクリプトなしの送信に対応するには、関数がデータではなくリダイレクトで応答する必要があります。このフォームは JavaScript が必要なものとして扱ってください。

## デプロイ

| ホスト | 設定 |
| --- | --- |
| Vercel | `nitro/vite` の `nitro()` を Vite のプラグインに追加してデプロイし、プロジェクトの環境変数に `IKIFORM_SECRET_KEY` を追加します。 |
| Netlify | `@netlify/vite-plugin-tanstack-start` の `netlify()` を追加し、サイトの環境変数にキーを設定します。 |
| Cloudflare | `@cloudflare/vite-plugin` の `cloudflare({ viteEnvironment: { name: "ssr" } })` を追加し、`nodejs_compat` を有効にして、キーを `wrangler secret put` で保存します。互換性日付が 2025-04-01 以降なら、このフラグでシークレットが `process.env` に入ります。 |

## localhost からの無料テスト

プランがなくても、`Origin` ヘッダーが `localhost` を示していれば、ワークスペースはテスト送信を月 50 件まで受け付けます。サーバー自身の `fetch` にはオリジンがないので、ヘッダーを転送しない送信には `402 plan_required` が返り、保留されます。転送すれば、`http://localhost:3000` の `vite dev` がブラウザーのオリジンを引き継ぎ、各回答はテストとして数えられます。本番では、同じ行がアプリのドメインを送ります。

## 動作を確認する

1. `vite dev` を実行してログインし、プロジェクトの設定を開いて、コメント付きで 2 を送ります。
2. **Submissions** では、回答に `user_id`、`email`、ルート ID が入った `screen`、実際のプロジェクト ID を含む `page` が入っています。
3. 別のタブでログアウトして、もう一度送ります。フォームにログアウト時のメッセージが表示され、コメントは残ります。
4. ブラウザーのコンソールから、フォームのデータに偽のユーザー ID を入れて関数を呼び出します。保存される `user_id` はセッションのもののままです。
5. 通知メールに返信します。返信はログイン中のユーザーに届きます。

## ハマりどころ

| 問題 | 対処法 |
| --- | --- |
| `src/start.ts` を追加したら、サーバー関数の CSRF チェックがなくなった | `src/start.ts` がなければ、Start はサーバー関数用の CSRF ミドルウェアを自分で追加します。自分で定義したら、そこに `createCsrfMiddleware` をサーバー関数に絞って自分で登録してください。 |
| ローカルのテストで `402 plan_required` が出る | `Origin` ヘッダーが Ikiform に届いていません。転送する行を確認し、LAN のアドレスではなく `localhost` でアプリを開いてください。 |
| Cloudflare で `IKIFORM_SECRET_KEY` が undefined になる | Workers が `process.env` を埋めるのは、`nodejs_compat` がオンで、互換性日付が 2025-04-01 以降のときだけです。`wrangler.jsonc` で両方を確認するか、`cloudflare:workers` の `env` からキーを読んでください。 |
| `screen` が常に `unknown` になる | ルート ID がパターンに合いませんでした。オプションのパラメーターに由来する `{` などの文字が原因のことがよくあります。自分のルートに合うようパターンを広げてください。 |
| `422 validation_failed` | 送信がフォームのフィールド定義に合っていません。`error.fields` に各フィールドと理由が示されます。入力欄の `name` をフィールド ID に合わせてください。 |

## よくある質問

### TanStack Start のサーバー関数の中で現在のユーザーを取得するには？

セッションを読み、ユーザーを context に入れて next を呼ぶ関数ミドルウェアを書き、サーバー関数の middleware に列挙します。ハンドラーはサーバー上で、型付きの context.user を読めます。

### TanStack Start のサーバー関数でリクエストヘッダーを読めますか？

はい。@tanstack/react-start/server の getRequestHeader と getRequest でハンドラー内から受信したリクエストを読めるので、ブラウザーから送らなくても Referer からページを取れます。

### フィードバックフォームは TanStack Start の SPA モードでも動きますか？

はい。サーバー関数を実行するサーバーがあり、ホストが /_serverFn/ のパスを SPA のシェルに書き換えない限り動きます。

### Ikiform は無料ですか？

いいえ、無料プランはありません。テストは無料で、支払う前に localhost から月 50 件のテスト送信ができます。本番運用するには、月 $6 で 500 件からの Pro か、1 回払いで 10,000 件 $49 または 50,000 件 $149 のパックを選べます。クレジットに有効期限はありません。

## その他のガイド

- [ウェブサイトやアプリのフィードバックフォーム](https://ikiform.com/ja/guides/feedback-form.md): どのページにもアプリ内にもフィードバックフォームを設置。評価、コメント、送信元ページ、スパム対策、低評価時の通知まで。
- [React Router のフィードバックフォーム](https://ikiform.com/ja/guides/feedback-form/react-router.md): React Router 8 のフィードバックフォーム。リソースルートの action が Cookie セッションを読み、レイアウトの fetcher が送信し、JS なしの送信は元のページにリダイレクトします。
- [Next.js のフィードバックフォーム](https://ikiform.com/ja/guides/feedback-form/nextjs.md): Next.js のアプリ内フィードバックフォーム。サーバーアクションがセッションを読み、リクエストからページを取り、ユーザー情報を付けて評価を転送します。
- [React フィードバックウィジェット](https://ikiform.com/ja/guides/feedback-form/react-feedback-widget.md): React アプリ用のフローティングのフィードバックボタン。小さなパネルを開き、回答ごとに画面、アプリのバージョン、アカウントを送り、下書きを失いません。
- [バグ報告フォーム](https://ikiform.com/ja/guides/feedback-form/bug-report.md): 手順と深刻度は本人に尋ね、URL、ブラウザー、ビューポート、直近のエラーはページが自動で追加するバグ報告フォームです。
- [機能要望フォーム](https://ikiform.com/ja/guides/feedback-form/feature-request.md): 解決策ではなく課題を尋ね、要望をテーマ別に保存し、件数ではなく人数で数え、リリース時にメールで知らせる機能要望フォームです。
