# Feedback-Formular in der App mit Nuxt bauen

> Ein Feedback-Formular in einer Nuxt-App: eine Server-Route, die die Nutzer-Session liest, die Seite aus dem Referer nimmt und auch ohne geladenes JavaScript funktioniert.

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

Sende ein Formular mit zwei Fragen an eine Nuxt-Server-Route unter `server/api/feedback.post.ts`. Die Route liest den angemeldeten Nutzer mit `getUserSession` aus nuxt-auth-utils, nimmt die Seite aus dem `Referer` des Requests, validiert die Bewertung mit Zod und leitet alles mit einem Secret Key aus `runtimeConfig` an dein Formular-Backend weiter. Mit JavaScript sendet das Formular über `$fetch` und bleibt an Ort und Stelle; ohne leitet dieselbe Route mit dem Ergebnis in der URL zurück.

Der [Nuxt-Tab der Hauptanleitung](https://ikiform.com/de/guides/feedback-form.md#form) baut Seiten-Feedback für jeden Besucher. In einer Nuxt-App mit Konten ist die Aufgabe enger und die Daten sind reicher: Der Nutzer ist angemeldet, der Server weiß, wer er ist, und der Router weiß, auf welcher Ansicht er ist. Diese Seite baut diese Version. Keys, erlaubte Domains und das restliche Produkt-Setup stehen in der [Nuxt-Anleitung der Doku](https://ikiform.com/de/docs/guides/nuxt.md).

## Was eine Nuxt-App mit Anmeldung ändert

In einem öffentlichen Formular entscheidet der Browser, was gespeichert wird. Hier entscheidet der Browser nur über Bewertung und Kommentar. Alles, was sagt, wer und wo, ergänzt die Server-Route, wo niemand es bearbeiten kann:

| Feld | Gesetzt von | Wie |
| --- | --- | --- |
| `rating`, `comment` | Dem Nutzer | Radio-Buttons und ein Textfeld |
| `user_id`, `email` | Die Server-Route | `getUserSession(event)` liest das versiegelte Session-Cookie |
| `page` | Die Server-Route | Der `Referer`-Header, den der Browser bei Same-Origin-Posts mit dem vollständigen Pfad sendet |
| `screen` | Die Komponente, von der Route geprüft | `useRoute().name`, etwa `projects-id-settings`, von Nuxt aus dem Dateipfad der Seite gebildet |

Routennamen sind in Nuxt ein kostenloser Schlüssel zum Gruppieren. Die Einstellungsseite jedes Projekts teilt sich `projects-id-settings`, sodass du Bewertungen pro Ansicht mitteln kannst, während `page` die genaue Adresse behält.

Diagramm: Wie eine Nuxt-Server-Route Feedback mit und ohne JavaScript verarbeitet

1. Nach der Hydration stoppt die Komponente das native Absenden und sendet die Felder mit $fetch als JSON.
2. Die Server-Route prüft den Origin, liest den Nutzer aus der Session, validiert die Bewertung und ergänzt die Seite aus dem Referer.
3. Sie leitet die Antwort mit dem Secret Key aus runtimeConfig an Ikiform weiter.
4. Ein JSON-Request bekommt { sent: true } oder einen Fehler mit Meldung, und das Formular aktualisiert sich an Ort und Stelle.
5. Vor der Hydration sendet der Browser das Formular selbst. Die Route führt dieselben Prüfungen und denselben Post aus.
6. Dann antwortet sie mit einem 303 zurück zur Seite, mit dem Ergebnis im Query-String, den die Komponente anzeigt.

Die Route unterscheidet beides am Content-Type des Requests. Beide Wege speichern genau dieselbe Antwort.

## Projekt-Setup

Das nutzt die Struktur von Nuxt 4, mit App-Code unter `app/` und Server-Code im Root. Installiere `nuxt-auth-utils` und `zod`. Meldet deine App Leute schon auf andere Weise an, behalte das und lies den Nutzer stattdessen in der Route aus deiner eigenen Session.

`Projektstruktur`:

```text
app/
  components/FeedbackForm.vue
  pages/projects/[id]/settings.vue
server/
  api/feedback.post.ts
shared/
  types/auth.d.ts
nuxt.config.ts
.env
```

Deklariere den Secret Key in `runtimeConfig` mit leerem Standardwert. Nuxt befüllt ihn zur Laufzeit aus `NUXT_IKIFORM_SECRET_KEY`, und weil der Key nicht unter `public` steht, existiert er nur auf dem Server.

`nuxt.config.ts`:

```ts
export default defineNuxtConfig({
  modules: ["nuxt-auth-utils"],
  runtimeConfig: {
    ikiformSecretKey: "",
  },
});
```

`.env`:

```bash
NUXT_IKIFORM_SECRET_KEY=ik_sk_your_secret_key
NUXT_SESSION_PASSWORD=at-least-32-characters-long-random-string
```

Erstelle den Key unter **API keys** mit dem Scope `submissions:write`. nuxt-auth-utils versiegelt sein Session-Cookie mit `NUXT_SESSION_PASSWORD`, das mindestens 32 Zeichen lang sein muss; in der Entwicklung schreibt es beim ersten Start eins für dich. Sag ihm, wie dein Nutzer aussieht, damit `session.user.id` typisiert ist:

`shared/types/auth.d.ts`:

```ts
declare module "#auth-utils" {
  interface User {
    id: string;
    email: string;
  }
}

export {};
```

Füge dann die Definition im Tab **Fields** des Formulars ein. Die ersten zwei Felder sind das Formular; die Server-Route ergänzt den Rest, und das Feld `email` sorgt dafür, dass **Reply** in jeder Benachrichtigung an den Nutzer geht.

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)

