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

Published 2026-10-25 · https://ikiform.com/guides/feedback-form/tanstack-start

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](https://ikiform.com/guides/feedback-form.md#form) 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](https://ikiform.com/docs/guides/server.md).

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

| Value | Source | Trust |
| --- | --- | --- |
| `rating`, `comment` | The form's `FormData` | The user's own words |
| `user_id`, `email` | `context.user`, set by session middleware | Read from a sealed cookie on the server |
| `page` | `getRequestHeader("referer")` inside the handler | The browser calls the function from the open page, so the header holds its URL |
| `screen` | The deepest match's `routeId`, such as `/_app/projects/$projectId/settings` | A grouping label. Checked against a pattern before it's stored |

Diagram: How a TanStack Start server function adds the user and page to a feedback answer

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

```text
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`:

```bash
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.

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

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

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

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

```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](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 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

| Host | Setup |
| --- | --- |
| Vercel | Add `nitro()` from `nitro/vite` to the Vite plugins, deploy, and add `IKIFORM_SECRET_KEY` to the project's environment variables. |
| Netlify | Add `netlify()` from `@netlify/vite-plugin-tanstack-start` and set the key in the site's environment variables. |
| Cloudflare | Add `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

| Problem | What to do |
| --- | --- |
| Server functions lost their CSRF check after you added `src/start.ts` | With 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 tests | The `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 Cloudflare | Workers 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 `unknown` | The route id failed the pattern, often because of characters such as `{` from optional params. Widen the pattern to match your routes. |
| `422 validation_failed` | The 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

### How do I get the current user inside a TanStack Start server function?

Write a function middleware that reads the session and calls next with the user in context, then list it in the server function's middleware. The handler reads context.user, typed, on the server.

### Can a TanStack Start server function read request headers?

Yes. getRequestHeader and getRequest from @tanstack/react-start/server read the incoming request inside the handler, so you can take the page from Referer without sending it from the browser.

### Does the feedback form work in TanStack Start SPA mode?

Yes, as long as a server runs your server functions and your host doesn't rewrite /_serverFn/ paths to the SPA shell.

### 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.
- [React Router feedback form](https://ikiform.com/guides/feedback-form/react-router.md): 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.
- [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.
