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

> React Router 8 のフィードバックフォーム。リソースルートの action が Cookie セッションを読み、レイアウトの fetcher が送信し、JS なしの送信は元のページにリダイレクトします。

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

ログイン後のルートを包むレイアウトにフィードバックフォームを 1 つマウントし、`useFetcher` でリソースルートに送信します。その action は Cookie セッションからユーザーを読み、`Referer` からページを取り、シークレットキーを付けて評価をフォームバックエンドに転送します。JavaScript がまだ読み込まれていないときは、同じ action が生の JSON を返す代わりに元のページへリダイレクトします。

[メインガイドの React Router タブ](https://ikiform.com/ja/guides/feedback-form.md#form)では、公開ページのフォームを fetcher で送信します。ここではフォームはアプリの中、ログインの向こう側にあり、誰が送ったかは action が決めます。キー、許可ドメイン、基本については[サーバーフレームワークのドキュメントガイド](https://ikiform.com/ja/docs/guides/server.md)を見てください。コードはフレームワークモードの React Router 8 を対象にしています。

## レイアウトの fetcher で変わること

レイアウトにあるフィードバックフォームはすべての画面に同時に存在し、React Router はユーザーが移動してもマウントしたままにします。それが望ましい動作ですが、メインガイドでは答える必要のなかった 4 つの疑問が生まれます。

| 懸念 | レイアウトでは | このページでの対処 |
| --- | --- | --- |
| 誰が送ったか | ブラウザーはどのユーザーにでもなりすませる | action が署名付きの Cookie セッションを読む |
| どの画面か | フォームはどの子ルートが表示されているかを知らない | 各ルートが `handle` で自分の名前を示し、`useMatches` で読む |
| 残った状態 | fetcher の `data` はナビゲーション後も残る | `key` オプションで画面ごとに 1 つの fetcher |
| JavaScript がまだない | `fetcher.Form` は本物のフォームなので、ブラウザーがリソースルートに送信する | action がドキュメントの送信を見分けて元に戻す |

図: React Router のフィードバック送信が同じリソースルートに届く 2 つの経路

1. ユーザーは、ログイン後のすべてのルートでレイアウトが描画するフォームで画面を評価します。
2. ハイドレーション後は、fetcher がバックグラウンドで送信します。action は Cookie セッション、Referer のページ、画面名を読みます。
3. action はシークレットキーとユーザーを付けて Ikiform に送信します。
4. 結果は data() で戻り、ナビゲーションなしでその場に表示されます。
5. ハイドレーション前は、ブラウザーがフォームをドキュメントとして送信します。action はナビゲーションを検出し、結果をクエリに入れて送信元のページにリダイレクトします。

1 つの action に 2 種類の呼び出し元。リダイレクトを受け取るのはブラウザーのナビゲーションだけで、fetcher の呼び出しはデータを受け取ります。

## プロジェクトの準備

`create-react-router` で作ったプロジェクトで関わるファイル:

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

```text
app/
  routes.ts
  sessions.server.ts
  components/
    feedback-form.tsx
  routes/
    api.feedback.ts
    app-layout.tsx
    project-settings.tsx
```

action 用のリソースルートと、ログイン後の画面用のパスなしレイアウトを追加します。action があってデフォルトエクスポートのないモジュールはリソースルートなので、何も描画しません。

`app/routes.ts`:

```ts
import { layout, route, type RouteConfig } from "@react-router/dev/routes";

export default [
  route("api/feedback", "routes/api.feedback.ts"),
  layout("routes/app-layout.tsx", [route("projects/:id/settings", "routes/project-settings.tsx")]),
] satisfies RouteConfig;
```

**API keys** で `submissions:write` スコープのシークレットキーを作成します。サーバーは両方の値を `process.env` から読みます。サーバーが動く場所で設定し、キーには絶対に `VITE_` の接頭辞を付けないでください。付けるとクライアントのバンドルに入ってしまいます。

`.env`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
SESSION_SECRET=a-long-random-string
```

下の定義をフォームの **Fields** タブに貼り付けます。本人が入力するのは 2 つのフィールドで、残りは action が用意します。セッションの `email` によって、各通知の **Reply** が本人に届きます。

フォーム: In-app feedback

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

`rating` に `9` を送るか、定義にないフィールドを送ると、action が受け取る `422` を確認できます。

## 作ってみる

### Cookie セッションからユーザーを読む

`createCookieSessionStorage` は `secrets` で Cookie に署名するので、ユーザーは中身を読めても変更はできません。ログイン処理がすでにセッションを書き込んでいるなら、そのストレージを再利用して `readUser` を追加してください。

`app/sessions.server.ts`:

```ts
import { createCookieSessionStorage } from "react-router";

interface SessionData {
  userId: string;
  email: string;
}

export const { getSession, commitSession, destroySession } = createCookieSessionStorage<SessionData>({
  cookie: {
    name: "__session",
    httpOnly: true,
    path: "/",
    sameSite: "lax",
    secrets: [process.env.SESSION_SECRET ?? ""],
    secure: process.env.NODE_ENV === "production",
  },
});

export async function readUser(request: Request) {
  const session = await getSession(request.headers.get("Cookie"));
  const id = session.get("userId");
  const email = session.get("email");
  return id && email ? { id, email } : null;
}
```

`.server` の接尾辞で、このモジュールをクライアントのバンドルから除外します。コンポーネントからインポートするとビルドが失敗します。

### リソースルート

`app/routes/api.feedback.ts`:

```ts
import { data, redirect } from "react-router";
import { readUser } from "~/sessions.server";
import type { Route } from "./+types/api.feedback";

const SCREEN = /^[a-z0-9/._-]{1,120}$/;

function samePage(referer: string | null, origin: string) {
  try {
    const url = new URL(referer ?? "");
    return url.origin === origin ? url.pathname : "";
  } catch {
    return "";
  }
}

export async function action({ request }: Route.ActionArgs) {
  const form = await request.formData();
  const origin = new URL(request.url).origin;
  const path = samePage(request.headers.get("Referer"), origin);
  const native = request.headers.get("Sec-Fetch-Mode") === "navigate";

  function reply(status: "sent" | "error", message: string, code = 200) {
    if (native) return redirect(`${path || "/"}?feedback=${status}`);
    return data({ status, message }, { status: code });
  }

  const user = await readUser(request);
  if (!user) return reply("error", "You were signed out. Sign in, then send it again.", 401);

  const rating = Number(form.get("rating"));
  if (!Number.isInteger(rating) || rating < 1 || rating > 5) return reply("error", "Choose a score first.", 400);
  const screen = String(form.get("screen") ?? "");

  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,
      },
      body: JSON.stringify({
        rating,
        comment: String(form.get("comment") ?? "").slice(0, 4000),
        page: path ? origin + path : "",
        screen: SCREEN.test(screen) ? screen : "unknown",
        user_id: user.id,
        email: user.email,
        _gotcha: String(form.get("_gotcha") ?? ""),
      }),
      signal: AbortSignal.timeout(8000),
    });
  } catch {
    return reply("error", "Feedback is unreachable right now. Try again shortly.", 502);
  }

  const result = await response.json().catch(() => null);
  if (result?.ok) return reply("sent", "Got it. Thank you.");
  return reply("error", result?.error?.fields?.comment?.[0] ?? result?.error?.message ?? "That didn't go through.", 502);
}
```

- `samePage` は、`Referer` が自分のオリジンのときだけパスを残し、クエリ文字列は捨てます。アプリの URL では、クエリに ID やトークンが入っているからです。
- ブラウザー自身がページの読み込みとしてフォームを送信すると、`Sec-Fetch-Mode` は `navigate` になります。fetcher はバックグラウンドのリクエストとして送るので、fetcher には `data()` が、それ以外にはリダイレクトが返ります。
- エラーは throw せずに、4xx か 5xx の `data()` で返します。throw したエラーは最も近い `ErrorBoundary` まで伝わり、ユーザーが作業していた画面を置き換えてしまいます。
- `Origin` にはリクエスト自身のオリジンを設定します。`react-router dev` では `http://localhost:5173`、本番では自分のドメインです。

