# Build an in-app feedback form with React Router

> A React Router 8 feedback form: a resource route action reads the cookie session, a fetcher posts from the layout, and a no-JS post redirects back.

Published 2026-10-25 · https://ikiform.com/guides/feedback-form/react-router

Mount one feedback form in the layout around your signed-in routes and post it with `useFetcher` to a resource route. Its action reads the user from your cookie session, takes the page from `Referer`, and forwards the rating to your form backend with a secret key. When JavaScript hasn't loaded, the same action redirects back to the page instead of answering with raw JSON.

The [React Router tab in the main guide](https://ikiform.com/guides/feedback-form.md#form) posts a public page form through a fetcher. Here the form lives inside your app, behind sign-in, and the action decides who sent it. For keys, allowed domains and the basics, see the [server framework docs guide](https://ikiform.com/docs/guides/server.md). The code targets React Router 8 in framework mode.

## What a layout fetcher changes

A feedback form in a layout is on every screen at once, and React Router keeps it mounted while the user navigates. That's what you want, and it raises four questions the main guide never has to answer:

| Concern | In a layout | How this page handles it |
| --- | --- | --- |
| Who sent it | The browser could claim any user | The action reads the signed cookie session |
| Which screen | The form doesn't know which child route is showing | Each route names itself in `handle`, read with `useMatches` |
| Leftover state | A fetcher's `data` survives navigation | One fetcher per screen through the `key` option |
| No JavaScript yet | `fetcher.Form` is a real form, so the browser posts it to the resource route | The action spots a document post and redirects back |

Diagram: Two ways a React Router feedback post reaches the same resource route

1. The user rates the screen in the form that the layout renders on every signed-in route.
2. After hydration, the fetcher posts in the background. The action reads the cookie session, the page from Referer and the screen name.
3. The action posts to Ikiform with the secret key and the user attached.
4. The result comes back through data() and shows in place, without a navigation.
5. Before hydration, the browser posts the form as a document. The action sees a navigation and redirects to the page it came from with the result in the query.

One action, two kinds of caller. Only a browser navigation gets a redirect; fetcher calls get data.

## Project setup

The files involved, in a project made with `create-react-router`:

`Project tree`:

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

Add a resource route for the action and a pathless layout for the signed-in screens. A module with an action and no default export is a resource route, so it renders nothing.

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

Create a secret key under **API keys** with the `submissions:write` scope. The server reads both values from `process.env`; set them where your server runs, and never give the key a `VITE_` prefix, which would put it in the client bundle.

`.env`:

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

Paste the definition below on the form's **Fields** tab. The person types two fields; the action supplies the rest, and the `email` from the session makes **Reply** on each notification reach them.

Form: In-app feedback

- **How is this screen working for you?** (`rating`, rating, required)
- **What would make it better?** (`comment`, textarea)
- **Page** (`page`, url)
- **Screen** (`screen`, text)
- **User** (`user_id`, text)
- **Email** (`email`, email)

Send a `rating` of `9` or a field the definition doesn't list, and you'll see the `422` your action receives.

## Build it

### Read the user from the cookie session

`createCookieSessionStorage` signs the cookie with `secrets`, so a user can read it but can't change it. If your login already writes a session, reuse its storage and add `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;
}
```

The `.server` suffix keeps the module out of the client bundle; importing it from a component fails the build.

### The resource route

`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` keeps the path only when `Referer` is your own origin, and drops the query string, where app URLs carry ids and tokens.
- `Sec-Fetch-Mode` is `navigate` when the browser itself submits the form as a page load. Fetchers send it as a background request, so they get `data()` and everyone else gets a redirect.
- Errors use a 4xx or 5xx `data()` rather than a throw. A thrown error would bubble to the nearest `ErrorBoundary` and replace the screen the user was working in.
- `Origin` is set to the request's own origin. That's `http://localhost:5173` under `react-router dev`, and your domain in production.

### The form

`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` walks the matched routes and keeps the deepest `handle.screen`, so a nested route can name itself more precisely than its parent.
- The fetcher `key` includes the screen. After a send on one screen, the next screen shows a fresh form instead of the earlier thank-you.
- `fetcher.state` is `submitting` and then `loading` while matched loaders revalidate. Both count as busy, so the button stays disabled until the answer is in.
- Without JavaScript, the redirect lands back on the page with `?feedback=sent`, and `useSearchParams` shows the same thank-you line.

### Mount it once

`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>;
}
```

Every project's settings screen reports as `projects/settings`, so they average together; the concrete path with the real id is still in `page`.

## Honeypot, human check and limits

The `_gotcha` input travels through the action untouched, and a filled one is stored as spam. Ikiform's human check is skipped for posts with a secret key, and the session check takes its place: a visitor without a signed cookie gets nothing posted. What's left to watch is one account posting in a loop, and Ikiform counts all of those against your server's IP.

