ウェブサイトやアプリのフィードバックフォーム3 分で読めます
TanStack Start でアプリ内フィードバックフォームを作る
TanStack Start のアプリ内フィードバックフォーム。セッションのミドルウェア付きの POST サーバー関数、文脈としてのルート ID、リクエストヘッダーから読むページを組み合わせます。
フィードバック用の 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 | まとめるためのラベル。保存前にパターンで確認 |
- フォームがスコアとコメントを集め、現在のルート ID を加えて、サーバー関数を呼び出します。
- サーバーでは、ミドルウェアがセッションを読み、ユーザーを context として渡します。
- バリデーターが FormData を型付きのオブジェクトにし、形の合わない画面名を捨てます。
- ハンドラーはユーザーがいなければ拒否し、ユーザーと Referer ヘッダーのページを加えます。
- シークレットキーを付けて Ikiform に送信し、sent か、フォームが表示するエラーを返します。
プロジェクトの準備
このツリーは、ログイン後のすべての画面を包むパスなしの _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
.envStart は接頭辞のない変数を、サーバー上でだけ process.env から読みます。VITE_ の接頭辞が付いたものはブラウザー向けにバンドルされるので、キーには絶対に付けないでください。キーはモジュールの先頭ではなく、ハンドラーの中で読みます。
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 を渡すので、ハンドラーはフォームが表示するメッセージで応答できます。
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 } });
});サーバー関数
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 とメールアドレスだけです。ハンドラーが返すものはすべてシリアライズされてブラウザーに送られます。
フォーム
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は呼び出しのあいだボタンを無効にし、二重送信を防ぎます。
レイアウトルートにマウントする
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 が必要なものとして扱ってください。
デプロイ
| ホスト | 設定 |
|---|---|
| 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 がブラウザーのオリジンを引き継ぎ、各回答はテストとして数えられます。本番では、同じ行がアプリのドメインを送ります。
動作を確認する
vite devを実行してログインし、プロジェクトの設定を開いて、コメント付きで 2 を送ります。- Submissions では、回答に
user_id、email、ルート ID が入ったscreen、実際のプロジェクト ID を含むpageが入っています。 - 別のタブでログアウトして、もう一度送ります。フォームにログアウト時のメッセージが表示され、コメントは残ります。
- ブラウザーのコンソールから、フォームのデータに偽のユーザー ID を入れて関数を呼び出します。保存される
user_idはセッションのもののままです。 - 通知メールに返信します。返信はログイン中のユーザーに届きます。
ハマりどころ
| 問題 | 対処法 |
|---|---|
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 に合わせてください。 |
よくある質問
セッションを読み、ユーザーを context に入れて next を呼ぶ関数ミドルウェアを書き、サーバー関数の middleware に列挙します。ハンドラーはサーバー上で、型付きの context.user を読めます。
はい。@tanstack/react-start/server の getRequestHeader と getRequest でハンドラー内から受信したリクエストを読めるので、ブラウザーから送らなくても Referer からページを取れます。
はい。サーバー関数を実行するサーバーがあり、ホストが /_serverFn/ のパスを SPA のシェルに書き換えない限り動きます。
いいえ、無料プランはありません。テストは無料で、支払う前に localhost から月 50 件のテスト送信ができます。本番運用するには、月 $6 で 500 件からの Pro か、1 回払いで 10,000 件 $49 または 50,000 件 $149 のパックを選べます。クレジットに有効期限はありません。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com