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.
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:
| 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 |
- The user rates the screen in the form that the layout renders on every signed-in route.
- After hydration, the fetcher posts in the background. The action reads the cookie session, the page from Referer and the screen name.
- The action posts to Ikiform with the secret key and the user attached.
- The result comes back through data() and shows in place, without a navigation.
- 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.
Project setup
The files involved, in a project made with create-react-router:
app/
routes.ts
sessions.server.ts
components/
feedback-form.tsx
routes/
api.feedback.ts
app-layout.tsx
project-settings.tsxAdd 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.
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.
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
SESSION_SECRET=a-long-random-stringPaste 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.
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.
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
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);
}samePagekeeps the path only whenRefereris your own origin, and drops the query string, where app URLs carry ids and tokens.Sec-Fetch-Modeisnavigatewhen the browser itself submits the form as a page load. Fetchers send it as a background request, so they getdata()and everyone else gets a redirect.- Errors use a 4xx or 5xx
data()rather than a throw. A thrown error would bubble to the nearestErrorBoundaryand replace the screen the user was working in. Originis set to the request's own origin. That'shttp://localhost:5173underreact-router dev, and your domain in production.
The form
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>
);
}useScreenwalks the matched routes and keeps the deepesthandle.screen, so a nested route can name itself more precisely than its parent.- The fetcher
keyincludes the screen. After a send on one screen, the next screen shows a fresh form instead of the earlier thank-you. fetcher.stateissubmittingand thenloadingwhile 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, anduseSearchParamsshows the same thank-you line.
Mount it once
import { Outlet } from "react-router";
import { FeedbackForm } from "~/components/feedback-form";
export default function AppLayout() {
return (
<>
<Outlet />
<aside aria-label="Feedback">
<FeedbackForm />
</aside>
</>
);
}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
| 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
- Run
react-router dev, sign in, open a project's settings, choose Painful and add a comment. - Check Submissions:
screenisprojects/settings,pageholds the real id without a query string, anduser_idandemailmatch your account. - Move to another screen. The form there is empty, not showing the earlier thank-you.
- Turn off JavaScript and send again. The page reloads with
?feedback=sentand the thank-you line. - 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
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.
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.
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.
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.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.