### フォーム

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

```tsx
import { useFetcher, useMatches, useSearchParams } from "react-router";
import type { action } from "~/routes/api.feedback";

const SCORES = ["Blocked", "Painful", "Okay", "Smooth", "Excellent"];

function useScreen() {
  const named = useMatches().flatMap((match) => {
    const screen = (match.handle as { screen?: string } | undefined)?.screen;
    return screen ? [screen] : [];
  });
  return named.at(-1) ?? "unknown";
}

export function FeedbackForm() {
  const screen = useScreen();
  const fetcher = useFetcher<typeof action>({ key: `feedback:${screen}` });
  const [params] = useSearchParams();
  const busy = fetcher.state !== "idle";
  const fallback = params.get("feedback");
  const sent = fetcher.data?.status === "sent" || fallback === "sent";
  const error = fetcher.data?.status === "error" ? fetcher.data.message : fallback === "error" ? "That didn't go through." : "";

  if (sent) return <p role="status">Got it. Thank you.</p>;

  return (
    <fetcher.Form method="post" action="/api/feedback" aria-busy={busy}>
      <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={3} maxLength={4000} />
      <input type="hidden" name="screen" value={screen} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {error ? <p role="alert">{error}</p> : null}
      <button disabled={busy}>{busy ? "Sending" : "Send feedback"}</button>
    </fetcher.Form>
  );
}
```