Die Vorschau führt den Validator von Ikiform aus, sodass eine Bewertung von `6` den `422` zeigt, den die Route zurückbekäme.

## Bauen

### Die Server-Route

`server/api/feedback.post.ts`:

```ts
import { z } from "zod";

const Feedback = z.object({
  rating: z.coerce.number().int().min(1).max(5),
  comment: z.string().trim().max(4000).default(""),
  screen: z.string().regex(/^[a-z0-9_-]{1,120}$/).catch("unknown"),
  _gotcha: z.string().default(""),
});

function pageFrom(referer: string | undefined) {
  try {
    const url = new URL(referer ?? "");
    return url.origin + url.pathname;
  } catch {
    return "";
  }
}

export default defineEventHandler(async (event) => {
  const own = getRequestURL(event, { xForwardedHost: true });
  if (getRequestHeader(event, "origin") !== own.origin) {
    throw createError({ status: 403, message: "Posts from other sites are refused." });
  }

  const wantsJson = getRequestHeader(event, "content-type")?.includes("application/json") ?? false;
  const referer = getRequestHeader(event, "referer");
  const back = (outcome: string) => {
    const target = new URL(referer ?? "/", own.origin);
    return sendRedirect(event, `${target.pathname}?feedback=${outcome}`, 303);
  };

  const session = await getUserSession(event);
  if (!session.user) {
    if (wantsJson) throw createError({ status: 401, message: "Your session ended. Sign in again, then send it." });
    return back("signed-out");
  }

  const parsed = await readValidatedBody(event, (body) => Feedback.safeParse(body));
  if (!parsed.success) {
    if (wantsJson) throw createError({ status: 422, message: "Pick a score from 1 to 5." });
    return back("invalid");
  }

  const { ikiformSecretKey } = useRuntimeConfig(event);
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${ikiformSecretKey}`,
      Origin: own.origin,
    },
    body: JSON.stringify({
      ...parsed.data,
      page: pageFrom(referer),
      user_id: session.user.id,
      email: session.user.email,
    }),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);

  const result = await response?.json().catch(() => null);
  if (result?.ok) return wantsJson ? { sent: true } : back("sent");
  if (wantsJson) {
    throw createError({ status: 502, message: result?.error?.message ?? "We couldn't send it. Try again." });
  }
  return back("failed");
});
```

- Das Suffix `.post.ts` lässt die Datei nur auf `POST` antworten.
- Die Origin-Prüfung läuft vor allem anderen. `readBody`, das `readValidatedBody` nutzt, parst auch urlencoded Bodys, also könnte ohne sie eine andere Site ein einfaches Formular mit dem Cookie deines Nutzers an diese Route senden.
- `getUserSession` wirft keine Exception. Die Route wählt die Antwort selbst: ein `401` für `$fetch`, eine Weiterleitung für einen einfachen Post. `requireUserSession` würde einem Nutzer ohne JavaScript eine `401`-Seite schicken.
- `z.coerce.number()` macht aus dem urlencoded `"4"` eine `4`. `.catch("unknown")` speichert einen fehlerhaften `screen` als `unknown`, statt die ganze Antwort abzulehnen.
- `back` behält vom `Referer` nur den Pfad, sodass eine Weiterleitung deine Site nie verlassen kann.

### Die Komponente

`app/components/FeedbackForm.vue`:

```vue
<script setup lang="ts">
const MESSAGES: Record<string, string> = {
  "signed-out": "Your session ended. Sign in again, then send it.",
  invalid: "Pick a score from 1 to 5.",
  failed: "We couldn't send it. Try again in a moment.",
};
const SCORES = ["Broken", "Frustrating", "Fine", "Good", "Great"];

