Feedback-Formular für eine Website oder App

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.

Preet SutharGründer, Ikiform

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:

WertStammt vonWarum dort
rating, commentDas FormularNur der Nutzer kennt sie
user_id, emailDas Session-Cookie, in der Action gelesenDer Browser kann keine signierte Session fälschen
pageDer Referer-Header des Requests der ActionEine Server Action sendet an die Seite, die sie rendert, also enthält der Header die vollständige URL
screenEine Prop, die die Seite übergibt, etwa projects/[id]/settingsFasst 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.

Wie eine Feedback-Antwort in Next.js auf dem Server Nutzer und Seite erhältFeedbackFormBewertung,Kommentar,AnsichtServerActionPOSTandieaktuelleSeitegetUser()liestdasSession-CookieIkiformSecretKey,speichertsieAction-StateDankeoderderFehlerFehlerzustandneuanmelden,KommentarbleibtFormDatacookies()+ Nutzer, SeiteErgebniskeine SessionWie eine Feedback-Antwort in Next.js auf dem Server Nutzer und Seite erhältFeedbackFormBewertung, Kommentar,AnsichtServer ActionPOST an die aktuelleSeitegetUser()liest dasSession-CookieIkiformSecret Key, speichertsieAction-StateDanke oder der FehlerFehlerzustandneu anmelden,KommentarbleibtFormDatacookies()+ Nutzer, SeiteErgebniskeine Session
  1. Der Nutzer wählt eine Bewertung und schreibt vielleicht einen Kommentar. Das Formular sendet beides mit dem Namen der Ansicht.
  2. Die Server Action läuft als POST an die Seite, auf der das Formular steht, mit dem Session-Cookie und der Seite im Referer.
  3. getUser() liest und prüft das Session-Cookie. Ohne gültige Session wird nichts gesendet.
  4. Die Action ergänzt ID und E-Mail des Nutzers sowie die Seitenadresse und sendet mit dem Secret Key an Ikiform.
  5. Die Antwort von Ikiform wird zum State der Action: die Dankeszeile oder der Fehler, mit dem Kommentar noch im Feld.
Die Session wird beim Absenden des Formulars gelesen, nicht beim Rendern der Seite. Die Seite um das Formular kann statisch bleiben.

Projekt-Setup

Vier Dateien, von denen du eine wahrscheinlich schon hast. Das Layout des App Routers ist das übliche:

Projektstruktur
app/
  (app)/
    projects/[id]/settings/page.tsx
  feedback/
    actions.ts
    feedback-form.tsx
lib/
  user.ts
.env.local

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

.env.local
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
SESSION_SECRET=the-secret-your-auth-already-uses

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

Wie funktioniert diese Ansicht für dich?

Von deinem Server aus dem Request gesetzt.

Probier eine Bewertung von 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.

lib/user.ts
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

app/feedback/actions.ts
"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.
  • pageFrom behält vom Referer nur Origin und Pfad. Query-Strings enthalten in einer App oft IDs und Tokens, die du nicht in einem Feedback-Posteingang haben willst.
  • screen muss einem kurzen, pfadartigen Pattern entsprechen. Alles andere wird als unknown gespeichert, damit ein manipulierter Post die Spalte nicht mit Müll füllen kann.
  • Origin reicht den Origin des Browsers weiter. In Produktion ist das deine Domain; auf deinem Rechner ist es localhost, was lokale Tests kostenlos macht, wie unten erklärt.
  • AbortSignal.timeout verhindert, dass der Button bei langsamem Netz ewig lädt.

Das Formular

app/feedback/feedback-form.tsx
"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.
  • pending aus useActionState deaktiviert 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. Broken und Great sagen mehr als 1 und 5, und der gespeicherte Wert ist trotzdem eine Zahl.

Auf eine Ansicht setzen

app/(app)/projects/[id]/settings/page.tsx
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

HostWas zu tun ist
VercelFüge IKIFORM_SECRET_KEY unter Environment Variables des Projekts für Production und Preview hinzu. Actions laufen ohne weiteres Setup als Functions.
NetlifyNetlify baut Next.js mit einer eigenen Integration. Füge den Key in den Umgebungsvariablen der Site mit dem Scope Functions hinzu und deploye neu.
CloudflareCloudflare 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

  1. Starte next dev, melde dich an, öffne die Einstellungen eines Projekts und sende eine Bewertung von 2 mit Kommentar.
  2. Öffne Submissions. Die Antwort enthält user_id, email, screen mit projects/[id]/settings und page mit der echten Projekt-ID und ohne Query-String.
  3. Melde dich in einem anderen Tab ab und sende erneut. Das Formular zeigt die Anmeldemeldung, und dein Kommentar steht noch im Feld.
  4. Deaktiviere JavaScript und sende noch einmal. Die Seite lädt mit der Dankeszeile neu.
  5. Antworte auf die Benachrichtigungs-E-Mail. Sie geht an die Adresse des angemeldeten Nutzers.

Stolperfallen

ProblemWas zu tun ist
402 plan_required bei jedem lokalen TestDer 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 leerEine 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 DeployEin 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 IkiformDem Key fehlt submissions:write, er gehört zu einem anderen Workspace oder wurde widerrufen. Erstelle einen neuen.
422 validation_failedDer 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

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