# Feedback-Formular in der App mit React Router bauen

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

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

Mounte ein Feedback-Formular im Layout um deine Routen für angemeldete Nutzer und sende es mit `useFetcher` an eine Resource Route. Deren Action liest den Nutzer aus deiner Cookie-Session, nimmt die Seite aus dem `Referer` und leitet die Bewertung mit einem Secret Key an dein Formular-Backend weiter. Ist JavaScript noch nicht geladen, leitet dieselbe Action zurück auf die Seite, statt mit rohem JSON zu antworten.

Der [React-Router-Tab der Hauptanleitung](https://ikiform.com/de/guides/feedback-form.md#form) sendet ein öffentliches Seitenformular über einen Fetcher. Hier lebt das Formular in deiner App, hinter einer Anmeldung, und die Action entscheidet, wer es gesendet hat. Für Keys, erlaubte Domains und die Grundlagen siehe die [Anleitung für Server-Frameworks in der Doku](https://ikiform.com/de/docs/guides/server.md). Der Code zielt auf React Router 8 im Framework-Modus.

## Was ein Fetcher im Layout ändert

Ein Feedback-Formular in einem Layout ist auf allen Ansichten gleichzeitig, und React Router hält es gemountet, während der Nutzer navigiert. Das willst du, und es wirft vier Fragen auf, die die Hauptanleitung nie beantworten muss:

| Frage | In einem Layout | Wie diese Seite damit umgeht |
| --- | --- | --- |
| Wer es gesendet hat | Der Browser könnte jeden Nutzer behaupten | Die Action liest die signierte Cookie-Session |
| Welche Ansicht | Das Formular weiß nicht, welche Kind-Route angezeigt wird | Jede Route benennt sich selbst in `handle`, gelesen mit `useMatches` |
| Übrig gebliebener State | Die `data` eines Fetchers überleben die Navigation | Ein Fetcher pro Ansicht über die Option `key` |
| Noch kein JavaScript | `fetcher.Form` ist ein echtes Formular, also sendet der Browser es an die Resource Route | Die Action erkennt einen Dokument-Post und leitet zurück |

Diagramm: Zwei Wege, auf denen ein Feedback-Post mit React Router dieselbe Resource Route erreicht

1. Der Nutzer bewertet die Ansicht im Formular, das das Layout auf jeder Route für angemeldete Nutzer rendert.
2. Nach der Hydration sendet der Fetcher im Hintergrund. Die Action liest die Cookie-Session, die Seite aus dem Referer und den Namen der Ansicht.
3. Die Action sendet mit dem Secret Key und dem angehängten Nutzer an Ikiform.
4. Das Ergebnis kommt über data() zurück und erscheint an Ort und Stelle, ohne Navigation.
5. Vor der Hydration sendet der Browser das Formular als Dokument. Die Action erkennt eine Navigation und leitet mit dem Ergebnis in der Query auf die Seite zurück, von der es kam.

Eine Action, zwei Arten von Aufrufern. Nur eine Browser-Navigation bekommt eine Weiterleitung; Fetcher-Aufrufe bekommen Daten.

## Projekt-Setup

Die beteiligten Dateien in einem mit `create-react-router` erstellten Projekt:

`Projektstruktur`:

```text
app/
  routes.ts
  sessions.server.ts
  components/
    feedback-form.tsx
  routes/
    api.feedback.ts
    app-layout.tsx
    project-settings.tsx
```

Füge eine Resource Route für die Action und ein pfadloses Layout für die Ansichten angemeldeter Nutzer hinzu. Ein Modul mit einer Action und ohne Default-Export ist eine Resource Route, es rendert also nichts.

`app/routes.ts`:

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

Erstelle unter **API keys** einen Secret Key mit dem Scope `submissions:write`. Der Server liest beide Werte aus `process.env`; setze sie dort, wo dein Server läuft, und gib dem Key nie das Präfix `VITE_`, das ihn ins Client-Bundle bringen würde.

`.env`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
SESSION_SECRET=a-long-random-string
```

Füge die Definition unten im Tab **Fields** des Formulars ein. Die Person tippt zwei Felder; die Action liefert den Rest, und die `email` aus der Session sorgt dafür, dass **Reply** in jeder Benachrichtigung sie erreicht.

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 ein `rating` von `9` oder ein Feld, das die Definition nicht listet, und du siehst den `422`, den deine Action bekommt.

## Bauen

### Den Nutzer aus der Cookie-Session lesen

`createCookieSessionStorage` signiert das Cookie mit `secrets`, sodass ein Nutzer es lesen, aber nicht ändern kann. Schreibt dein Login bereits eine Session, nutze dessen Storage weiter und ergänze `readUser`.

`app/sessions.server.ts`:

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

Das Suffix `.server` hält das Modul aus dem Client-Bundle heraus; es aus einer Komponente zu importieren, lässt den Build scheitern.

### Die Resource Route

`app/routes/api.feedback.ts`:

```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` behält den Pfad nur, wenn `Referer` dein eigener Origin ist, und entfernt den Query-String, in dem App-URLs IDs und Tokens tragen.
- `Sec-Fetch-Mode` ist `navigate`, wenn der Browser das Formular selbst als Seitenladevorgang absendet. Fetcher senden es als Hintergrund-Request, also bekommen sie `data()`, und alle anderen bekommen eine Weiterleitung.
- Fehler nutzen ein `data()` mit 4xx oder 5xx statt einer Exception. Eine geworfene Exception würde bis zur nächsten `ErrorBoundary` aufsteigen und die Ansicht ersetzen, in der der Nutzer gerade gearbeitet hat.
- `Origin` wird auf den eigenen Origin des Requests gesetzt. Das ist `http://localhost:5173` unter `react-router dev` und deine Domain in Produktion.

### Das Formular

`app/components/feedback-form.tsx`:

```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` geht die passenden Routen durch und behält das tiefste `handle.screen`, sodass eine verschachtelte Route sich genauer benennen kann als ihre Eltern-Route.
- Der `key` des Fetchers enthält die Ansicht. Nach dem Senden auf einer Ansicht zeigt die nächste ein frisches Formular statt des vorherigen Dankeschöns.
- `fetcher.state` ist `submitting` und dann `loading`, während passende Loader neu validieren. Beides gilt als beschäftigt, also bleibt der Button deaktiviert, bis die Antwort da ist.
- Ohne JavaScript landet die Weiterleitung mit `?feedback=sent` wieder auf der Seite, und `useSearchParams` zeigt dieselbe Dankeszeile.

### Einmal mounten

`app/routes/app-layout.tsx`:

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

```tsx
export const handle = { screen: "projects/settings" };

export default function ProjectSettings() {
  return <h1>Project settings</h1>;
}
```

Die Einstellungsansicht jedes Projekts meldet sich als `projects/settings`, sodass ihre Durchschnitte zusammenfallen; der konkrete Pfad mit der echten ID steht weiterhin in `page`.

## Honeypot, Menschlichkeitsprüfung und Limits

Der `_gotcha`-Input geht unverändert durch die Action, und ein ausgefüllter wird als Spam gespeichert. Die Menschlichkeitsprüfung von Ikiform wird bei Posts mit Secret Key übersprungen, und die Session-Prüfung tritt an ihre Stelle: Für einen Besucher ohne signiertes Cookie wird nichts gesendet. Bleibt ein Konto, das in einer Schleife sendet, und Ikiform zählt all diese Posts gegen die IP 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

Die Action braucht einen Server. Mit `ssr: false` in `react-router.config.ts` hat der Build keinen Server, und Server-Actions können nicht laufen; du wärst wieder bei einer `clientAction`, die ohne vertrauenswürdige Identität aus dem Browser sendet. Marketingseiten vorzurendern ist in Ordnung, weil die Action eine separate Route ist, die weiterhin pro Request läuft. Behalte `ssr: true` für den Teil der App mit Anmeldung.

## Deployen

| Host | Was zu tun ist |
| --- | --- |
| Vercel | Installiere `@vercel/react-router` und füge `vercelPreset()` zu `presets` in `react-router.config.ts` hinzu. Füge beide Variablen in den Projekteinstellungen hinzu. |
| Netlify | Füge `@netlify/vite-plugin-react-router` zu den Vite-Plugins hinzu. Actions laufen als Netlify Functions; setze beide Variablen in den Umgebungseinstellungen der Site. |
| Cloudflare | Nutze die React-Router-Vorlage von Cloudflare mit `@cloudflare/vite-plugin`. Lies Bindings mit `import { env } from "cloudflare:workers"`; in React Router 8 trägt der `context` der Action kein `cloudflare.env` mehr. Lies den Key dort und speichere ihn als Worker-Secret, mit `.dev.vars` für lokale Läufe. |

## Kostenlose Tests von localhost

Ikiform gibt einem Workspace ohne Tarif 50 kostenlose Testeinsendungen pro Monat von `localhost`, beurteilt am `Origin`-Header. Ein `fetch` aus deiner Action hat keinen `Origin`, außer du fügst einen hinzu, und ein Post ohne Origin wird als Live-Post eines Servers behandelt: `402 plan_required`, zurückgehalten, bis du einen Tarif wählst. Den Origin des Requests weiterzureichen, lässt `react-router dev` als lokalen Test zählen.

## Prüfen, ob es funktioniert

1. Starte `react-router dev`, melde dich an, öffne die Einstellungen eines Projekts, wähle **Painful** und füge einen Kommentar hinzu.
2. Prüf **Submissions**: `screen` ist `projects/settings`, `page` enthält die echte ID ohne Query-String, und `user_id` und `email` passen zu deinem Konto.
3. Wechsle zu einer anderen Ansicht. Das Formular dort ist leer und zeigt nicht das vorherige Dankeschön.
4. Schalte JavaScript aus und sende erneut. Die Seite lädt mit `?feedback=sent` und der Dankeszeile neu.
5. Lösche das Session-Cookie und sende ab. Das Formular zeigt die Anmeldemeldung.

## Stolperfallen

| Problem | Was zu tun ist |
| --- | --- |
| Der Browser zeigt nach dem Absenden rohes JSON | Der Post kam als Navigation an, und die Action gab `data()` zurück. Prüf den Zweig für `Sec-Fetch-Mode` und dass nichts vor deinem Server diesen Header entfernt. |
| `402 plan_required` beim lokalen Testen | Der Post hatte keinen lokalen Origin. Behalte den `Origin`-Header in der Action und öffne die App unter `localhost`. |
| `screen` ist immer `unknown` | Keine passende Route exportiert ein `handle` mit `screen`, oder der Wert enthält Großbuchstaben oder Leerzeichen. Nutze kleingeschriebene, pfadartige Namen. |
| Die Error Boundary ersetzt die Seite bei einem fehlgeschlagenen Senden | Etwas in der Action hat eine Exception geworfen. Gib für jeden erwarteten Fehler `data()` mit einem Status zurück. |
| `readUser` gibt direkt nach der Anmeldung null zurück | `SESSION_SECRET` unterscheidet sich zwischen dem Code, der das Cookie geschrieben hat, und diesem, also schlägt die Signatur fehl. Nutze für beide ein Storage-Modul. |

## Fragen

### Kann ein useFetcher-Formular an eine Resource Route senden?

Ja. Gib fetcher.Form eine action, die auf die Resource Route zeigt. Gib aus der Action data() zurück, damit der Fetcher das Ergebnis bekommt, und leite weiter, wenn der Request eine Browser-Navigation ist, damit ein Post vor der Hydration kein rohes JSON zeigt.

### Wie bekomme ich den angemeldeten Nutzer in einer React-Router-Action?

Lies die Session mit der getSession-Funktion deines Session-Storage aus dem Cookie-Header und lies daraus die ID des Nutzers. Vertraue nie einer Nutzer-ID, die das Formular sendet.

### Funktioniert das im SPA-Modus von React Router?

Nein. Mit ssr auf false gibt es keinen Server, der die Action ausführt. Du könntest aus einer clientAction senden, aber dann liefert der Browser die Identität, und der kann man nicht vertrauen.

### 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 TanStack Start](https://ikiform.com/de/guides/feedback-form/tanstack-start.md): 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.
- [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.
