# Feedback-Formular in der App mit TanStack Start bauen

> Ein Feedback-Formular in einer TanStack-Start-App: eine POST-Server-Funktion mit Session-Middleware, die Routen-ID als Kontext und die Seite aus den Request-Headern.

Veröffentlicht am 2026-10-25 · https://ikiform.com/de/guides/feedback-form/tanstack-start

Schreib eine POST-Server-Funktion für das Feedback und häng eine Function Middleware an, die die Session liest und den Nutzer auf `context` legt. Die Funktion ergänzt die Bewertung um diesen Nutzer, die Seite aus dem `Referer` des Requests und die ID der passenden Route und leitet alles mit einem Secret Key an dein Formular-Backend weiter. Mounte das Formular einmal in deiner Layout-Route für angemeldete Nutzer, dann weiß es von selbst, auf welcher Ansicht es ist.

Der [TanStack-Start-Tab der Hauptanleitung](https://ikiform.com/de/guides/feedback-form.md#form) sendet Seiten-Feedback von jedem, wobei die Seite im Browser gesetzt wird. In einer App mit Anmeldung geht es besser: Der Server weiß, wer fragt, und der Router weiß, welche Ansicht offen ist. Konto-Setup, Keys und erlaubte Domains behandelt einmal die [Server-Anleitung der Doku](https://ikiform.com/de/docs/guides/server.md).

## Wer was liefert

In Start ist eine Server-Funktion ein HTTP-Endpunkt unter `/_serverFn/`, den deine Komponente über einen generierten Client aufruft. Alles in ihrem Input kann vom Aufrufer umgeschrieben werden. Also enthält der Input nur, was der Nutzer gewählt hat, und alles zur Identität kommt aus Middleware, die vor dem Handler auf dem Server läuft.

| Wert | Quelle | Vertrauen |
| --- | --- | --- |
| `rating`, `comment` | Die `FormData` des Formulars | Die eigenen Worte des Nutzers |
| `user_id`, `email` | `context.user`, gesetzt von der Session-Middleware | Auf dem Server aus einem versiegelten Cookie gelesen |
| `page` | `getRequestHeader("referer")` im Handler | Der Browser ruft die Funktion von der offenen Seite aus auf, also enthält der Header deren URL |
| `screen` | Die `routeId` des tiefsten Treffers, etwa `/_app/projects/$projectId/settings` | Ein Label zum Gruppieren. Wird vor dem Speichern gegen ein Pattern geprüft |

Diagramm: Wie eine TanStack-Start-Server-Funktion einer Feedback-Antwort Nutzer und Seite hinzufügt

1. Das Formular sammelt Bewertung und Kommentar, ergänzt die aktuelle Routen-ID und ruft die Server-Funktion auf.
2. Auf dem Server liest die Middleware die Session und reicht den Nutzer als Kontext weiter.
3. Der Validator macht aus den FormData ein typisiertes Objekt und verwirft einen Ansichtsnamen mit falscher Form.
4. Der Handler lehnt einen fehlenden Nutzer ab und ergänzt dann den Nutzer und die Seite aus dem Referer-Header.
5. Er sendet mit dem Secret Key an Ikiform und gibt sent zurück oder einen Fehler, den das Formular anzeigt.

Der Nutzer kommt auf dem Server aus dem Cookie. Nichts in den Formulardaten kann ändern, von wem eine Antwort stammt.

## Projekt-Setup

Die Struktur geht von dateibasiertem Routing mit einem pfadlosen `_app`-Layout aus, das jede Ansicht für angemeldete Nutzer umschließt. `session.ts` ist der iron-session-Helper aus der Authentifizierungsanleitung von Start; jede Session-Bibliothek, die ein Cookie liest, funktioniert genauso. `findUser` in `users.ts` und `getCurrentUserFn` in `server/user.ts` sind deine eigenen Nutzerabfragen.

`Projektstruktur`:

```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 liest Variablen ohne Präfix nur auf dem Server aus `process.env`. Alles mit dem Präfix `VITE_` wird für den Browser gebündelt, also darf der Key es nie haben. Lies ihn im Handler, nicht oben im Modul.

`.env`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
SESSION_SECRET=at-least-32-characters-from-your-auth-setup
```

Füge die Felddefinition im Tab **Fields** des Formulars ein. Das Formular zeigt zwei davon; die Server-Funktion füllt die anderen vier, und das Feld `email` sorgt dafür, dass **Reply** in jeder Benachrichtigung an den Nutzer geht.

Formular: In-app feedback

- **Wie funktioniert diese Ansicht für dich?** (`rating`, rating, Pflichtfeld)
- **Was würde sie besser machen?** (`comment`, textarea)
- **Seite** (`page`, url)
- **Ansicht** (`screen`, text)
- **Nutzer** (`user_id`, text)
- **E-Mail** (`email`, email)

Sende `rating: 6` oder ein Feld, das die Definition nicht listet, und die Vorschau zeigt den `422`, den deine Server-Funktion bekäme.

## Bauen

### Session-Middleware

Eine Function Middleware umschließt jede Server-Funktion, die sie auflistet. Was in `next({ context })` geht, kommt typisiert im Handler an. Sie reicht `null` weiter, statt eine Exception zu werfen, damit der Handler mit einer Meldung antworten kann, die das Formular anzeigt.

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

### Die Serverfunktion

`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` bekommt die rohen `FormData` und gibt ein einfaches Objekt zurück. Eine ungültige Bewertung wird zu `null` statt zu einer geworfenen Exception, die den Client als allgemeiner Fehler erreichen würde.
- `getRequestHeader` liest den eingehenden Request von überall im Handler, ohne dass ein Request-Objekt herumgereicht wird.
- `Origin` wird aus dem Aufruf des Browsers weitergereicht. Er entscheidet, ob Ikiform den Post als lokalen Test behandelt, wie unten erklärt.
- In den Kontext kommen nur ID und E-Mail, nicht der ganze Nutzerdatensatz. Was der Handler zurückgibt, wird zum Browser serialisiert.

### Das Formular

`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` mit `select` gibt nur die `routeId` des letzten Treffers zurück, sodass die Komponente neu rendert, wenn sich die Ansicht ändert, nicht bei jedem Router-Update.
- `onSubmit` stoppt den nativen Post und ruft die Funktion auf. Nichts setzt das Formular zurück, also steht der Kommentar nach einem Fehler noch im Feld.
- `pending` deaktiviert den Button für die Dauer des Aufrufs, was doppeltes Senden verhindert.

### In der Layout-Route mounten

`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` hält abgemeldete Nutzer von den Ansichten fern, schützt aber nicht das Feedback: Jeder kann `/_serverFn/` direkt aufrufen, deshalb prüft die Middleware erneut. Routen-IDs enthalten pfadlose Layouts, daher das Präfix `/_app` in `screen`. Entferne es vor dem Speichern, wenn du kürzere Namen bevorzugst.

## Honeypot, Menschlichkeitsprüfung und Limits

Der `_gotcha`-Input geht unverändert durch den Validator, und Ikiform legt einen ausgefüllten als Spam ab. Die Menschlichkeitsprüfung wird bei Posts mit Secret Key übersprungen, was hinter einer Session in Ordnung ist: Bots haben keine. Bleibt ein angemeldetes Konto, das in einer Schleife sendet, und Ikiform zählt all diese Posts gegen die Adresse deines Servers.

Die serverseitigen Versionen senden mit einem Secret Key, daher überspringt Ikiform **Allowed domains** und seine Menschlichkeitsprüfung. Jeder Post kommt dann von der IP deines Servers, und das Standard-Limit von 5 pro Minute und Besucher zählt alle deine Besucher zusammen. Wähle für ein Formular, das du über einen Server sendest, das [Rate-Limit](https://ikiform.com/de/docs/rate-limits.md) Relaxed oder Custom, und halte `IKIFORM_SECRET_KEY` in reinen Server-Umgebungsvariablen.

## SSR, SPA-Modus und Prerendering

Das Formular funktioniert unter vollem SSR genauso wie mit `spa: { enabled: true }` in den Optionen von `tanstackStart()`. Im SPA-Modus wird nur die Root-Shell vorgerendert und die Ansichten rendern im Browser, aber Server-Funktionen brauchen trotzdem einen laufenden Server: Leitet ein Host jeden Pfad auf die Shell um, nimm `/_serverFn/*` in die Pfade auf, die er durchlässt. Rendere Ansichten für angemeldete Nutzer nicht vor; zur Build-Zeit gibt es keinen Nutzer.

Die Doku von Start erwähnt auch eine `.url`-Eigenschaft, um eine Server-Funktion aus einem einfachen HTML-Formular aufzurufen. Diese Seite nutzt sie nicht. Eine Ansicht für angemeldete Nutzer ist hydriert, bevor jemand einen Kommentar tippt, und ein Post ohne Skript würde verlangen, dass die Funktion mit einer Weiterleitung statt mit Daten antwortet. Geh davon aus, dass das Formular JavaScript braucht.

## Deployen

| Host | Einrichtung |
| --- | --- |
| Vercel | Füge `nitro()` aus `nitro/vite` zu den Vite-Plugins hinzu, deploye und füge `IKIFORM_SECRET_KEY` zu den Umgebungsvariablen des Projekts hinzu. |
| Netlify | Füge `netlify()` aus `@netlify/vite-plugin-tanstack-start` hinzu und setze den Key in den Umgebungsvariablen der Site. |
| Cloudflare | Füge `cloudflare({ viteEnvironment: { name: "ssr" } })` aus `@cloudflare/vite-plugin` hinzu, aktiviere `nodejs_compat` und speichere den Key mit `wrangler secret put`. Mit einem Compatibility Date ab 2025-04-01 füllt dieses Flag `process.env` mit deinen Secrets. |

## Kostenlose Tests von localhost

Ohne Tarif nimmt ein Workspace 50 Testeinsendungen pro Monat an, wenn der `Origin`-Header `localhost` nennt. Das eigene `fetch` deines Servers hat keinen Origin, also wird ein Post ohne den weitergereichten Header mit `402 plan_required` beantwortet und zurückgehalten. Mit ihm reicht `vite dev` auf `http://localhost:3000` den Origin des Browsers weiter, und jede Antwort zählt als Test. In Produktion sendet dieselbe Zeile die Domain deiner App.

## Prüfen, ob es funktioniert

1. Starte `vite dev`, melde dich an, öffne die Einstellungen eines Projekts und sende eine 2 mit Kommentar.
2. In **Submissions** hat die Antwort `user_id`, `email`, `screen` mit der Routen-ID und `page` mit der echten Projekt-ID.
3. Melde dich in einem anderen Tab ab und sende erneut. Das Formular zeigt die Abmeldemeldung und behält den Kommentar.
4. Ruf die Funktion aus der Browser-Konsole mit einer gefälschten Nutzer-ID in den Formulardaten auf. Die gespeicherte `user_id` ist trotzdem die der Session.
5. Antworte auf die Benachrichtigungs-E-Mail. Sie geht an den angemeldeten Nutzer.

## Stolperfallen

| Problem | Was zu tun ist |
| --- | --- |
| Server-Funktionen haben nach dem Hinzufügen von `src/start.ts` ihre CSRF-Prüfung verloren | Ohne `src/start.ts` fügt Start seine CSRF-Middleware für Server-Funktionen selbst hinzu. Sobald du eine definierst, registriere `createCsrfMiddleware` dort selbst, gefiltert auf Server-Funktionen. |
| `402 plan_required` bei lokalen Tests | Der `Origin`-Header hat Ikiform nicht erreicht. Prüf die Zeile, die ihn weiterreicht, und öffne die App unter `localhost` statt unter einer LAN-Adresse. |
| `IKIFORM_SECRET_KEY` ist auf Cloudflare undefined | Workers füllen `process.env` nur mit aktiviertem `nodejs_compat` und einem Compatibility Date ab 2025-04-01. Prüf beides in `wrangler.jsonc` oder lies den Key aus `env` in `cloudflare:workers`. |
| `screen` ist immer `unknown` | Die Routen-ID hat das Pattern nicht erfüllt, oft wegen Zeichen wie `{` aus optionalen Parametern. Erweitere das Pattern, damit es zu deinen Routen passt. |
| `422 validation_failed` | Der Post passt nicht zur Felddefinition des Formulars. `error.fields` nennt jedes Feld und den Grund. Gleiche den `name` des Eingabefelds an die Feld-ID an. |

## Fragen

### Wie bekomme ich den aktuellen Nutzer in einer TanStack-Start-Server-Funktion?

Schreib eine Function Middleware, die die Session liest und next mit dem Nutzer im Kontext aufruft, und liste sie dann in der Middleware der Server-Funktion auf. Der Handler liest context.user typisiert auf dem Server.

### Kann eine TanStack-Start-Server-Funktion Request-Header lesen?

Ja. getRequestHeader und getRequest aus @tanstack/react-start/server lesen den eingehenden Request im Handler, sodass du die Seite aus dem Referer nehmen kannst, ohne sie aus dem Browser zu senden.

### Funktioniert das Feedback-Formular im SPA-Modus von TanStack Start?

Ja, solange ein Server deine Server-Funktionen ausführt und dein Host die Pfade unter /_serverFn/ nicht auf die SPA-Shell umleitet.

### Ist Ikiform kostenlos?

Nein, es gibt keinen kostenlosen Tarif. Testen ist kostenlos: Du kannst 50 Testeinsendungen pro Monat von localhost senden, bevor du zahlst. Für den Livebetrieb startet Pro bei $6/Monat für 500 Einsendungen, oder du zahlst einmalig 10.000 Einsendungen für $49 oder 50.000 Einsendungen für $149, mit Guthaben, das nie verfällt.

## Weitere Anleitungen

- [Feedback-Formular für eine Website oder App](https://ikiform.com/de/guides/feedback-form.md): Setze ein Feedback-Formular auf jede Seite oder in deine App: eine Bewertung, ein Kommentar, die Herkunftsseite, Spamfilter und Alerts bei niedrigen Werten.
- [Feedback-Formular mit React Router](https://ikiform.com/de/guides/feedback-form/react-router.md): Ein Feedback-Formular mit React Router 8: Die Action einer Resource Route liest die Cookie-Session, ein Fetcher sendet aus dem Layout, und ein Post ohne JS leitet zurück.
- [Feedback-Formular mit Next.js](https://ikiform.com/de/guides/feedback-form/nextjs.md): Ein Feedback-Formular in einer Next.js-App: eine Server Action, die die Session liest, die Seite aus dem Request nimmt und eine Bewertung mit dem Nutzer weiterleitet.
- [Feedback-Widget für React](https://ikiform.com/de/guides/feedback-form/react-feedback-widget.md): Ein schwebender Feedback-Button für eine React-App, der ein kleines Panel öffnet, Ansicht, App-Version und Konto mit jeder Antwort sendet und nie einen Entwurf verliert.
- [Formular für Bug-Reports](https://ikiform.com/de/guides/feedback-form/bug-report.md): Ein Bug-Report-Formular, das nach Schritten und Schweregrad fragt und URL, Browser, Viewport und letzte Fehler von der Seite selbst ergänzen lässt.
- [Formular für Feature-Wünsche](https://ikiform.com/de/guides/feedback-form/feature-request.md): Ein Formular für Feature-Wünsche, das nach dem Problem statt nach der Lösung fragt, Wünsche nach Thema speichert, Personen statt Zeilen zählt und sie beim Release anschreibt.