The server-side versions post with a secret key, so Ikiform skips **Allowed domains** and its human check. Every post then comes from your server's IP, and the Standard limit of 5 a minute per visitor counts all your visitors together. Choose the Relaxed or Custom [rate limit](https://ikiform.com/docs/rate-limits.md) for a form you post through a server, and keep `IKIFORM_SECRET_KEY` in server-only environment variables.

## SSR, SPA mode and pre-rendering

The action needs a server. With `ssr: false` in `react-router.config.ts`, the build has no server, and server actions can't run; you'd be back to a `clientAction` posting from the browser without trusted identity. Pre-rendering marketing pages is fine, because the action is a separate route that still runs on request. Keep `ssr: true` for the signed-in part of the app.

## Deploy

| Host | What to do |
| --- | --- |
| Vercel | Install `@vercel/react-router` and add `vercelPreset()` to `presets` in `react-router.config.ts`. Add both variables in the project settings. |
| Netlify | Add `@netlify/vite-plugin-react-router` to the Vite plugins. Actions run as Netlify Functions; set both variables in the site's environment settings. |
| Cloudflare | Use Cloudflare's React Router template with `@cloudflare/vite-plugin`. Read bindings with `import { env } from "cloudflare:workers"`; in React Router 8 the action's `context` no longer carries `cloudflare.env`. Read the key there, and store it as a Worker secret, with `.dev.vars` for local runs. |

## Free tests from localhost

Ikiform gives a workspace without a plan 50 free test submissions a month from `localhost`, judged by the `Origin` header. A `fetch` from your action has no `Origin` unless you add one, and a post with no origin is treated as a live server post: `402 plan_required`, held until you pick a plan. Passing the request's origin makes `react-router dev` count as a local test.

## Check it works

1. Run `react-router dev`, sign in, open a project's settings, choose **Painful** and add a comment.
2. Check **Submissions**: `screen` is `projects/settings`, `page` holds the real id without a query string, and `user_id` and `email` match your account.
3. Move to another screen. The form there is empty, not showing the earlier thank-you.
4. Turn off JavaScript and send again. The page reloads with `?feedback=sent` and the thank-you line.
5. Clear the session cookie and send. The form shows the sign-in message.

## Gotchas

| Problem | What to do |
| --- | --- |
| The browser shows raw JSON after submitting | The post arrived as a navigation and the action returned `data()`. Check the `Sec-Fetch-Mode` branch, and that nothing strips that header in front of your server. |
| `402 plan_required` while testing locally | The post had no local origin. Keep the `Origin` header in the action and open the app at `localhost`. |
| `screen` is always `unknown` | No matched route exports a `handle` with `screen`, or the value has capitals or spaces. Use lowercase, path-like names. |
| The error boundary replaces the page on a failed send | Something in the action threw. Return `data()` with a status for every expected failure. |
| `readUser` returns null right after sign-in | `SESSION_SECRET` differs between the code that wrote the cookie and this one, so the signature fails. Use one storage module for both. |

## Questions

### Can a useFetcher form post to a resource route?

Yes. Give fetcher.Form an action pointing at the resource route. Return data() from the action so the fetcher gets the result, and redirect when the request is a browser navigation so a post before hydration doesn't show raw JSON.

### How do I get the signed-in user in a React Router action?

Read the session from the Cookie header with the getSession function of your session storage, then read the user's id from it. Never trust a user id posted by the form.

### Does this work in React Router SPA mode?

No. With ssr set to false there is no server to run the action. You could post from a clientAction, but then the browser supplies the identity and it can't be trusted.

### Is Ikiform free?

No, there is no free plan. Testing is free: you can send 50 test submissions a month from localhost before you pay. To go live, Pro starts at $6/mo for 500 submissions, or you can pay once for 10,000 submissions for $49 or 50,000 submissions for $149, with credits that never expire.

## More guides

- [Feedback form for a website or app](https://ikiform.com/guides/feedback-form.md): Put a feedback form on any page or inside your app: a rating, a comment, the page it came from, spam filtering, and alerts when a score is low.
- [TanStack Start feedback form](https://ikiform.com/guides/feedback-form/tanstack-start.md): An in-app TanStack Start feedback form: a POST server function with session middleware, the route id as context, and the page read from request headers.
- [Next.js feedback form](https://ikiform.com/guides/feedback-form/nextjs.md): An in-app Next.js feedback form: a server action that reads the session, takes the page from the request and forwards a rating with the user attached.
- [React feedback widget](https://ikiform.com/guides/feedback-form/react-feedback-widget.md): A floating feedback button for a React app that opens a small panel, sends the screen, app version and account with each answer, and never loses a draft.
- [Bug report form](https://ikiform.com/guides/feedback-form/bug-report.md): A bug report form that asks people for steps and severity, and lets the page add the URL, browser, viewport and recent errors on its own.
- [Feature request form](https://ikiform.com/guides/feedback-form/feature-request.md): A feature request form that asks for the problem instead of the solution, stores requests by theme, counts people not rows, and emails them when it ships.
