Feedback form for a website or app

Feedback form for a website or app8 min read

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.

Preet SutharFounder, Ikiform

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 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. 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:

ConcernIn a layoutHow this page handles it
Who sent itThe browser could claim any userThe action reads the signed cookie session
Which screenThe form doesn't know which child route is showingEach route names itself in handle, read with useMatches
Leftover stateA fetcher's data survives navigationOne fetcher per screen through the key option
No JavaScript yetfetcher.Form is a real form, so the browser posts it to the resource routeThe action spots a document post and redirects back
Two ways a React Router feedback post reaches the same resource routefetcher.Formin the applayoutapi/feedbackactionsession,Referer,screenIkiformsecret key,stores itfetcher.datathanks orthemessageredirectback?feedback=senton the samepagefetch+ user, pagedata()Sec-Fetch-Mode: navigateTwo ways a React Router feedback post reaches the same resource routefetcher.Formin the app layoutapi/feedbackactionsession, Referer,screenIkiformsecret key, stores itfetcher.datathanks or the messageredirectback?feedback=senton the samepagefetch+ user, pagedata()Sec-Fetch-Mode: navigate
  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
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
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
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.

Preview

Runs Ikiform's validator in your browser. Nothing is sent.

How is this screen working for you?

Set by your server from the request.

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

HostWhat to do
VercelInstall @vercel/react-router and add vercelPreset() to presets in react-router.config.ts. Add both variables in the project settings.
NetlifyAdd @netlify/vite-plugin-react-router to the Vite plugins. Actions run as Netlify Functions; set both variables in the site's environment settings.
CloudflareUse 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

ProblemWhat to do
The browser shows raw JSON after submittingThe 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 locallyThe post had no local origin. Keep the Origin header in the action and open the app at localhost.
screen is always unknownNo 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 sendSomething in the action threw. Return data() with a status for every expected failure.
readUser returns null right after sign-inSESSION_SECRET differs between the code that wrote the cookie and this one, so the signature fails. Use one storage module for both.

Questions

Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.