const route = useRoute();
const { loggedIn } = useUserSession();
const screen = computed(() => String(route.name ?? "unknown"));
const outcome = String(route.query.feedback ?? "");
const status = ref<"idle" | "sending" | "sent">(outcome === "sent" ? "sent" : "idle");
const error = ref(MESSAGES[outcome] ?? "");

async function send(event: SubmitEvent) {
  const body = Object.fromEntries(new FormData(event.currentTarget as HTMLFormElement));
  status.value = "sending";
  error.value = "";
  try {
    await $fetch("/api/feedback", { method: "POST", body });
    status.value = "sent";
  } catch (failure) {
    error.value = (failure as { data?: { message?: string } }).data?.message ?? "We couldn't send it. Try again in a moment.";
    status.value = "idle";
  }
}
</script>

<template>
  <template v-if="loggedIn">
    <p v-if="status === 'sent'" role="status">Thanks. We read every one.</p>
    <form v-else method="post" action="/api/feedback" :aria-busy="status === 'sending'" @submit.prevent="send">
      <fieldset>
        <legend>How is this screen working for you?</legend>
        <label v-for="(label, index) in SCORES" :key="label">
          <input type="radio" name="rating" :value="index + 1" required /> {{ label }}
        </label>
      </fieldset>
      <label for="feedback-comment">What would make it better?</label>
      <textarea id="feedback-comment" name="comment" rows="4" maxlength="4000" />
      <input type="hidden" name="screen" :value="screen" />
      <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
      <p v-if="error" role="alert">{{ error }}</p>
      <button :disabled="status === 'sending'">{{ status === "sending" ? "Sending" : "Send feedback" }}</button>
    </form>
  </template>
</template>
```

- `method` und `action` sind echte Attribute, also sendet der Browser das Formular vor der Hydration an die Route. Sobald Vue läuft, übernimmt `@submit.prevent`.
- Am JSON-Body erkennt die Route, dass sie mit JSON antworten kann. Der Body kommt aus `FormData`, also reisen Honeypot und `screen` ohne zusätzlichen Code mit.
- Bei einem fehlgeschlagenen `$fetch` ist `data.message` des Fehlers die Meldung aus `createError` in der Route.
- Nach einem einfachen Post setzt `?feedback=` in der URL den Anfangszustand, sodass Dankeschön oder Fehler auf der neu geladenen Seite erscheinen.
- `useUserSession` verbirgt das Formular vor abgemeldeten Besuchern. Das ist eine Bequemlichkeit, keine Prüfung; die Prüfung übernimmt die Route.

Setze `<FeedbackForm />` auf eine beliebige Seite im angemeldeten Teil deiner App, etwa `app/pages/projects/[id]/settings.vue`. Nuxt importiert es automatisch.

## Honeypot, Menschlichkeitsprüfung und Limits

Der `_gotcha`-Input erreicht Ikiform mit den übrigen Feldern, und ein ausgefüllter wird als Spam gespeichert. Die Menschlichkeitsprüfung von Ikiform wird bei Posts mit Secret Key übersprungen, und hier übernimmt die Session diese Aufgabe: Die Route lehnt jeden ab, der nicht angemeldet ist. Bleibt ein angemeldetes Konto, das in einer Schleife sendet, und das kann Ikiform nur über die Adresse deines Servers zählen.

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.

## Entscheidungen zum Rendering

Für sein Ergebnis ohne JavaScript braucht das Formular serverseitiges Rendering. Auf einer Route mit `ssr: false` in `routeRules` ist die Seite leer, bis JavaScript läuft, also findet der einfache Post nie statt, und `?feedback=` wird nur im Browser gelesen. Das ist in Ordnung, es entfällt nur der Fallback. Setze das Formular nicht auf eine Route mit `prerender: true`: Das HTML wird einmal gebaut, also sieht `useUserSession` niemanden, und das Formular fehlt bis zur Hydration.

## Deployen

| Host | Was zu tun ist |
| --- | --- |
| Vercel | Nitro erkennt Vercel, ein Preset ist also nicht nötig. Füge `NUXT_IKIFORM_SECRET_KEY` und `NUXT_SESSION_PASSWORD` unter **Environment Variables** hinzu und deploye neu. |
| Netlify | Wird ebenfalls erkannt. Füge beide Variablen in den Umgebungseinstellungen der Site mit dem Scope Functions hinzu; die Server-Route läuft als Funktion. |
| Cloudflare | Baue mit dem Preset `cloudflare_module` für Workers oder verbinde das Repo mit Pages, was erkannt wird. Speichere beide Werte als Secrets und leg sie für `wrangler dev` in `.dev.vars` ab. |

Auf Cloudflare existieren Umgebungswerte nur, während ein Request läuft. Die Route liest `useRuntimeConfig(event)` im Handler, und genau dort muss es stehen.

## Kostenlos auf localhost testen

Ohne Tarif nimmt ein Workspace 50 Testeinsendungen pro Monat von `localhost` an, beurteilt am `Origin`-Header. Ein `fetch` aus einer Nitro-Route hat keinen Browser dahinter und sendet keinen `Origin`, außer du setzt einen. Deshalb reicht die Route `own.origin` weiter: Unter `nuxt dev` ist das `http://localhost:3000` und zählt als Test, in Produktion ist es deine Domain. Lässt du es weg, kommt jeder lokale Test mit `402 plan_required` zurück.