- `useScreen` はマッチしたルートをたどり、いちばん深い `handle.screen` を使うので、ネストしたルートは親より細かく自分の名前を付けられます。
- fetcher の `key` には画面名が含まれます。ある画面で送信したあと、次の画面では前のお礼ではなく新しいフォームが表示されます。
- マッチしたローダーが再検証される間、`fetcher.state` は `submitting`、続いて `loading` になります。どちらも処理中として扱うので、結果が届くまでボタンは無効のままです。
- JavaScript がなければ、リダイレクトで `?feedback=sent` 付きの同じページに戻り、`useSearchParams` が同じお礼の一文を表示します。

### 1 回だけマウントする

`app/routes/app-layout.tsx`:

```tsx
import { Outlet } from "react-router";
import { FeedbackForm } from "~/components/feedback-form";

export default function AppLayout() {
  return (
    <>
      <Outlet />
      <aside aria-label="Feedback">
        <FeedbackForm />
      </aside>
    </>
  );
}
```

`app/routes/project-settings.tsx`:

```tsx
export const handle = { screen: "projects/settings" };

export default function ProjectSettings() {
  return <h1>Project settings</h1>;
}
```

どのプロジェクトの設定画面も `projects/settings` として記録されるので、まとめて平均されます。実際の ID を含む具体的なパスは `page` に残ります。

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

`_gotcha` 入力欄は action をそのまま通り、埋まったものはスパムとして保存されます。シークレットキー付きの送信では Ikiform の人間確認はスキップされ、代わりにセッションのチェックが働きます。署名付き Cookie のない訪問者からは何も送信されません。残る注意点は 1 つのアカウントがループで送信することで、Ikiform はそれらをすべてサーバーの IP に対して数えます。

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

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

action にはサーバーが必要です。`react-router.config.ts` で `ssr: false` にするとビルドにサーバーがなくなり、サーバーの action は動きません。信頼できる本人情報のないまま、ブラウザーから送信する `clientAction` に戻ることになります。マーケティング用のページをプリレンダーするのは問題ありません。action は別のルートで、リクエストごとに実行されるからです。サインイン後のアプリ部分は `ssr: true` のままにしてください。

## デプロイ

| ホスト | 対処法 |
| --- | --- |
| Vercel | `@vercel/react-router` をインストールし、`react-router.config.ts` の `presets` に `vercelPreset()` を追加します。両方の変数をプロジェクトの設定に追加します。 |
| Netlify | Vite のプラグインに `@netlify/vite-plugin-react-router` を追加します。action は Netlify Functions として動きます。両方の変数をサイトの環境変数の設定に設定します。 |
| Cloudflare | `@cloudflare/vite-plugin` を使う Cloudflare の React Router テンプレートを使います。バインディングは `import { env } from "cloudflare:workers"` で読みます。React Router 8 では action の `context` に `cloudflare.env` が含まれなくなりました。キーはそこから読み、Worker のシークレットとして保存し、ローカル実行用には `.dev.vars` を使います。 |

