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.
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:
| 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 |
- Der Nutzer bewertet die Ansicht im Formular, das das Layout auf jeder Route für angemeldete Nutzer rendert.
- Nach der Hydration sendet der Fetcher im Hintergrund. Die Action liest die Cookie-Session, die Seite aus dem Referer und den Namen der Ansicht.
- Die Action sendet mit dem Secret Key und dem angehängten Nutzer an Ikiform.
- Das Ergebnis kommt über data() zurück und erscheint an Ort und Stelle, ohne Navigation.
- 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.
Projekt-Setup
Die beteiligten Dateien in einem mit create-react-router erstellten Projekt:
app/
routes.ts
sessions.server.ts
components/
feedback-form.tsx
routes/
api.feedback.ts
app-layout.tsx
project-settings.tsxFü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.
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.
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
SESSION_SECRET=a-long-random-stringFü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.
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.
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
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);
}samePagebehält den Pfad nur, wennRefererdein eigener Origin ist, und entfernt den Query-String, in dem App-URLs IDs und Tokens tragen.Sec-Fetch-Modeistnavigate, wenn der Browser das Formular selbst als Seitenladevorgang absendet. Fetcher senden es als Hintergrund-Request, also bekommen siedata(), 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ächstenErrorBoundaryaufsteigen und die Ansicht ersetzen, in der der Nutzer gerade gearbeitet hat. Originwird auf den eigenen Origin des Requests gesetzt. Das isthttp://localhost:5173unterreact-router devund deine Domain in Produktion.
Das Formular
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>
);
}useScreengeht die passenden Routen durch und behält das tiefstehandle.screen, sodass eine verschachtelte Route sich genauer benennen kann als ihre Eltern-Route.- Der
keydes Fetchers enthält die Ansicht. Nach dem Senden auf einer Ansicht zeigt die nächste ein frisches Formular statt des vorherigen Dankeschöns. fetcher.stateistsubmittingund dannloading, 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=sentwieder auf der Seite, unduseSearchParamszeigt dieselbe Dankeszeile.
Einmal mounten
import { Outlet } from "react-router";
import { FeedbackForm } from "~/components/feedback-form";
export default function AppLayout() {
return (
<>
<Outlet />
<aside aria-label="Feedback">
<FeedbackForm />
</aside>
</>
);
}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
| 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
- Starte
react-router dev, melde dich an, öffne die Einstellungen eines Projekts, wähle Painful und füge einen Kommentar hinzu. - Prüf Submissions:
screenistprojects/settings,pageenthält die echte ID ohne Query-String, unduser_idundemailpassen zu deinem Konto. - Wechsle zu einer anderen Ansicht. Das Formular dort ist leer und zeigt nicht das vorherige Dankeschön.
- Schalte JavaScript aus und sende erneut. Die Seite lädt mit
?feedback=sentund der Dankeszeile neu. - 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
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.
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.
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.
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.
Hängst du bei einem Schritt fest oder willst eine Anleitung für deinen Fall? Schreib an hello@ikiform.com.