ウェブサイトやアプリのフィードバックフォーム

ウェブサイトやアプリのフィードバックフォーム3 分で読めます

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

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

Preet SutharIkiform 創業者

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

メインガイドの TanStack Start タブでは、誰からでもページのフィードバックを送信し、ページはブラウザーで設定します。ログインが必要なアプリの中なら、もっとうまくできます。誰が送っているかはサーバーが、どの画面が開いているかはルーターが知っています。アカウントの準備、キー、許可ドメインについてはサーバーのドキュメントガイドにまとめてあります。

どの値を誰が用意するか

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

値流入元信頼
rating, commentフォームの FormDataユーザー自身の言葉
user_id, emailセッションのミドルウェアが設定する context.userサーバーで封印された Cookie から読む
pageハンドラー内の getRequestHeader("referer")ブラウザーは開いているページから関数を呼ぶので、ヘッダーにはそのページの URL が入る
screen最も深い一致の routeId。例: /_app/projects/$projectId/settingsまとめるためのラベル。保存前にパターンで確認
TanStack Start のサーバー関数がフィードバックの回答にユーザーとページを加える流れFeedbackFormFormData+routeIduserMiddlewareセッションCookieを読むバリデータースコア、コメント、画面名の形ハンドラーユーザーとRefererのページを追加Ikiformシークレットキー、保存エラーの結果フォームはそのまま、コメントも保持/_serverFncontext.userデータJSONユーザーなしTanStack Start のサーバー関数がフィードバックの回答にユーザーとページを加える流れFeedbackFormFormData + routeIduserMiddlewareセッション Cookie を読むバリデータースコア、コメント、画面名の形ハンドラーユーザーと Refererのページを追加Ikiformシークレットキー、保存エラーの結果フォームはそのまま、コメントも保持/_serverFncontext.userデータJSONユーザーなし
  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 は、自分で用意するユーザーの取得処理です。

プロジェクトの構成
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
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
SESSION_SECRET=at-least-32-characters-from-your-auth-setup

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

プレビュー

Ikiform のバリデーターをブラウザーで実行します。何も送信されません。

この画面の使い心地はどうですか?

リクエストからサーバーが設定します。

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

作ってみる

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

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

src/lib/user-middleware.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
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
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
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 のレート制限を選び、IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。

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

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

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

デプロイ

ホスト設定
Vercelnitro/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 に合わせてください。

よくある質問

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