Feedback-Formular für eine Website oder App8 Min. Lesezeit
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.
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 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.
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 |
- Das Formular sammelt Bewertung und Kommentar, ergänzt die aktuelle Routen-ID und ruft die Server-Funktion auf.
- Auf dem Server liest die Middleware die Session und reicht den Nutzer als Kontext weiter.
- Der Validator macht aus den FormData ein typisiertes Objekt und verwirft einen Ansichtsnamen mit falscher Form.
- Der Handler lehnt einen fehlenden Nutzer ab und ergänzt dann den Nutzer und die Seite aus dem Referer-Header.
- Er sendet mit dem Secret Key an Ikiform und gibt sent zurück oder einen Fehler, den das Formular anzeigt.
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.
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
.envStart 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.
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
SESSION_SECRET=at-least-32-characters-from-your-auth-setupFü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.
Vorschau
Führt den Validator von Ikiform in deinem Browser aus. Es wird nichts gesendet.
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.
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
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." };
});.validatorbekommt die rohenFormDataund gibt ein einfaches Objekt zurück. Eine ungültige Bewertung wird zunullstatt zu einer geworfenen Exception, die den Client als allgemeiner Fehler erreichen würde.getRequestHeaderliest den eingehenden Request von überall im Handler, ohne dass ein Request-Objekt herumgereicht wird.Originwird 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
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>
);
}useMatchesmitselectgibt nur dierouteIddes letzten Treffers zurück, sodass die Komponente neu rendert, wenn sich die Ansicht ändert, nicht bei jedem Router-Update.onSubmitstoppt den nativen Post und ruft die Funktion auf. Nichts setzt das Formular zurück, also steht der Kommentar nach einem Fehler noch im Feld.pendingdeaktiviert den Button für die Dauer des Aufrufs, was doppeltes Senden verhindert.
In der Layout-Route mounten
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 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
- Starte
vite dev, melde dich an, öffne die Einstellungen eines Projekts und sende eine 2 mit Kommentar. - In Submissions hat die Antwort
user_id,email,screenmit der Routen-ID undpagemit der echten Projekt-ID. - Melde dich in einem anderen Tab ab und sende erneut. Das Formular zeigt die Abmeldemeldung und behält den Kommentar.
- Ruf die Funktion aus der Browser-Konsole mit einer gefälschten Nutzer-ID in den Formulardaten auf. Die gespeicherte
user_idist trotzdem die der Session. - 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
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.
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.
Ja, solange ein Server deine Server-Funktionen ausführt und dein Host die Pfade unter /_serverFn/ nicht auf die SPA-Shell umleitet.
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.