Feedback-Formular für eine Website oder App

Feedback-Formular für eine Website oder App8 Min. Lesezeit

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.

Preet SutharGründer, Ikiform

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

FrageIn einem LayoutWie diese Seite damit umgeht
Wer es gesendet hatDer Browser könnte jeden Nutzer behauptenDie Action liest die signierte Cookie-Session
Welche AnsichtDas Formular weiß nicht, welche Kind-Route angezeigt wirdJede Route benennt sich selbst in handle, gelesen mit useMatches
Übrig gebliebener StateDie data eines Fetchers überleben die NavigationEin Fetcher pro Ansicht über die Option key
Noch kein JavaScriptfetcher.Form ist ein echtes Formular, also sendet der Browser es an die Resource RouteDie Action erkennt einen Dokument-Post und leitet zurück
Zwei Wege, auf denen ein Feedback-Post mit React Router dieselbe Resource Route erreichtfetcher.FormimApp-LayoutActionfürapi/feedbackSession,Referer,AnsichtIkiformSecretKey,speichertsiefetcher.dataDanke oderdieMeldungzurückleiten?feedback=sentaufderselbenSeitefetch+ Nutzer, Seitedata()Sec-Fetch-Mode: navigateZwei Wege, auf denen ein Feedback-Post mit React Router dieselbe Resource Route erreichtfetcher.Formim App-LayoutAction fürapi/feedbackSession, Referer,AnsichtIkiformSecret Key, speichertsiefetcher.dataDanke oder dieMeldungzurückleiten?feedback=sentaufderselbenSeitefetch+ Nutzer, Seitedata()Sec-Fetch-Mode: navigate
  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
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
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
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.

Vorschau

Führt den Validator von Ikiform in deinem Browser aus. Es wird nichts gesendet.

Wie funktioniert diese Ansicht für dich?

Von deinem Server aus dem Request gesetzt.

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

HostWas zu tun ist
VercelInstalliere @vercel/react-router und füge vercelPreset() zu presets in react-router.config.ts hinzu. Füge beide Variablen in den Projekteinstellungen hinzu.
NetlifyFüge @netlify/vite-plugin-react-router zu den Vite-Plugins hinzu. Actions laufen als Netlify Functions; setze beide Variablen in den Umgebungseinstellungen der Site.
CloudflareNutze 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

ProblemWas zu tun ist
Der Browser zeigt nach dem Absenden rohes JSONDer 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 TestenDer Post hatte keinen lokalen Origin. Behalte den Origin-Header in der Action und öffne die App unter localhost.
screen ist immer unknownKeine 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 SendenEtwas 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ückSESSION_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

Hängst du bei einem Schritt fest oder willst eine Anleitung für deinen Fall? Schreib an hello@ikiform.com.