## localhost からの無料テスト

Ikiform は、プランのないワークスペースに `localhost` からの無料のテスト送信を月 50 件提供し、`Origin` ヘッダーでそれを判定します。action からの `fetch` には、自分で付けない限り `Origin` がありません。オリジンのない送信は本番のサーバー送信として扱われ、`402 plan_required` となり、プランを選ぶまで保留されます。リクエストのオリジンを渡せば、`react-router dev` からの送信がローカルテストとして数えられます。

## 動作を確認する

1. `react-router dev` を実行してサインインし、プロジェクトの設定を開いて **Painful** を選び、コメントを追加します。
2. **Submissions** を確認します。`screen` は `projects/settings`、`page` にはクエリ文字列なしの実際の ID が入り、`user_id` と `email` が自分のアカウントと一致しています。
3. 別の画面に移動します。そこのフォームは空で、前のお礼は表示されていません。
4. JavaScript をオフにしてもう一度送信します。ページが `?feedback=sent` 付きで再読み込みされ、お礼の一文が表示されます。
5. セッションの Cookie を消して送信します。フォームにサインインを促すメッセージが表示されます。

## ハマりどころ

| 問題 | 対処法 |
| --- | --- |
| 送信後にブラウザーに生の JSON が表示される | 送信がナビゲーションとして届き、action が `data()` を返しています。`Sec-Fetch-Mode` の分岐と、サーバーの手前で何かがそのヘッダーを削除していないかを確認してください。 |
| ローカルでのテスト中に `402 plan_required` になる | 送信にローカルのオリジンがありませんでした。action の `Origin` ヘッダーを残し、`localhost` でアプリを開いてください。 |
| `screen` が常に `unknown` になる | マッチしたルートのどれも `screen` 付きの `handle` をエクスポートしていないか、値に大文字やスペースが含まれています。小文字でパスのような名前を使ってください。 |
| 送信に失敗するとエラーバウンダリーがページを置き換える | action の中で何かが throw しています。想定される失敗には、すべてステータス付きの `data()` を返してください。 |
| サインイン直後に `readUser` が null を返す | Cookie を書いたコードとこのコードで `SESSION_SECRET` が違うため、署名の確認に失敗しています。両方で同じストレージモジュールを使ってください。 |

## よくある質問

### useFetcher のフォームからリソースルートに送信できますか？

はい。fetcher.Form の action にリソースルートを指定します。fetcher が結果を受け取れるよう action から data() を返し、リクエストがブラウザーのナビゲーションのときはリダイレクトして、ハイドレーション前の送信で生の JSON が表示されないようにします。

### React Router の action でサインイン中のユーザーを取得するには？

セッションストレージの getSession 関数で Cookie ヘッダーからセッションを読み、そこからユーザーの ID を読みます。フォームから送られたユーザー ID は決して信用しないでください。

### React Router の SPA モードでも動きますか？

いいえ。ssr を false にすると、action を実行するサーバーがありません。clientAction から送信することはできますが、その場合は本人情報をブラウザーが渡すことになり、信用できません。

### Ikiform は無料ですか？

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

## その他のガイド

- [ウェブサイトやアプリのフィードバックフォーム](https://ikiform.com/ja/guides/feedback-form.md): どのページにもアプリ内にもフィードバックフォームを設置。評価、コメント、送信元ページ、スパム対策、低評価時の通知まで。
- [TanStack Start のフィードバックフォーム](https://ikiform.com/ja/guides/feedback-form/tanstack-start.md): TanStack Start のアプリ内フィードバックフォーム。セッションのミドルウェア付きの POST サーバー関数、文脈としてのルート ID、リクエストヘッダーから読むページを組み合わせます。
- [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): 解決策ではなく課題を尋ね、要望をテーマ別に保存し、件数ではなく人数で数え、リリース時にメールで知らせる機能要望フォームです。
