Feedback-Formular für eine Website oder App

Feedback-Formular für eine Website oder App8 Min. Lesezeit

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.

Preet SutharGründer, Ikiform

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

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:

FeldGesetzt vonWie
rating, commentDem NutzerRadio-Buttons und ein Textfeld
user_id, emailDie Server-RoutegetUserSession(event) liest das versiegelte Session-Cookie
pageDie Server-RouteDer Referer-Header, den der Browser bei Same-Origin-Posts mit dem vollständigen Pfad sendet
screenDie Komponente, von der Route geprüftuseRoute().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.

Wie eine Nuxt-Server-Route Feedback mit und ohne JavaScript verarbeitetMit JavaScriptOhne JavaScriptFeedbackForm.vue$fetch,JSON-Bodyfeedback.post.tsOrigin,Session,ZodIkiformSecretKey,speichertsie{ sent:true }DankeschönersetztdasFormulareinfacherFormular-PosturlencodedBodyfeedback.post.tsgleichePrüfungenIkiformgleicherPost303zurück?feedback=sentPOST+ Nutzer, Seite200POST+ Nutzer, SeiteWeiterleitungWie eine Nuxt-Server-Route Feedback mit und ohne JavaScript verarbeitetMit JavaScriptOhne JavaScriptFeedbackForm.vue$fetch, JSON-Bodyfeedback.post.tsOrigin, Session, ZodIkiformSecret Key, speichert sie{ sent: true }Dankeschön ersetzt das Formulareinfacher Formular-Posturlencoded Bodyfeedback.post.tsgleiche PrüfungenIkiformgleicher Post303 zurück?feedback=sentPOST+ Nutzer, Seite200POST+ Nutzer, SeiteWeiterleitung
  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
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
export default defineNuxtConfig({
  modules: ["nuxt-auth-utils"],
  runtimeConfig: {
    ikiformSecretKey: "",
  },
});
.env
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
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.

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.

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

HostWas zu tun ist
VercelNitro 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.
NetlifyWird ebenfalls erkannt. Füge beide Variablen in den Umgebungseinstellungen der Site mit dem Scope Functions hinzu; die Server-Route läuft als Funktion.
CloudflareBaue 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

ProblemWas zu tun ist
403 von deiner eigenen Route in ProduktionEin 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 unknownRoutennamen 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 leerDer 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 TestsDer Post ging ohne Origin raus. Behalte den Header Origin: own.origin und öffne die App unter localhost.
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.