## Prüfen, ob es funktioniert

1. Starte `nuxt dev`, melde dich an, öffne die Einstellungsseite eines Projekts und sende eine Bewertung von 2 mit Kommentar.
2. Prüf in **Submissions**, dass `user_id` und `email` zu deinem Konto passen, `screen` `projects-id-settings` ist und `page` den echten Pfad enthält.
3. Schalte JavaScript aus und sende erneut. Die Seite lädt mit der Dankeszeile und `?feedback=sent` in der Adresse neu.
4. Sende mit `curl` und ohne `Origin` an `/api/feedback`. Die Route antwortet mit `403`, und nichts erreicht Ikiform.
5. Lösche das Session-Cookie und sende von der offenen Seite. Das Formular zeigt die Anmeldemeldung.

## Stolperfallen

| Problem | Was zu tun ist |
| --- | --- |
| `403` von deiner eigenen Route in Produktion | Ein Proxy ändert den Host, also stimmt `getRequestURL` nicht mit dem `Origin` des Browsers überein. Lass den Proxy `X-Forwarded-Host` senden, den `xForwardedHost: true` liest. |
| `screen` ist bei jeder Antwort `unknown` | Routennamen mit Sprachsuffix, etwa die von `@nuxtjs/i18n`, enthalten Zeichen, die das Pattern ablehnt. Erweitere den Regex oder entferne das Suffix in der Komponente. |
| Der Secret Key ist in Produktion leer | Der Variablenname muss `NUXT_` plus der Key in Upper Snake Case sein, also `NUXT_IKIFORM_SECRET_KEY`, und der Key muss in `runtimeConfig` existieren. |
| `402 plan_required` bei lokalen Tests | Der Post ging ohne `Origin` raus. Behalte den Header `Origin: own.origin` und öffne die App unter `localhost`. |
| `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 bekomme ich den angemeldeten Nutzer in einer Nuxt-Server-Route?

Ruf mit nuxt-auth-utils im Handler getUserSession(event) oder requireUserSession(event) auf und lies session.user. Ohne setzt du event.context.user in einer Server-Middleware und liest das. Nimm den Nutzer nie aus dem Request-Body.

### Kann ein Nuxt-Formular ohne JavaScript funktionieren?

Ja. Gib dem Formular eine echte method und action, die auf eine Server-Route zeigen, und lass die Route mit einem 303 zurückleiten, wenn der Request kein JSON ist. Fang nach der Hydration das Absenden ab und nutze $fetch.

### Wie liest Nuxt NUXT_-Umgebungsvariablen?

Deklariere den Key in runtimeConfig in nuxt.config, und Nuxt überschreibt ihn zur Laufzeit aus einer Umgebungsvariable namens NUXT_ plus dem Key in Upper Snake Case. Keys außerhalb von public bleiben auf dem Server.

### 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-Formular mit SvelteKit](https://ikiform.com/de/guides/feedback-form/sveltekit.md): Ein Feedback-Formular in einer SvelteKit-App: Ein Layout-Formular sendet an eine Form Action, die auf dem Server den Nutzer aus locals und die Seite aus dem Request ergänzt.
- [Feedback-Formular mit Astro](https://ikiform.com/de/guides/feedback-form/astro.md): Ein Feedback-Formular in einer Astro-App: eine Action, die den Nutzer aus den Middleware-Locals, die Seite aus dem Request und die Ansicht aus dem Routenmuster nimmt.
- [Feedback-Formular mit Next.js](https://ikiform.com/de/guides/feedback-form/nextjs.md): 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.
- [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.
