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

Veröffentlicht am 2026-10-25 · https://ikiform.com/de/guides/feedback-form/nextjs

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](https://ikiform.com/de/guides/feedback-form.md#form) 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](https://ikiform.com/de/docs/guides/nextjs.md).

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

Diagramm: Wie eine Feedback-Antwort in Next.js auf dem Server Nutzer und Seite erhält

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

```text
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`:

```bash
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.

Formular: In-app feedback

- **Wie funktioniert diese Ansicht für dich?** (`rating`, rating, Pflichtfeld)
- **Was würde sie besser machen?** (`comment`, textarea)
- **Seite** (`page`, url)
- **Ansicht** (`screen`, text)
- **Nutzer** (`user_id`, text)
- **E-Mail** (`email`, email)

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

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

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

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

```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](https://ikiform.com/de/docs/rate-limits.md) 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

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

| 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

### Wie hänge ich in Next.js den angemeldeten Nutzer an Feedback an?

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.

### Woher weiß eine Next.js Server Action, von welcher Seite das Feedback kam?

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.

### Macht ein Feedback-Formular meine Next.js-Seite dynamisch?

Dieses nicht. Das Formular liest beim Rendern weder Cookies noch Header. Nur die Action liest die Session, und sie läuft bei jedem Absenden.

### Ist Ikiform kostenlos?

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.

## Weitere Anleitungen

- [Feedback-Formular für eine Website oder App](https://ikiform.com/de/guides/feedback-form.md): Setze ein Feedback-Formular auf jede Seite oder in deine App: eine Bewertung, ein Kommentar, die Herkunftsseite, Spamfilter und Alerts bei niedrigen Werten.
- [Feedback-Widget für React](https://ikiform.com/de/guides/feedback-form/react-feedback-widget.md): Ein schwebender Feedback-Button für eine React-App, der ein kleines Panel öffnet, Ansicht, App-Version und Konto mit jeder Antwort sendet und nie einen Entwurf verliert.
- [Feedback-Formular mit React Router](https://ikiform.com/de/guides/feedback-form/react-router.md): 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.
- [Feedback-Formular mit TanStack Start](https://ikiform.com/de/guides/feedback-form/tanstack-start.md): 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.
- [Formular für Bug-Reports](https://ikiform.com/de/guides/feedback-form/bug-report.md): Ein Bug-Report-Formular, das nach Schritten und Schweregrad fragt und URL, Browser, Viewport und letzte Fehler von der Seite selbst ergänzen lässt.
- [Formular für Feature-Wünsche](https://ikiform.com/de/guides/feedback-form/feature-request.md): Ein Formular für Feature-Wünsche, das nach dem Problem statt nach der Lösung fragt, Wünsche nach Thema speichert, Personen statt Zeilen zählt und sie beim Release anschreibt.
