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.
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:
| 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.
- Nach der Hydration stoppt die Komponente das native Absenden und sendet die Felder mit $fetch als JSON.
- Die Server-Route prüft den Origin, liest den Nutzer aus der Session, validiert die Bewertung und ergänzt die Seite aus dem Referer.
- Sie leitet die Antwort mit dem Secret Key aus runtimeConfig an Ikiform weiter.
- Ein JSON-Request bekommt { sent: true } oder einen Fehler mit Meldung, und das Formular aktualisiert sich an Ort und Stelle.
- Vor der Hydration sendet der Browser das Formular selbst. Die Route führt dieselben Prüfungen und denselben Post aus.
- Dann antwortet sie mit einem 303 zurück zur Seite, mit dem Ergebnis im Query-String, den die Komponente anzeigt.
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.
app/
components/FeedbackForm.vue
pages/projects/[id]/settings.vue
server/
api/feedback.post.ts
shared/
types/auth.d.ts
nuxt.config.ts
.envDeklariere 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.
export default defineNuxtConfig({
modules: ["nuxt-auth-utils"],
runtimeConfig: {
ikiformSecretKey: "",
},
});NUXT_IKIFORM_SECRET_KEY=ik_sk_your_secret_key
NUXT_SESSION_PASSWORD=at-least-32-characters-long-random-stringErstelle 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:
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.
6 den 422 zeigt, den die Route zurückbekäme.Bauen
Die Server-Route
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.tslässt die Datei nur aufPOSTantworten. - Die Origin-Prüfung läuft vor allem anderen.
readBody, dasreadValidatedBodynutzt, parst auch urlencoded Bodys, also könnte ohne sie eine andere Site ein einfaches Formular mit dem Cookie deines Nutzers an diese Route senden. getUserSessionwirft keine Exception. Die Route wählt die Antwort selbst: ein401für$fetch, eine Weiterleitung für einen einfachen Post.requireUserSessionwürde einem Nutzer ohne JavaScript eine401-Seite schicken.z.coerce.number()macht aus dem urlencoded"4"eine4..catch("unknown")speichert einen fehlerhaftenscreenalsunknown, statt die ganze Antwort abzulehnen.backbehält vomReferernur den Pfad, sodass eine Weiterleitung deine Site nie verlassen kann.
Die Komponente
<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>methodundactionsind 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 undscreenohne zusätzlichen Code mit. - Bei einem fehlgeschlagenen
$fetchistdata.messagedes Fehlers die Meldung auscreateErrorin der Route. - Nach einem einfachen Post setzt
?feedback=in der URL den Anfangszustand, sodass Dankeschön oder Fehler auf der neu geladenen Seite erscheinen. useUserSessionverbirgt 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
| 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
- Starte
nuxt dev, melde dich an, öffne die Einstellungsseite eines Projekts und sende eine Bewertung von 2 mit Kommentar. - Prüf in Submissions, dass
user_idundemailzu deinem Konto passen,screenprojects-id-settingsist undpageden echten Pfad enthält. - Schalte JavaScript aus und sende erneut. Die Seite lädt mit der Dankeszeile und
?feedback=sentin der Adresse neu. - Sende mit
curlund ohneOriginan/api/feedback. Die Route antwortet mit403, und nichts erreicht Ikiform. - 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
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.
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.
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.
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.