Anleitungen15 Min. Lesezeit
Feedback-Formular zu deiner Website oder App hinzufügen
Setze ein Feedback-Formular auf jede Seite oder in deine App: eine Bewertung, ein Kommentar, die Herkunftsseite, Spamfilter und Alerts bei niedrigen Werten.
Füge ein kurzes Formular mit einer Bewertung von 1 bis 5, einem optionalen Kommentar und einer optionalen E-Mail hinzu und sende es an ein Formular-Backend, damit du dafür keinen Server betreibst. Schick bei jeder Antwort die Seitenadresse mit, denn der Browser verrät dir die vollständige URL nicht von selbst. Leite niedrige Bewertungen noch am selben Tag in deinen Team-Chat und bewahre alle Antworten an einem Ort auf, um Trends zu sehen.
Die meisten Leute sagen dir nie, was mit deiner Site nicht stimmt. Sie gehen einfach. Ein kleines Feedback-Formular fängt die wenigen ab, die es sagen, in dem Moment, in dem sie es bemerken, auf der Seite, die es verursacht hat. Diese Anleitung baut eins, das zu jeder Site oder App passt, die Herkunftsseite mitsendet, Bots fernhält und unzufriedene Antworten noch am selben Tag vor dein Team bringt.
So funktioniert ein Feedback-Formular
Das Formular sendet an Ikiform. Ikiform validiert die Bewertung, prüft den Post auf Spam, speichert ihn, schickt deinem Team eine E-Mail und sendet einen signierten Webhook. Über den Webhook gehen niedrige Bewertungen in einen Chat-Channel, und jede Antwort kann in eine Tabelle oder Datenbank wandern.
- Ein Besucher wählt eine Bewertung, schreibt vielleicht einen Kommentar, und das Formular sendet beides mit der Seitenadresse.
- Ikiform prüft, ob die Bewertung eine ganze Zahl von 1 bis 5 ist, führt die Spamprüfungen aus und speichert die Antwort.
- Die Antwort erscheint im Dashboard, wo du sie filtern und als CSV exportieren kannst.
- Ikiform schickt deinem Team eine E-Mail. Hat der Besucher eine E-Mail hinterlassen, geht „Antworten“ direkt an ihn.
- Ikiform sendet einen signierten submission.created-Webhook mit den Feldern und den Browser-Metadaten.
- Ein kleines Relay schickt nur die niedrigen Bewertungen in deinen Team-Chat, damit sie heute noch gelesen werden.
Wege, Feedback zu sammeln
Jede Art von Tool ist für eine andere Aufgabe gebaut. Wähle danach, was du sehen musst, wenn eine Antwort ankommt, nicht nach dem Aussehen des Widgets.
| Ansatz | Stark bei | Nachteile |
|---|---|---|
| Website-Feedback-Tools wie Hotjar oder Usersnap | Screenshots, Anmerkungen und Seitenkontext, ohne Code außer einem Script-Tag. | Ein Drittanbieter-Skript auf jeder Seite, Antworten in deren Produkt gespeichert und Preise nach deren Tarifen. |
| Das Widget eines Error-Trackers wie Sentry User Feedback | Verknüpft einen Bericht mit dem Fehler und, bei aktivem Session Replay, mit einer Aufzeichnung des Geschehens. | Für Bugs gebaut, nicht für Meinungen. Du brauchst zuerst das SDK des Trackers in deiner App. |
| Formular-Baukästen wie Typeform, Tally oder Google Forms | Ausgefeilte Formulare ohne Code. Gut für eine Umfrage, die du per Link verschickst. | Ein iframe oder eine separate Seite, also verliert die Antwort die Seite, um die es ging, außer du übergibst sie in der URL. |
| Feedback-Boards wie Canny | Öffentliche Abstimmungen und Statusupdates zu Feature-Wünschen. | Für Ideen gebaut, nicht für schnelle Reaktionen. Antworten sind bewusst öffentlich. |
| Deine eigene API-Route und Datenbank | Volle Kontrolle über die Daten und jede Logik. | Spamfilter, Rate-Limits, Benachrichtigungs-E-Mails und Export baust du selbst, und du brauchst einen Server. |
| Ein Formular-Backend wie Ikiform | Dein eigenes Markup auf jeder Site oder App. Validierung, Spamfilter, Speicherung, E-Mail und signierte Webhooks ohne Server. | Keine Screenshots, weil Datei-Uploads nicht unterstützt werden, und keine Abstimmungen oder Session Replay. |
Brauchst du bei jedem Bericht Screenshots, lohnt sich das Skript eines Feedback-Tools oder Error-Trackers. Soll das Formular wie deine Site aussehen, sollen die Antworten portabel bleiben und willst du selbst entscheiden, wohin sie gehen, nimm ein Formular-Backend. Der Rest dieser Anleitung macht genau das.
Das Formular
Drei Fragen reichen: eine Bewertung, ein Kommentar und eine optionale E-Mail. Die Bewertung beantworten die Leute; im Kommentar steckt der Wert; über die E-Mail schließt du den Kreis. Wähle unten deinen Stack; die Auswahl gilt für jedes Codebeispiel in diesen Anleitungen.
Ein Block Markup und ein kurzes Skript. Ohne JavaScript sendet das Formular trotzdem und leitet weiter; mit JavaScript bleibt der Besucher auf der Seite und sieht den Fehler des Servers neben dem Button. Das Skript füllt page mit der Adresse ohne Query-String, damit Tokens in URLs nie in deinem Posteingang landen.
<form id="feedback" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
<fieldset>
<legend>How useful was this page?</legend>
<label><input type="radio" name="rating" value="1" required /> 1</label>
<label><input type="radio" name="rating" value="2" /> 2</label>
<label><input type="radio" name="rating" value="3" /> 3</label>
<label><input type="radio" name="rating" value="4" /> 4</label>
<label><input type="radio" name="rating" value="5" /> 5</label>
</fieldset>
<label for="comment">What would make it better?</label>
<textarea id="comment" name="comment" rows="4" maxlength="4000"></textarea>
<label for="feedback-email">Email, if you'd like a reply</label>
<input id="feedback-email" name="email" type="email" />
<input type="hidden" name="page" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<button>Send feedback</button>
<p id="feedback-status" role="status"></p>
</form>
<script>
const form = document.getElementById("feedback");
const status = document.getElementById("feedback-status");
form.elements.page.value = location.origin + location.pathname;
form.addEventListener("submit", async (event) => {
event.preventDefault();
const button = form.querySelector("button");
button.disabled = true;
status.textContent = "Sending";
try {
const response = await fetch(form.action, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});
const result = await response.json();
if (result.ok) {
form.replaceChildren(Object.assign(document.createElement("p"), { textContent: "Thanks. The team reads every note." }));
return;
}
const fields = result.error.fields ?? {};
status.textContent = fields.rating?.[0] ?? fields.comment?.[0] ?? fields.email?.[0] ?? result.error.message;
} catch {
status.textContent = "Couldn't send it. Try again in a moment.";
}
button.disabled = false;
});
</script>Die Server Action liest den Referer-Header ihres eigenen Posts. Formular und Action teilen sich einen Origin, also sendet der Browser die vollständige Seiten-URL, und die Action behält nur Origin und Pfad. Ein Hidden Input ist nicht nötig.
"use server";
import { headers } from "next/headers";
export interface FeedbackState {
sent: boolean;
error: string;
}
export async function sendFeedback(_previous: FeedbackState, formData: FormData): Promise<FeedbackState> {
const rating = Number(formData.get("rating"));
if (!Number.isInteger(rating) || rating < 1 || rating > 5) return { sent: false, error: "Pick a score from 1 to 5." };
const incoming = await headers();
const referer = incoming.get("referer");
const origin = incoming.get("origin");
const page = referer ? new URL(referer) : null;
const 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 ? { Origin: origin } : {}),
},
body: JSON.stringify({
rating,
comment: String(formData.get("comment") ?? ""),
email: String(formData.get("email") ?? ""),
page: page ? page.origin + page.pathname : "",
_gotcha: String(formData.get("_gotcha") ?? ""),
}),
});
const result = await response.json().catch(() => null);
if (result?.ok) return { sent: true, error: "" };
const fields = result?.error?.fields ?? {};
return { sent: false, error: fields.comment?.[0] ?? fields.email?.[0] ?? "Couldn't send it. Try again." };
}"use client";
import { useActionState } from "react";
import { type FeedbackState, sendFeedback } from "./actions";
const initial: FeedbackState = { sent: false, error: "" };
export function FeedbackForm() {
const [state, action, pending] = useActionState(sendFeedback, initial);
if (state.sent) return <p role="status">Thanks. The team reads every note.</p>;
return (
<form action={action}>
<fieldset>
<legend>How useful was this page?</legend>
{[1, 2, 3, 4, 5].map((score) => (
<label key={score}>
<input type="radio" name="rating" value={score} required /> {score}
</label>
))}
</fieldset>
<label htmlFor="comment">What would make it better?</label>
<textarea id="comment" name="comment" rows={4} maxLength={4000} />
<label htmlFor="feedback-email">Email, if you'd like a reply</label>
<input id="feedback-email" name="email" type="email" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{state.error ? <p role="alert">{state.error}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send feedback"}</button>
</form>
);
}Mehr zu Next.js? Lies Feedback-Formular in der App mit Next.js bauen.
Die Seite kennt beim Rendern ihre eigene Adresse, also füllt Astro.url den versteckten page-Input auf dem Server. Leere Inputs erreichen die Action als null, was Ikiform als fehlend behandelt. Die Seite muss on demand rendern, damit das Ergebnis der Action angezeigt wird.
import { ActionError, defineAction } from "astro:actions";
import { getSecret } from "astro:env/server";
import { z } from "astro/zod";
export const server = {
sendFeedback: defineAction({
accept: "form",
input: z.object({
rating: z.coerce.number().int().min(1).max(5),
comment: z.string().max(4000).nullish(),
email: z.email().nullish(),
page: z.string().nullish(),
_gotcha: z.string().nullish(),
}),
handler: async (input, context) => {
const origin = context.request.headers.get("origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${getSecret("IKIFORM_SECRET_KEY")}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify(input),
});
const result = await response.json();
if (!result.ok) throw new ActionError({ code: "BAD_REQUEST", message: result.error.message });
return { sent: true };
},
}),
};---
import { actions, isInputError } from "astro:actions";
const result = Astro.getActionResult(actions.sendFeedback);
const page = Astro.url.origin + Astro.url.pathname;
---
{result?.data?.sent ? (
<p role="status">Thanks. The team reads every note.</p>
) : (
<form method="POST" action={actions.sendFeedback}>
<fieldset>
<legend>How useful was this page?</legend>
{[1, 2, 3, 4, 5].map((score) => (
<label><input type="radio" name="rating" value={score} required /> {score}</label>
))}
</fieldset>
<label for="comment">What would make it better?</label>
<textarea id="comment" name="comment" rows="4" maxlength="4000"></textarea>
<label for="feedback-email">Email, if you'd like a reply</label>
<input id="feedback-email" name="email" type="email" />
<input type="hidden" name="page" value={page} />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{result?.error && (
<p role="alert">{isInputError(result.error) ? "Pick a score and check your email address." : result.error.message}</p>
)}
<button>Send feedback</button>
</form>
)}Mehr zu Astro? Lies Feedback-Formular in der App mit Astro bauen.
Eine POST-Server-Funktion prüft die Bewertung, bevor irgendetwas deinen Server verlässt. Die Komponente setzt page beim Absenden aus window.location, also stimmt der Wert auch nach clientseitiger Navigation.
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";
const sendFeedback = createServerFn({ method: "POST" })
.validator((data: unknown) => {
if (!(data instanceof FormData)) throw new Error("Expected form data");
const rating = Number(data.get("rating"));
if (!Number.isInteger(rating) || rating < 1 || rating > 5) throw new Error("Pick a score from 1 to 5.");
return {
rating,
comment: String(data.get("comment") ?? ""),
email: String(data.get("email") ?? ""),
page: String(data.get("page") ?? ""),
_gotcha: String(data.get("_gotcha") ?? ""),
};
})
.handler(async ({ data }) => {
const origin = getRequestHeader("origin");
const 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 ? { Origin: origin } : {}),
},
body: JSON.stringify(data),
});
const result = await response.json();
if (result.ok) return { sent: true, error: "" };
const fields = result.error.fields ?? {};
return { sent: false, error: String(fields.comment?.[0] ?? fields.email?.[0] ?? result.error.message) };
});
export function FeedbackForm() {
const send = useServerFn(sendFeedback);
const [state, setState] = useState({ sent: false, error: "" });
const [pending, setPending] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
data.set("page", window.location.origin + window.location.pathname);
setPending(true);
try {
setState(await send({ data }));
} catch (error) {
setState({ sent: false, error: error instanceof Error ? error.message : "Couldn't send it. Try again." });
} finally {
setPending(false);
}
}
if (state.sent) return <p role="status">Thanks. The team reads every note.</p>;
return (
<form method="post" onSubmit={onSubmit}>
<fieldset>
<legend>How useful was this page?</legend>
{[1, 2, 3, 4, 5].map((score) => (
<label key={score}>
<input type="radio" name="rating" value={score} required /> {score}
</label>
))}
</fieldset>
<label htmlFor="comment">What would make it better?</label>
<textarea id="comment" name="comment" rows={4} maxLength={4000} />
<label htmlFor="feedback-email">Email, if you'd like a reply</label>
<input id="feedback-email" name="email" type="email" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{state.error ? <p role="alert">{state.error}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send feedback"}</button>
</form>
);
}Mehr zu TanStack Start? Lies Feedback-Formular in der App mit TanStack Start bauen.
Eine Resource Route enthält die Action, und useFetcher sendet von jeder Seite aus ohne Navigation an sie. Registriere die Route als api/feedback in app/routes.ts. useLocation liefert den Pfad, und die Action löst ihn gegen ihre eigene URL auf, die den Origin deiner Site hat.
import { data } from "react-router";
import type { Route } from "./+types/api.feedback";
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const origin = request.headers.get("origin");
const 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 ? { Origin: origin } : {}),
},
body: JSON.stringify({
rating: String(formData.get("rating") ?? ""),
comment: String(formData.get("comment") ?? ""),
email: String(formData.get("email") ?? ""),
page: new URL(String(formData.get("path") ?? "/"), request.url).href,
_gotcha: String(formData.get("_gotcha") ?? ""),
}),
});
const result = await response.json();
if (result.ok) return { sent: true, error: "" };
const fields = result.error.fields ?? {};
return data(
{ sent: false, error: String(fields.rating?.[0] ?? fields.comment?.[0] ?? fields.email?.[0] ?? result.error.message) },
{ status: response.status },
);
}import { useFetcher, useLocation } from "react-router";
import type { action } from "../routes/api.feedback";
export function FeedbackForm() {
const fetcher = useFetcher<typeof action>();
const { pathname } = useLocation();
const pending = fetcher.state !== "idle";
if (fetcher.data?.sent) return <p role="status">Thanks. The team reads every note.</p>;
return (
<fetcher.Form method="post" action="/api/feedback">
<fieldset>
<legend>How useful was this page?</legend>
{[1, 2, 3, 4, 5].map((score) => (
<label key={score}>
<input type="radio" name="rating" value={score} required /> {score}
</label>
))}
</fieldset>
<label htmlFor="comment">What would make it better?</label>
<textarea id="comment" name="comment" rows={4} maxLength={4000} />
<label htmlFor="feedback-email">Email, if you'd like a reply</label>
<input id="feedback-email" name="email" type="email" />
<input type="hidden" name="path" value={pathname} />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{fetcher.data?.error ? <p role="alert">{fetcher.data.error}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send feedback"}</button>
</fetcher.Form>
);
}Mehr zu React Router? Lies Feedback-Formular in der App mit React Router bauen.
Ein +server.ts-Endpunkt leitet den Post weiter, sodass die Komponente in einem Layout sitzen und auf jeder Seite funktionieren kann. page aus $app/state liefert die aktuelle URL. Deklariere IKIFORM_SECRET_KEY in src/env.ts mit defineEnvVars, damit $app/env/private ihn lesen kann. Der Endpunkt gibt Response.json() zurück, da SvelteKit 3 den json()-Helper als veraltet markiert.
import { error } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ request, fetch, url }) => {
const body = await request.json();
const rating = Number(body.rating);
if (!Number.isInteger(rating) || rating < 1 || rating > 5) error(400, "Pick a score from 1 to 5.");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
Origin: request.headers.get("origin") ?? url.origin,
},
body: JSON.stringify({
rating,
comment: String(body.comment ?? ""),
email: String(body.email ?? ""),
page: String(body.page ?? ""),
_gotcha: String(body._gotcha ?? ""),
}),
});
const result = await response.json();
if (!result.ok) {
const fields = result.error.fields ?? {};
error(response.status, String(fields.comment?.[0] ?? fields.email?.[0] ?? result.error.message));
}
return Response.json({ sent: true });
};<script lang="ts">
import { page } from "$app/state";
let rating = $state(0);
let comment = $state("");
let email = $state("");
let trap = $state("");
let status = $state<"idle" | "sending" | "sent">("idle");
let message = $state("");
async function send(event: SubmitEvent) {
event.preventDefault();
status = "sending";
message = "";
const response = await fetch("/api/feedback", {
method: "POST",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({ rating, comment, email, page: page.url.origin + page.url.pathname, _gotcha: trap }),
});
if (response.ok) {
status = "sent";
return;
}
message = (await response.json().catch(() => null))?.message ?? "Couldn't send it. Try again.";
status = "idle";
}
</script>
{#if status === "sent"}
<p role="status">Thanks. The team reads every note.</p>
{:else}
<form onsubmit={send}>
<fieldset>
<legend>How useful was this page?</legend>
{#each [1, 2, 3, 4, 5] as score}
<label><input type="radio" name="rating" value={score} bind:group={rating} required /> {score}</label>
{/each}
</fieldset>
<label for="comment">What would make it better?</label>
<textarea id="comment" name="comment" rows="4" maxlength="4000" bind:value={comment}></textarea>
<label for="feedback-email">Email, if you'd like a reply</label>
<input id="feedback-email" name="email" type="email" bind:value={email} />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden bind:value={trap} />
{#if message}
<p role="alert">{message}</p>
{/if}
<button disabled={status === "sending"}>{status === "sending" ? "Sending" : "Send feedback"}</button>
</form>
{/if}Mehr zu SvelteKit? Lies Feedback-Formular in der App mit SvelteKit bauen.
Eine Server-Route hält den Key in runtimeConfig.ikiformSecretKey, befüllt aus NUXT_IKIFORM_SECRET_KEY. useRoute().path und useRequestURL() bauen die Seitenadresse auf dem Server wie im Browser.
export default defineEventHandler(async (event) => {
const body = await readBody<{ rating?: number; comment?: string; email?: string; page?: string; _gotcha?: string }>(event);
const config = useRuntimeConfig(event);
const origin = getRequestHeader(event, "origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${config.ikiformSecretKey}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({
rating: body.rating,
comment: body.comment ?? "",
email: body.email ?? "",
page: body.page ?? "",
_gotcha: body._gotcha ?? "",
}),
});
const result = await response.json();
if (!result.ok) {
const fields = result.error.fields ?? {};
throw createError({
status: response.status,
message: String(fields.rating?.[0] ?? fields.comment?.[0] ?? fields.email?.[0] ?? result.error.message),
});
}
return { sent: true };
});<script setup lang="ts">
const route = useRoute();
const requestUrl = useRequestURL();
const rating = ref<number | null>(null);
const comment = ref("");
const email = ref("");
const trap = ref("");
const status = ref<"idle" | "sending" | "sent">("idle");
const error = ref("");
async function send() {
status.value = "sending";
error.value = "";
try {
await $fetch("/api/feedback", {
method: "POST",
body: {
rating: rating.value,
comment: comment.value,
email: email.value,
page: requestUrl.origin + route.path,
_gotcha: trap.value,
},
});
status.value = "sent";
} catch (failure) {
error.value = (failure as { data?: { message?: string } }).data?.message ?? "Couldn't send it. Try again.";
status.value = "idle";
}
}
</script>
<template>
<p v-if="status === 'sent'" role="status">Thanks. The team reads every note.</p>
<form v-else @submit.prevent="send">
<fieldset>
<legend>How useful was this page?</legend>
<label v-for="score in 5" :key="score">
<input v-model="rating" type="radio" name="rating" :value="score" required /> {{ score }}
</label>
</fieldset>
<label for="comment">What would make it better?</label>
<textarea id="comment" v-model="comment" name="comment" rows="4" maxlength="4000" />
<label for="feedback-email">Email, if you'd like a reply</label>
<input id="feedback-email" v-model="email" name="email" type="email" />
<input v-model="trap" 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>Mehr zu Nuxt? Lies Feedback-Formular in der App mit Nuxt bauen.
Ohne eigenen Server sendet der Browser direkt an Ikiform. submit() aus @ikiform/sdk sendet die FormData und gibt { ok } oder die Feldfehler zurück. Setze hier nie einen Secret Key ein; stattdessen schützt Allowed domains das Formular.
import { submit } from "@ikiform/sdk";
import { type FormEvent, useState } from "react";
const FORM_ID = "frm_xxxxxxxxxxxxxxxx";
export function FeedbackForm() {
const [status, setStatus] = useState<"idle" | "sending" | "sent">("idle");
const [error, setError] = useState("");
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
data.set("page", window.location.origin + window.location.pathname);
setStatus("sending");
setError("");
const result = await submit(FORM_ID, data);
if (result.ok) {
setStatus("sent");
return;
}
const fields = result.error.fields ?? {};
setError(fields.rating?.[0] ?? fields.comment?.[0] ?? fields.email?.[0] ?? result.error.message);
setStatus("idle");
}
if (status === "sent") return <p role="status">Thanks. The team reads every note.</p>;
return (
<form onSubmit={onSubmit}>
<fieldset>
<legend>How useful was this page?</legend>
{[1, 2, 3, 4, 5].map((score) => (
<label key={score}>
<input type="radio" name="rating" value={score} required /> {score}
</label>
))}
</fieldset>
<label htmlFor="comment">What would make it better?</label>
<textarea id="comment" name="comment" rows={4} maxLength={4000} />
<label htmlFor="feedback-email">Email, if you'd like a reply</label>
<input id="feedback-email" name="email" type="email" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{error ? <p role="alert">{error}</p> : null}
<button disabled={status === "sending"}>{status === "sending" ? "Sending" : "Send feedback"}</button>
</form>
);
}Derselbe browserseitige Post in einer Single-File-Komponente. Das Label des Kommentars ändert sich bei einer Bewertung von 1 oder 2, was von unzufriedenen Besuchern konkretere Antworten bringt.
<script setup lang="ts">
import { ref } from "vue";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const rating = ref(0);
const status = ref<"idle" | "sending" | "sent">("idle");
const error = ref("");
async function send(event: SubmitEvent) {
const body = new FormData(event.target as HTMLFormElement);
body.set("page", window.location.origin + window.location.pathname);
status.value = "sending";
error.value = "";
try {
const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
const result = await response.json();
if (result.ok) {
status.value = "sent";
return;
}
const fields = result.error.fields ?? {};
error.value = fields.rating?.[0] ?? fields.comment?.[0] ?? fields.email?.[0] ?? result.error.message;
} catch {
error.value = "Couldn't send it. Try again in a moment.";
}
status.value = "idle";
}
</script>
<template>
<p v-if="status === 'sent'" role="status">Thanks. The team reads every note.</p>
<form v-else :action="ENDPOINT" method="post" @submit.prevent="send">
<fieldset>
<legend>How useful was this page?</legend>
<label v-for="score in 5" :key="score">
<input v-model="rating" type="radio" name="rating" :value="score" required /> {{ score }}
</label>
</fieldset>
<label for="comment">{{ rating && rating <= 2 ? "What went wrong?" : "What would make it better?" }}</label>
<textarea id="comment" name="comment" rows="4" maxlength="4000" />
<label for="feedback-email">Email, if you'd like a reply</label>
<input id="feedback-email" name="email" type="email" />
<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>- Ersetze
frm_xxxxxxxxxxxxxxxxdurch die ID deines Formulars aus dem Dashboard. ratingist eine Radio-Gruppe mit fünf Werten. Ikiform speichert sie als Zahl, also brauchen Durchschnitte kein Parsing.emailist optional. Leute schreiben ehrlicher, wenn sie sich nicht zu erkennen geben müssen, und wer eine Antwort will, hinterlässt sie.- Der versteckte
_gotcha-Input ist der Honeypot. Menschen sehen ihn nie; Bots, die jeden Input ausfüllen, markieren sich selbst als Spam. pagehält fest, um welche Seite es in der Antwort geht. Der nächste Abschnitt erklärt, warum du sie selbst sendest.
IKIFORM_SECRET_KEY in reinen Server-Umgebungsvariablen.Die Framework-Anleitungen in der Doku behandeln das Setup ausführlicher: Next.js, Astro, SvelteKit, Nuxt, React, Vue und reines HTML. Die Feedback-Formular-Vorlage erzeugt dieselben Felder als HTML, React und Server Action; die Vorlagen für Bug-Reports und Umfragen decken die längeren Formulare ab.
Felder definieren
Füge eine Definition im Tab Fields des Formulars ein, und Ikiform setzt sie bei jedem Post durch, von jeder Seite. Probier es unten aus: Die Vorschau führt den Validator von Ikiform in deinem Browser aus, sodass eine Bewertung von 0 oder eine fehlerhafte E-Mail den 422 zeigt, den du zurückbekommen würdest.
Vorschau
Führt den Validator von Ikiform in deinem Browser aus. Es wird nichts gesendet.
rating nimmt eine ganze Zahl von 1 bis zu seinem max, hier 5. Es geht bis 10, aber fünf Optionen sind schneller beantwortet.| Feld | Typ | Warum |
|---|---|---|
rating | rating, max. 5 | Ein Klick. Als Zahl gespeichert, sodass du pro Seite einen Durchschnitt bilden kannst |
comment | textarea, bis zu 4.000 Zeichen | Der Grund hinter der Bewertung |
email | email, optional | Antworte Leuten, die eine Frage stellen. Wird zur Reply-To-Adresse der Benachrichtigung |
page | url, optional | Um welche Seite es in der Antwort geht |
Sind Felder definiert, wird jeder andere Name im Post als Unknown field abgelehnt. Wenn du später mehr Kontext sendest, etwa eine App-Version oder einen Tarifnamen, füge zuerst ein Feld dafür hinzu. Feld-IDs sind in Kleinbuchstaben und snake_case.
Wissen, um welche Seite es geht
Eine Bewertung ohne Seite ist schwer umzusetzen. Ikiform speichert bei jeder Einsendung die Metadaten des Browsers, aber einer dieser Werte ist weniger nützlich, als er aussieht:
| Wert | Woher er kommt | Was du bekommst |
|---|---|---|
meta.user_agent | Von Ikiform erfasst | Browser und Betriebssystem, bei einem Post aus dem Browser |
meta.origin | Von Ikiform erfasst | Der Origin deiner Site, etwa https://example.com |
meta.referrer | Von Ikiform erfasst | Meist nur der Origin. Browser nutzen standardmäßig die Policy strict-origin-when-cross-origin, die den Pfad beim Senden an eine andere Site entfernt |
meta.country | Von Ikiform erfasst | Ein Ländercode aus zwei Buchstaben |
page | Von deinem Formular gesendet | Der vollständige Pfad, damit du weißt, um welche Seite es in der Antwort geht |
Deshalb sendet jedes Beispiel oben page selbst. Die HTML- und SPA-Versionen lesen location; die Server-Versionen lesen die Adresse, die ihr Framework schon kennt. Alle entfernen den Query-String, der Suchbegriffe, Tracking-IDs oder Einmal-Tokens enthalten kann, die du nicht speichern willst.
meta.user_agent die Runtime deines Servers, nicht der Browser des Besuchers. Für Seiten-Feedback spielt das selten eine Rolle. Für Bug-Reports schon, und die Anleitung für Bug-Reports leitet den User-Agent des Browsers als Feld weiter.Wo du fragst
Wo das Formular sitzt, entscheidet, was du hörst. Ein Formular im Footer jeder Seite bringt ein Rinnsal starker Meinungen. Eine Frage am Ende einer Aufgabe bringt stetige, konkrete Antworten.
| Platzierung | Gut für | Achte auf |
|---|---|---|
| Ende einer Doku-Seite oder eines Artikels | Seiten finden, die die Frage nicht beantworten | Wenig Antworten pro Seite. Werte pro Abschnitt aus, nicht pro Seite |
| Nach einer Aufgabe in deiner App, etwa einem Export oder einem ersten Projekt | Wie sich ein bestimmter Ablauf anfühlt, solange er frisch ist | Frag einmal pro Person, nicht jedes Mal, wenn sie die Aufgabe abschließt |
| Ein schwebender Button auf jeder Ansicht | Alles, jederzeit. Siehe das React-Widget | Er darf deine eigenen Buttons, Cookie-Banner oder Chat-Bubbles nicht verdecken |
| Ein Link in einer E-Mail | Werte, die du über die Zeit verfolgen willst, wie NPS | Der E-Mail-Client ist nicht deine Seite, also braucht das Formular eine Landingpage auf deiner Site |
Halte das Formular klein, egal wo es steht. Frag zuerst nach der Bewertung und zeig das Kommentarfeld gleich mit, nicht in einem zweiten Schritt. Ein zweiter Schritt verliert die meisten Leute, und eine Bewertung ohne Grund ist trotzdem etwas wert.
Niedrige Bewertungen vor die richtigen Leute bringen
- Jede Antwort wird gespeichert, unabhängig von ihrer Bewertung und der Seite, von der sie kam.
- Ikiform sendet einen signierten submission.created-Webhook an dein Relay.
- Das Relay liest die Bewertung. Werte von 3 bis 5 brauchen vorerst nichts weiter.
- Eine Bewertung von 1 oder 2 geht mit Seite und Kommentar in deinen Team-Chat.
- Hat der Besucher eine E-Mail hinterlassen, antwortet jemand noch am selben Tag.
- Einmal pro Woche zeigt der Export Durchschnitte pro Seite, und dort werden Trends sichtbar.
Benachrichtigungs-E-Mails gehen an bis zu 10 bestätigte Empfänger pro Formular und kommen bei jeder Antwort. Bei ein paar Antworten am Tag ist das in Ordnung. Darüber hinaus schick die niedrigen Bewertungen mit einem Relay wie dieser Next.js-Route in den Chat; derselbe Code läuft in einem Cloudflare Worker:
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
export async function POST(request: Request) {
const payload = await request.text();
try {
const event = await verifyWebhook({
payload,
headers: request.headers,
secret: process.env.IKIFORM_WEBHOOK_SECRET!,
});
if (event.type !== "submission.created") return new Response(null, { status: 204 });
const { rating, comment, page, email } = event.data.fields;
if (Number(rating) > 2) return new Response(null, { status: 204 });
const lines = [
`*${rating}/5* on ${page ?? event.data.meta.origin ?? "an unknown page"}`,
comment ? `> ${String(comment).slice(0, 500)}` : "_No comment._",
email ? `Reply to ${email}` : "Anonymous",
];
const response = await fetch(process.env.SLACK_WEBHOOK_URL!, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text: lines.join("\n") }),
});
if (!response.ok) return new Response(`Slack answered ${response.status}`, { status: 502 });
return new Response(null, { status: 204 });
} catch (error) {
if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
throw error;
}
}verifyWebhookprüft Signatur und Zeitstempel, damit niemand sonst über diese URL in deinen Channel posten kann.- Bewertungen über 2 antworten mit
204und hören auf. Ikiform wertet jeden 2xx als zugestellt. - Ein fehlgeschlagener Slack-Aufruf antwortet mit
502, und Ikiform versucht es mit derselbenwebhook-iderneut, sodass ein kurzer Ausfall keinen Alert verliert. - Feldwerte sind das, was der Besucher eingetippt hat. Das Relay kürzt den Kommentar auf 500 Zeichen, bevor er im Chat landet.
Füge den Endpunkt unter Webhooks hinzu und kopiere sein whsec_-Secret in IKIFORM_WEBHOOK_SECRET. Ein Workspace kann 3 Endpunkte haben. Für No-Code-Wege siehe Slack, Discord und Google Sheets. Für Feedback, das zu einem Ticket werden soll, hat Linear und GitHub Issues ein Relay, das pro Einsendung eins anlegt.
Bots fernhalten
Ein Feedback-Formular auf jeder Seite ist ein öffentliches Formular auf jeder Seite. Bots finden es, und das meiste, was sie senden, sind Links. Die Standardeinstellungen von Ikiform erledigen den Großteil:
| Abwehr | Was sie stoppt | Einrichtung |
|---|---|---|
| Honeypot | Bots, die jedes Eingabefeld ausfüllen | Der versteckte _gotcha-Input in den Beispielen |
| Spam-Bewertung | Drei oder mehr Links (+2), bekannte Spam-Phrasen (+2), Wegwerf-E-Mails (+2), identische Wiederholungen (+3). Ab 5 wird es als Spam gespeichert | Keine |
| Rate-Limits | Skripte, die in einer Schleife senden | Standardmäßig aktiv: Standard, 5 pro Minute pro Besucher, und 300 pro Minute für das ganze Formular |
| Erlaubte Domains | Posts von anderen Sites | Füge deine Domain in den Einstellungen des Formulars hinzu |
| Menschlichkeitsprüfung | Bots, die einen echten Browser ausführen | Setze Spam check auf Ikiform. Posts per fetch aus dem Browser brauchen dann ein Token von challenge() |
Spam bekommt dieselbe Erfolgsantwort wie ein echter Post, löst weder E-Mail noch Webhook aus und zählt nicht zur Nutzung. Ein echter Besucher, der drei Links in einen Kommentar einfügt, kann zusammen mit einer Wiederholung die Link-Regel auslösen, also schau ab und zu in den Filter Spam. Markierst du eine Antwort als kein Spam, wird ihr Webhook dann gesendet. Die Spam-Doku erklärt jede Prüfung.
Die Antworten lesen
Lies in den ersten Wochen jeden Kommentar. Danach ist die nützliche Ansicht die Durchschnittsbewertung pro Seite, die niedrigste zuerst, mit den Kommentaren daneben.
Exportieren
Öffne Submissions, wähle das Feedback-Formular, einen Zeitraum und Export CSV. Die Spalten sind id, created_at, form und status, dann eine pro Feld, mit den Feldern oben ist rating also Spalte E und page Spalte H. Ein Export umfasst bis zu 100.000 Zeilen; die Export-Anleitung behandelt die API für mehr.
Importiere die Datei in Google Sheets und füge das hier in ein leeres Tabellenblatt ein. Es listet jede Seite mit ihrer Durchschnittsbewertung und der Zahl der Antworten auf, die schlechteste zuerst:
=QUERY(A2:H, "select H, avg(E), count(E) where D = 'received' and E is not null group by H order by avg(E) asc label avg(E) 'average', count(E) 'answers'", 0)- Ignoriere Seiten mit nur einer oder zwei Antworten. Ein verärgerter Besucher ist kein Trend.
- Lies die Kommentare zu den drei schlechtesten Seiten. Die Lösung steckt meist in den ersten paar.
- Wenn du eine Seite änderst, vergleiche ihren Durchschnitt der Wochen davor und danach, nicht den Gesamtwert.
Wähle deinen Fall
Das Formular oben deckt schnelle Reaktionen auf jeder Seite ab. Diese Seiten behandeln Feedback mit einer anderen Aufgabe: einen Bug, der reproduziert werden soll, einen Wunsch, der abgewogen werden soll, einen Wert, der verfolgt werden soll, oder einen Kunden, der geht.
- Formular für Bug-ReportsEin 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ünscheEin 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.
- Feedback-Widget für ReactEin 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 von Beta-TesternEin Beta-Feedback-Formular mit einem persönlichen Link pro Tester und Build, zwei Bewertungsskalen und einer Wochenübersicht, die zeigt, ob jeder Build besser wurde.
- NPS-UmfrageFühre eine NPS-Umfrage mit einem Wert von 0 bis 10 in der E-Mail durch, mit einer Folgefrage auf deiner eigenen Seite, die sich mit dem Wert ändert, und einem Skript, das den NPS berechnet.
- KündigungsumfrageFrag im Kündigungsablauf deines SaaS, warum Kunden kündigen, halte Überspringen einen Klick entfernt, speichere den Grund in Stripe und Ikiform und verzögere die Kündigung nie.
- Feedback-Formular mit Next.jsEin 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.
- Feedback-Formular mit AstroEin 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 TanStack StartEin 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.
- Feedback-Formular mit React RouterEin 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 SvelteKitEin 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 NuxtEin 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.
Prüfen, ob es funktioniert
Geh das durch, bevor du das Formular auf jede Seite setzt. Ikiform hat keinen kostenlosen Tarif. Testen ist kostenlos: Ein Workspace ohne Tarif nimmt 50 Testeinsendungen pro Monat von localhost an. Für den Livebetrieb wähle Pro ab $6/Monat für 500 Einsendungen oder zahle einmalig: 10.000 Einsendungen für $49 oder 50.000 Einsendungen für $149, mit Guthaben, das nie verfällt. Siehe Preise.
- Füge unter Allowed domains des Formulars die Domain deiner Site hinzu, plus
localhost, während du entwickelst. Lass das Feld leer, um Posts von überall anzunehmen. - Wähle eine Bewertung, schreib einen Kommentar und sende ab. Die Dankeszeile ersetzt das Formular, oder der Browser landet bei der reinen HTML-Version auf deiner Weiterleitungsseite.
- Öffne Submissions im Dashboard. Die Antwort zeigt
ratingals Zahl undpagemit dem vollständigen Pfad. - Sende eine Bewertung von
6mitcurloder aus der Browser-Konsole. Ikiform antwortet mit422und nenntrating. - Prüfe das Postfach jedes Benachrichtigungsempfängers. Jeder bekommt eine E-Mail mit den Feldern, und Reply geht an den Absender, wenn das Formular ein
email-Feld hat. - Sende eine Bewertung von 1. Dein Relay postet sie in den Team-Chat, und die Zustellung unter Webhooks zeigt
succeeded. - Sende einen Post mit ausgefülltem Honeypot,
_gotcha=bot. Er bekommt trotzdem eine Erfolgsantwort, aber die Einsendung wird als Spam gespeichert und löst weder E-Mail noch Webhook aus. - Nichts angekommen? Stell den Statusfilter im Dashboard auf Spam. Spam bekommt dieselbe Erfolgsantwort wie eine echte Einsendung.
Fehlerbehebung
| Problem | Was zu tun ist |
|---|---|
403 origin_not_allowed, oder jeder Post landet im Spam | Die Domain der Seite steht nicht unter Allowed domains des Formulars. Füge sie hinzu, plus localhost für lokale Tests. Subdomains einer eingetragenen Domain sind erlaubt. |
422 mit Rating must be at most 5 | Das Formular sendet eine höhere Bewertung als das max des Felds. Ändere die Radio-Werte oder erhöhe max in der Definition, bis maximal 10. |
422 mit einem Unknown field | Das Formular sendet einen Namen, der nicht in der Definition steht, oft ein neuer Hidden Input. Füge ein Feld mit dieser ID hinzu oder entferne den Input. |
page ist leer | Das Skript, das ihn füllt, lief nicht oder lief, bevor das Formular existierte. Setze es hinter das Formular oder setze page beim Absenden, wie es die SPA-Beispiele tun. |
meta.referrer zeigt nur deine Domain | Erwartet. Browser senden bei Cross-Site-Posts standardmäßig nur den Origin. Nutze stattdessen das Feld page. |
| Die Einsendung ist im Dashboard, aber keine E-Mail und kein Webhook kam an | Sie wurde als Spam gespeichert. Spam löst nichts aus. Öffne sie unter dem Filter Spam, um die Gründe zu sehen, und markiere sie als kein Spam, um ihre E-Mail und Webhooks zu senden. |
429 rate_limited | Ein Besucher hat mehr gesendet, als das Rate-Limit des Formulars erlaubt. Standard erlaubt 5 pro Minute und Besucher. Warte Retry-After ab oder wähle Relaxed oder Custom für Formulare, die von einer gemeinsamen IP aus senden. |
402 test_limit_reached | Der Workspace hat keinen Tarif und hat seine 50 kostenlosen localhost-Tests diesen Monat aufgebraucht. Wähle einen Tarif oder nutze Send test im Dashboard. |
Fragen
Richte die action des Formulars auf den Endpunkt eines Formular-Backends aus. Das Formular kann reines HTML auf einer statischen Site sein; das Backend validiert, speichert und mailt jede Antwort und sendet einen Webhook, wenn du sie woanders haben willst.
Sende die Seitenadresse als Feld. Ikiform speichert auch den Referrer, aber Browser senden bei einem Cross-Site-Post meist nur deine Domain, also ist ein Seitenfeld, das aus location befüllt wird, der verlässliche Weg.
Mach die E-Mail optional. Ohne sie sind Leute offener, und wer eine Antwort will, hinterlässt sie. Ikiform speichert trotzdem bei jeder Antwort Browser, Origin, Referrer und Land.
Nicht als Datei. Ikiform lehnt Posts mit Dateien mit 422 files_not_supported ab. Bitte stattdessen um einen Link zu einem Screenshot oder nutze ein Feedback-Tool, das auf Screenshots ausgelegt ist, wenn du bei jedem Bericht einen brauchst.
Nicht mit einem rating-Feld, das von 1 bis höchstens 10 reicht. Ein Wert von 0 bis 10 nutzt ein number-Feld mit min 0, max 10 und integer true. Die NPS-Anleitung zeigt das vollständige Setup.
Füge einen Webhook zu einem kleinen Relay hinzu, das die Bewertung prüft und nur niedrige Werte an Slack oder Discord postet. Benachrichtigungs-E-Mails decken jede Antwort ab; das Relay deckt die ab, die heute eine Reaktion brauchen.
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.