Feedback-Formular für eine Website oder App8 Min. Lesezeit
Feedback-Formular in der App mit Next.js bauen
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.
Setze ein kleines Client-Formular mit einer Bewertung von 1 bis 5 auf die Ansichten, zu denen du Feedback willst, und richte es auf eine Server Action aus. Die Action liest den angemeldeten Nutzer aus deinem Session-Cookie, nimmt die Seite aus dem Referer des Requests und leitet alles mit einem Secret Key an dein Formular-Backend weiter. Der Browser sendet nie, wer der Nutzer ist, also kann niemand Feedback im Namen eines anderen abgeben.
Der Next.js-Tab der Hauptanleitung ist ein Seiten-Feedback-Formular für jeden Besucher. Diese Seite ist für das Innere deiner App, wo Leute angemeldet sind. Dort weißt du schon, wer schreibt und auf welcher Ansicht, also stellt das Formular zwei Fragen, und der Server ergänzt den Rest. Projekt-Setup, Keys und erlaubte Domains stehen in der Next.js-Anleitung der Doku.
Was sich ändert, wenn der Nutzer angemeldet ist
Ein öffentliches Feedback-Formular nimmt, was der Browser ihm gibt. Ein Formular in der App sollte das nicht. Sendet der Browser user_id, kann jeder sie in den Entwicklertools ändern und Feedback als Kollege oder Kunde senden. Jeder Wert sollte von der Stelle kommen, der man ihn anvertrauen kann:
| Wert | Stammt von | Warum dort |
|---|---|---|
rating, comment | Das Formular | Nur der Nutzer kennt sie |
user_id, email | Das Session-Cookie, in der Action gelesen | Der Browser kann keine signierte Session fälschen |
page | Der Referer-Header des Requests der Action | Eine Server Action sendet an die Seite, die sie rendert, also enthält der Header die vollständige URL |
screen | Eine Prop, die die Seite übergibt, etwa projects/[id]/settings | Fasst die Einstellungsseite jedes Projekts unter einem Namen zusammen, damit Durchschnitte etwas aussagen |
screen ist der einzige Wert, der durch den Browser geht. Er ist ein Label zum Gruppieren, keine Berechtigung, und die Action prüft seine Form, bevor er gespeichert wird.
- Der Nutzer wählt eine Bewertung und schreibt vielleicht einen Kommentar. Das Formular sendet beides mit dem Namen der Ansicht.
- Die Server Action läuft als POST an die Seite, auf der das Formular steht, mit dem Session-Cookie und der Seite im Referer.
- getUser() liest und prüft das Session-Cookie. Ohne gültige Session wird nichts gesendet.
- Die Action ergänzt ID und E-Mail des Nutzers sowie die Seitenadresse und sendet mit dem Secret Key an Ikiform.
- Die Antwort von Ikiform wird zum State der Action: die Dankeszeile oder der Fehler, mit dem Kommentar noch im Feld.
Projekt-Setup
Vier Dateien, von denen du eine wahrscheinlich schon hast. Das Layout des App Routers ist das übliche:
app/
(app)/
projects/[id]/settings/page.tsx
feedback/
actions.ts
feedback-form.tsx
lib/
user.ts
.env.localErstelle unter API keys einen Secret Key mit dem Scope submissions:write und leg ihn in .env.local ab. Gib ihm nie das Präfix NEXT_PUBLIC_, das ihn an jeden Browser ausliefern würde.
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
SESSION_SECRET=the-secret-your-auth-already-usesFüge diese Definition im Tab Fields des Formulars ein. Nutzer sehen die ersten zwei Felder; dein Server setzt die anderen vier. Weil es ein email-Feld gibt, geht Reply in der Benachrichtigung direkt an den Nutzer.
Vorschau
Führt den Validator von Ikiform in deinem Browser aus. Es wird nichts gesendet.
0 oder einen unbekannten Feldnamen, und die Vorschau antwortet mit demselben 422, den deine Action zurückbekommt.Bauen
Den Nutzer an einer Stelle lesen
Next.js empfiehlt eine Data Access Layer: eine reine Server-Funktion, die die Session liest und prüft und von jeder Action genutzt wird. Reacts cache sorgt dafür, dass wiederholte Aufrufe in einem Request das Cookie nur einmal lesen. decrypt ist, was dein Session-Code schon mitbringt; mit einer Auth-Bibliothek gibst du hier stattdessen deren Nutzer zurück.
import "server-only";
import { cookies } from "next/headers";
import { cache } from "react";
import { decrypt } from "@/lib/session";
export interface User {
id: string;
email: string;
}
export const getUser = cache(async (): Promise<User | null> => {
const token = (await cookies()).get("session")?.value;
if (!token) return null;
const session = await decrypt(token);
return session?.userId ? { id: session.userId, email: session.email } : null;
});Die Server Action
"use server";
import { headers } from "next/headers";
import { getUser } from "@/lib/user";
export interface FeedbackState {
status: "idle" | "sent" | "error";
message: string;
comment?: string;
}
const SCREEN = /^[a-z0-9/[\]._-]{1,120}$/;
function pageFrom(referer: string | null) {
try {
const url = new URL(referer ?? "");
return url.origin + url.pathname;
} catch {
return "";
}
}
export async function sendFeedback(_previous: FeedbackState, formData: FormData): Promise<FeedbackState> {
const comment = String(formData.get("comment") ?? "").trim().slice(0, 4000);
const user = await getUser();
if (!user) return { status: "error", message: "Your session ended. Sign in again, then send it.", comment };
const rating = Number(formData.get("rating"));
if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
return { status: "error", message: "Pick a score from 1 to 5.", comment };
}
const request = await headers();
const screen = String(formData.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: request.get("origin") ?? "",
},
body: JSON.stringify({
rating,
comment,
page: pageFrom(request.get("referer")),
screen: SCREEN.test(screen) ? screen : "unknown",
user_id: user.id,
email: user.email,
_gotcha: String(formData.get("_gotcha") ?? ""),
}),
signal: AbortSignal.timeout(8000),
});
} catch {
return { status: "error", message: "We couldn't send it. Try again in a moment.", comment };
}
const result = await response.json().catch(() => null);
if (result?.ok) return { status: "sent", message: "Thanks. We read every one." };
return {
status: "error",
message: result?.error?.fields?.comment?.[0] ?? result?.error?.message ?? "We couldn't send it. Try again.",
comment,
};
}- Die Session-Prüfung kommt zuerst und wirft keine Exception. Ein Nutzer, dessen Session mitten im Kommentar abgelaufen ist, bekommt eine Meldung und behält seinen Kommentar, statt einer Fehlerseite.
pageFrombehält vomReferernur Origin und Pfad. Query-Strings enthalten in einer App oft IDs und Tokens, die du nicht in einem Feedback-Posteingang haben willst.screenmuss einem kurzen, pfadartigen Pattern entsprechen. Alles andere wird alsunknowngespeichert, damit ein manipulierter Post die Spalte nicht mit Müll füllen kann.Originreicht den Origin des Browsers weiter. In Produktion ist das deine Domain; auf deinem Rechner ist eslocalhost, was lokale Tests kostenlos macht, wie unten erklärt.AbortSignal.timeoutverhindert, dass der Button bei langsamem Netz ewig lädt.
Das Formular
"use client";
import { useActionState } from "react";
import { type FeedbackState, sendFeedback } from "./actions";
const initial: FeedbackState = { status: "idle", message: "" };
const SCORES = ["Broken", "Frustrating", "Fine", "Good", "Great"];
export function FeedbackForm({ screen }: { screen: string }) {
const [state, action, pending] = useActionState(sendFeedback, initial);
if (state.status === "sent") return <p role="status">{state.message}</p>;
return (
<form action={action} 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} defaultValue={state.comment} />
<input type="hidden" name="screen" value={screen} />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{state.status === "error" ? <p role="alert">{state.message}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send feedback"}</button>
</form>
);
}- Die Action geht direkt an
action. Next.js rendert sie als echten Formular-Post, sodass ein Nutzer, der klickt, bevor das JavaScript geladen ist, sein Feedback trotzdem absendet. pendingaususeActionStatedeaktiviert den Button, während die Action läuft, was doppeltes Senden verhindert.- React setzt das Formular nach einer Action zurück.
defaultValue={state.comment}stellt den Kommentar bei einem Fehler wieder her, damit niemand ihn zweimal tippt. - Die Labels benennen die Bewertungen.
BrokenundGreatsagen mehr als1und5, und der gespeicherte Wert ist trotzdem eine Zahl.
Auf eine Ansicht setzen
import { FeedbackForm } from "@/app/feedback/feedback-form";
export default function ProjectSettingsPage() {
return (
<main>
<h1>Project settings</h1>
<aside>
<FeedbackForm screen="projects/[id]/settings" />
</aside>
</main>
);
}Übergib den Ordnerpfad der Route als screen. Die Einstellungsseite jedes Projekts teilt sich dann einen Namen, und die vollständige Adresse mit der echten ID steht weiterhin in page.
Honeypot, Menschlichkeitsprüfung und Limits
Der versteckte _gotcha-Input wird unverändert weitergeleitet. Ikiform speichert einen ausgefüllten als Spam. Die Menschlichkeitsprüfung läuft nicht, weil ein Post mit Secret Key sie überspringt, und das ist hier in Ordnung: Die Action lehnt jeden ohne Session ab, und die hat ein Bot nicht. Bleibt das Risiko, dass ein angemeldetes Konto in einer Schleife sendet. Das Rate-Limit von Ikiform sieht nur die Adresse deines Servers, also wähle ein passendes Preset.
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.
Rendering und Caching
Das Formular liest beim Rendern nichts. Es ruft weder cookies() noch headers() auf, macht die Seite also nicht dynamisch, und mit aktivierten Cache Components kann es ohne <Suspense>-Grenze in der statischen Shell sitzen. Nur die Action greift auf den Request zu, und Actions laufen immer pro Request. Liest die Seite die Session schon aus anderen Gründen, ändert sich nichts.
Deployen
| Host | Was zu tun ist |
|---|---|
| Vercel | Füge IKIFORM_SECRET_KEY unter Environment Variables des Projekts für Production und Preview hinzu. Actions laufen ohne weiteres Setup als Functions. |
| Netlify | Netlify baut Next.js mit einer eigenen Integration. Füge den Key in den Umgebungsvariablen der Site mit dem Scope Functions hinzu und deploye neu. |
| Cloudflare | Cloudflare betreibt Next.js über seine OpenNext-Integration auf Workers. Speichere den Key als Worker-Secret und leg ihn für lokale Previews des Worker-Builds in .dev.vars ab. |
Hinter einem Proxy oder CDN, der den Host ändert, lehnt Next.js Actions ab, deren Origin nicht passt. Trag diese Domain in experimental.serverActions.allowedOrigins in next.config.ts ein.
Kostenlos auf localhost testen
Ein Workspace ohne Tarif empfängt 50 Testeinsendungen pro Monat, aber nur von localhost, was Ikiform aus dem Origin-Header liest. Ein serverseitiges fetch sendet kein eigenes Origin, also käme ohne die Zeile, die es weiterreicht, jeder lokale Test mit 402 plan_required zurück und würde zurückgehalten. Mit ihr zählt next dev auf http://localhost:3000 als lokaler Test, und Posts aus Produktion tragen deine echte Domain.
Prüfen, ob es funktioniert
- Starte
next dev, melde dich an, öffne die Einstellungen eines Projekts und sende eine Bewertung von 2 mit Kommentar. - Öffne Submissions. Die Antwort enthält
user_id,email,screenmitprojects/[id]/settingsundpagemit der echten Projekt-ID und ohne Query-String. - Melde dich in einem anderen Tab ab und sende erneut. Das Formular zeigt die Anmeldemeldung, und dein Kommentar steht noch im Feld.
- Deaktiviere JavaScript und sende noch einmal. Die Seite lädt mit der Dankeszeile neu.
- Antworte auf die Benachrichtigungs-E-Mail. Sie geht an die Adresse des angemeldeten Nutzers.
Stolperfallen
| Problem | Was zu tun ist |
|---|---|
402 plan_required bei jedem lokalen Test | Der Post erreichte Ikiform ohne localhost-Origin. Prüf die Origin-Zeile in der Action und öffne die App unter localhost, nicht unter der Netzwerkadresse deines Rechners. |
page ist leer | Eine strenge Referrer-Policy wie no-referrer entfernt den Header, sogar bei Same-Origin-Requests. Nutze strict-origin-when-cross-origin, den Standard. |
| Failed to find Server Action, direkt nach einem Deploy | Ein Tab läuft noch mit dem vorherigen Build, dessen Action-ID nicht mehr existiert. Bitte den Nutzer, neu zu laden, und halte NEXT_SERVER_ACTIONS_ENCRYPTION_KEY auf jeder Instanz gleich, wenn du selbst hostest. |
403 forbidden von Ikiform | Dem Key fehlt submissions:write, er gehört zu einem anderen Workspace oder wurde widerrufen. Erstelle einen neuen. |
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
Lies die Session in der Server Action, nicht im Browser. Ruf dort deinen Session- oder Auth-Helper auf und ergänze ID und E-Mail des Nutzers im Post. Alles, was der Browser zur Identität sendet, kann der Nutzer ändern.
Eine Server Action ist ein POST an die Seite, die das Formular rendert, also enthält ihr Referer-Header die URL dieser Seite. Behalte Origin und Pfad und entferne den Query-String.
Dieses nicht. Das Formular liest beim Rendern weder Cookies noch Header. Nur die Action liest die Session, und sie läuft bei jedem Absenden.
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.