Feedback form for a website or app

Feedback form for a website or app8 min read

Build an in-app feedback form in TanStack Start

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.

Preet SutharFounder, Ikiform

Write a POST server function for the feedback and attach a function middleware that reads the session and puts the user on context. The function adds that user, the page from the request's Referer and the matched route id to the rating, then forwards it to your form backend with a secret key. Mount the form once in your signed-in layout route and it knows which screen it's on by itself.

The TanStack Start tab in the main guide posts page feedback from anyone, with the page set in the browser. Inside a signed-in app you can do better: the server knows who is asking, and the router knows which screen is open. Account setup, keys and allowed domains are covered once in the server docs guide.

Who supplies what

In Start, a server function is an HTTP endpoint under /_serverFn/ that your component calls through a generated client. Anything in its input can be rewritten by whoever calls it. So the input carries only what the user chose, and everything about identity comes from middleware that runs on the server before the handler.

ValueSourceTrust
rating, commentThe form's FormDataThe user's own words
user_id, emailcontext.user, set by session middlewareRead from a sealed cookie on the server
pagegetRequestHeader("referer") inside the handlerThe browser calls the function from the open page, so the header holds its URL
screenThe deepest match's routeId, such as /_app/projects/$projectId/settingsA grouping label. Checked against a pattern before it's stored
How a TanStack Start server function adds the user and page to a feedback answerFeedbackFormFormData+routeIduserMiddlewarereadsthesessioncookievalidatorscore,comment,screenshapehandleraddsuserandRefererpageIkiformsecretkey,storesiterrorresultformstays,commentkept/_serverFncontext.userdataJSONno userHow a TanStack Start server function adds the user and page to a feedback answerFeedbackFormFormData + routeIduserMiddlewarereads the sessioncookievalidatorscore, comment, screenshapehandleradds user andReferer pageIkiformsecret key, stores iterrorresultform stays,comment kept/_serverFncontext.userdataJSONno user
  1. The form collects the score and comment, adds the current route id, and calls the server function.
  2. On the server, the middleware reads the session and passes the user on as context.
  3. The validator turns the FormData into a typed object and drops a screen name with the wrong shape.
  4. The handler refuses a missing user, then adds the user and the page from the Referer header.
  5. It posts to Ikiform with the secret key and returns sent, or an error the form shows.
The user comes from the cookie on the server. Nothing in the form data can change who an answer is from.

Project setup

The tree assumes file-based routing with a pathless _app layout that wraps every signed-in screen. session.ts is the iron-session helper from Start's authentication guide; any session library that reads a cookie works the same way. findUser in users.ts and getCurrentUserFn in server/user.ts are your own user lookups.

Project tree
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
.env

Start reads unprefixed variables from process.env on the server only. Anything with the VITE_ prefix is bundled for the browser, so the key must never have it. Read it inside the handler, not at the top of the module.

.env
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
SESSION_SECRET=at-least-32-characters-from-your-auth-setup

Paste the field definition on the form's Fields tab. The form shows two of them; the server function fills the other four, and the email field makes Reply on each notification go to the user.

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 rating: 6 or a field the definition doesn't list and the preview shows the 422 your server function would get.

Build it

Session middleware

A function middleware wraps any server function that lists it. Whatever goes into next({ context }) arrives typed in the handler. It passes null rather than throwing, so the handler can answer with a message the form shows.

src/lib/user-middleware.ts
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 } });
});

The server function

src/server/feedback.ts
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 receives the raw FormData and returns a plain object. A bad score becomes null instead of a thrown error, which would reach the client as a generic failure.
  • getRequestHeader reads the incoming request from anywhere in the handler, with no request object passed around.
  • Origin is forwarded from the browser's call. It decides whether Ikiform treats the post as a local test, explained below.
  • Only an id and email go into the context, not the whole user record. Whatever the handler returns is serialized to the browser.

The form

src/components/feedback-form.tsx
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>
  );
}
  • useMatches with select returns only the last match's routeId, so the component re-renders when the screen changes, not on every router update.
  • onSubmit stops the native post and calls the function. Nothing resets the form, so after an error the comment is still in the box.
  • pending disables the button for the length of the call, which stops a double send.

Mount it in the layout route

src/routes/_app.tsx
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 keeps signed-out users away from the screens, but it isn't what protects the feedback: anyone can call /_serverFn/ directly, which is why the middleware checks again. Route ids include pathless layouts, hence the /_app prefix in screen. Strip it before storing if you prefer shorter names.

Honeypot, human check and limits

The _gotcha input travels through the validator untouched, and Ikiform files a filled one as spam. Its human check is skipped for posts made with a secret key, which is fine behind a session: bots don't have one. What's left is a signed-in account posting in a loop, and Ikiform counts all of those against your server's address.

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 prerendering

The form works the same under full SSR or with spa: { enabled: true } in the tanstackStart() options. In SPA mode, only the root shell is prerendered and the screens render in the browser, but server functions still need a running server: if a host rewrites every path to the shell, add /_serverFn/* to the paths it passes through. Don't prerender signed-in screens; there is no user at build time.

Start's docs also mention a .url property for calling a server function from a plain HTML form. This page doesn't use it. A signed-in screen is hydrated before anyone types a comment, and a no-script post would need the function to answer with a redirect instead of data. Treat the form as needing JavaScript.

Deploy

HostSetup
VercelAdd nitro() from nitro/vite to the Vite plugins, deploy, and add IKIFORM_SECRET_KEY to the project's environment variables.
NetlifyAdd netlify() from @netlify/vite-plugin-tanstack-start and set the key in the site's environment variables.
CloudflareAdd cloudflare({ viteEnvironment: { name: "ssr" } }) from @cloudflare/vite-plugin, enable nodejs_compat, and store the key with wrangler secret put. With a compatibility date of 2025-04-01 or later, that flag fills process.env with your secrets.

Free tests from localhost

Without a plan, a workspace accepts 50 test submissions a month when the Origin header names localhost. Your server's own fetch has no origin, so a post without the forwarded header is answered 402 plan_required and held. With it, vite dev on http://localhost:3000 passes the browser's origin along and each answer counts as a test. In production the same line sends your app's domain.

Check it works

  1. Run vite dev, sign in, open a project's settings and send a 2 with a comment.
  2. In Submissions, the answer has user_id, email, screen set to the route id, and page with the real project id.
  3. Sign out in another tab and send again. The form shows the signed-out message and keeps the comment.
  4. Call the function from the browser console with a forged user id in the form data. The stored user_id is still the session's.
  5. Reply to the notification email. It goes to the signed-in user.

Gotchas

ProblemWhat to do
Server functions lost their CSRF check after you added src/start.tsWith no src/start.ts, Start adds its CSRF middleware for server functions itself. Once you define one, register createCsrfMiddleware there yourself, filtered to server functions.
402 plan_required on local testsThe Origin header didn't reach Ikiform. Check the forwarding line, and open the app at localhost rather than a LAN address.
IKIFORM_SECRET_KEY is undefined on CloudflareWorkers fill process.env only with nodejs_compat on and a compatibility date of 2025-04-01 or later. Check both in wrangler.jsonc, or read the key from env in cloudflare:workers.
screen is always unknownThe route id failed the pattern, often because of characters such as { from optional params. Widen the pattern to match your routes.
422 validation_failedThe post doesn't match the form's field definition. error.fields names each field and the reason. Match the input name to the field id.

Questions

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