Guides16 min read
Add a feedback form to your website or app
Put a feedback form on any page or inside your app: a rating, a comment, the page it came from, spam filtering, and alerts when a score is low.
Add a short form with a 1 to 5 rating, an optional comment and an optional email, and post it to a form backend so you don't run a server for it. Send the page address with every answer, because the browser won't tell you the full URL on its own. Route low scores to your team chat the same day and keep every answer in one place for trends.
Most people never tell you what's wrong with your site. They leave. A small feedback form catches the few who will say it, at the moment they notice, on the page that caused it. This guide builds one that fits any site or app, sends the page it came from, keeps bots out, and puts unhappy answers in front of your team the same day.
How a feedback form works
The form posts to Ikiform. Ikiform validates the score, checks the post for spam, stores it, emails your team, and sends a signed webhook. From the webhook, low scores go to a chat channel and every answer can go to a sheet or database.
- A visitor picks a score, maybe writes a comment, and the form posts it with the page address.
- Ikiform checks the score is a whole number from 1 to 5, runs the spam checks and stores the answer.
- The answer shows up in the dashboard, where you can filter it and export it as CSV.
- Ikiform emails your team. When the visitor left an email, Reply goes straight to them.
- Ikiform sends a signed submission.created webhook with the fields and the browser metadata.
- A small relay posts only the low scores to your team chat, so they get read today.
Ways to collect feedback
Each kind of tool is built for a different job. Pick by what you need to see when an answer arrives, not by the widget's looks.
| Approach | Good at | Trade-offs |
|---|---|---|
| Website feedback tools, such as Hotjar or Usersnap | Screenshots, annotations and page context with no code beyond a script tag. | A third-party script on every page, answers stored in their product, and pricing set by their plans. |
| An error tracker's widget, such as Sentry User Feedback | Ties a report to the error and, with session replay on, to a recording of what happened. | Built for bugs, not opinions. You need the tracker's SDK in your app first. |
| Form builders, such as Typeform, Tally or Google Forms | Polished forms with no code. Good for a survey you send by link. | An iframe or a separate page, so the answer loses the page it was about unless you pass it in the URL. |
| Feedback boards, such as Canny | Public voting and status updates on feature requests. | Built for ideas, not quick reactions. Answers are public by design. |
| Your own API route and database | Full control over the data and any logic. | You build spam filtering, rate limits, notification email and export yourself, and you need a server. |
| A form backend, such as Ikiform | Your own markup on any site or app. Validation, spam filtering, storage, email and signed webhooks with no server. | No screenshots, because file uploads aren't supported, and no voting or session replay. |
If you need screenshots on every report, a feedback tool or an error tracker earns its script. If you want the form to look like your site, keep answers portable and decide yourself where they go, use a form backend. The rest of this guide does that.
The form
Three questions are enough: a score, a comment and an optional email. The score is the one people answer; the comment is where the value is; the email is how you close the loop. Pick your stack below; the choice carries over to every code sample in these guides.
One block of markup and a short script. Without JavaScript the form still posts and redirects; with it, the visitor stays on the page and sees the server's error next to the button. The script fills page with the address minus any query string, so tokens in URLs never reach your inbox.
<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>The server action reads the Referer header of its own post. The form and the action share an origin, so the browser sends the full page URL, and the action keeps only the origin and path. No hidden input is needed.
"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>
);
}Going further with Next.js? Read Build an in-app feedback form in Next.js.
The page knows its own address while it renders, so Astro.url fills the hidden page input on the server. Empty inputs reach the action as null, which Ikiform treats as missing. The page must render on demand for the action result to show.
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>
)}Going further with Astro? Read Build an in-app feedback form in Astro.
A POST server function checks the score before anything leaves your server. The component sets page from window.location at submit time, so it is right even after client-side 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>
);
}Going further with TanStack Start? Read Build an in-app feedback form in TanStack Start.
A resource route holds the action, and useFetcher posts to it from any page without a navigation. Register the route as api/feedback in app/routes.ts. useLocation supplies the path, and the action resolves it against its own URL, which has your site's origin.
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>
);
}Going further with React Router? Read Build an in-app feedback form with React Router.
A +server.ts endpoint forwards the post, so the component can sit in a layout and work on every page. page from $app/state gives the current URL. Declare IKIFORM_SECRET_KEY in src/env.ts with defineEnvVars so $app/env/private can read it. The endpoint returns Response.json(), since SvelteKit 3 deprecates the json() helper.
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}Going further with SvelteKit? Read Build an in-app feedback form in SvelteKit.
A server route keeps the key in runtimeConfig.ikiformSecretKey, filled from NUXT_IKIFORM_SECRET_KEY. useRoute().path and useRequestURL() build the page address on the server and in the browser alike.
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>Going further with Nuxt? Read Build an in-app feedback form in Nuxt.
With no server of your own, the browser posts straight to Ikiform. submit() from @ikiform/sdk sends the FormData and returns { ok } or the field errors. Never put a secret key here; Allowed domains protects the form instead.
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>
);
}The same browser-side post in a single-file component. The comment label changes when the score is 1 or 2, which gets more specific answers from unhappy visitors.
<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>- Replace
frm_xxxxxxxxxxxxxxxxwith your form's id from the dashboard. ratingis a radio group of five values. Ikiform stores it as a number, so averages need no parsing.emailis optional. People write more honestly when they don't have to identify themselves, and the ones who want an answer will leave it.- The hidden
_gotchainput is the honeypot. People never see it; bots that fill every input mark themselves as spam. pagerecords which page the answer is about. The next section explains why you send it yourself.
IKIFORM_SECRET_KEY in server-only environment variables.Framework guides in the docs cover setup in more depth: Next.js, Astro, SvelteKit, Nuxt, React, Vue and plain HTML. The feedback form template generates the same fields as HTML, React and a server action; the bug report and survey templates cover the longer forms.
Define the fields
Paste a definition on the form's Fields tab and Ikiform enforces it on every post, from any page. Try it below: the preview runs Ikiform's validator in your browser, so a score of 0 or a malformed email shows the 422 you'd get back.
Preview
Runs Ikiform's validator in your browser. Nothing is sent.
rating takes a whole number from 1 to its max, here 5. It can go up to 10, but five choices are quicker to answer.| Field | Type | Why |
|---|---|---|
rating | rating, max 5 | One click. Stored as a number, so you can average it per page |
comment | textarea, up to 4,000 characters | The reason behind the score |
email | email, optional | Reply to people who ask a question. Becomes the reply-to of the notification |
page | url, optional | Which page the answer is about |
With fields defined, any other name in the post is refused as Unknown field. If you later send more context, such as an app version or a plan name, add a field for it first. Field ids are lowercase snake_case.
Know which page it's about
A score without a page is hard to act on. Ikiform records the browser's metadata with every submission, but one of those values is less useful than it looks:
| Value | Where it comes from | What you get |
|---|---|---|
meta.user_agent | Recorded by Ikiform | The browser and operating system, from a browser post |
meta.origin | Recorded by Ikiform | Your site's origin, such as https://example.com |
meta.referrer | Recorded by Ikiform | Usually only the origin. Browsers default to the strict-origin-when-cross-origin policy, which drops the path when posting to another site |
meta.country | Recorded by Ikiform | A two-letter country code |
page | Sent by your form | The full path, so you know which page the answer is about |
That's why every sample above sends page itself. The HTML and SPA versions read location; the server versions read the address their own framework already knows. They all drop the query string, which can hold search terms, tracking ids or one-time tokens you don't want stored.
meta.user_agent is your server's runtime, not the visitor's browser. For page feedback that rarely matters. For bug reports it does, and the bug report guide forwards the browser's user agent as a field.Where to ask
Where the form sits decides what you hear. A form in the footer of every page gets a trickle of strong opinions. A question at the end of a task gets steady, specific answers.
| Placement | Good for | Watch out for |
|---|---|---|
| End of a docs page or article | Finding pages that don't answer the question | Low volume per page. Read it per section, not per page |
| After a task in your app, such as an export or a first project | How a specific flow feels, while it's fresh | Ask once per person, not every time they finish the task |
| A floating button on every screen | Anything, any time. See the React widget | It must not cover your own buttons, cookie banners or chat bubbles |
| A link in an email | Scores you want to track over time, like NPS | The email client isn't your page, so the form needs a landing page on your site |
Keep the form small wherever it goes. Ask the score first and show the comment box with it, not on a second step. A second step loses most people, and a score without a reason is still worth having.
Get low scores in front of people
- Every answer is stored, whatever its score and whichever page it came from.
- Ikiform sends a signed submission.created webhook to your relay.
- The relay reads the rating. Scores of 3 to 5 need nothing more right away.
- A score of 1 or 2 goes to your team chat with the page and the comment.
- If the visitor left an email, someone replies the same day.
- Once a week, the export shows averages per page, which is where trends show up.
Notification emails go to up to 10 confirmed recipients per form and arrive for every answer. That's fine for a few answers a day. Past that, send the low scores to chat with a relay like this Next.js route; the same code runs in a 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;
}
}verifyWebhookchecks the signature and the timestamp, so nobody else can post to your channel through this URL.- Scores above 2 answer
204and stop. Ikiform treats any 2xx as delivered. - A failed Slack call answers
502, and Ikiform retries with the samewebhook-id, so a short outage doesn't lose an alert. - Field values are what the visitor typed. The relay cuts the comment to 500 characters before it reaches chat.
Add the endpoint under Webhooks and copy its whsec_ secret into IKIFORM_WEBHOOK_SECRET. A workspace can have 3 endpoints. For no-code routes, see Slack, Discord and Google Sheets. For feedback that should become a ticket, Linear and GitHub Issues has a relay that creates one per submission.
Keep bots out
A feedback form on every page is a public form on every page. Bots find it, and most of what they send is links. Ikiform's defaults handle the bulk of it:
| Defence | What it stops | Setup |
|---|---|---|
| Honeypot | Bots that fill every input | The hidden _gotcha input in the samples |
| Spam scoring | Three or more links (+2), known spam phrases (+2), disposable emails (+2), identical repeats (+3). At 5 it's stored as spam | None |
| Rate limits | Scripts that post in a loop | On by default: Standard, 5 a minute per visitor, and 300 a minute for the whole form |
| Allowed domains | Posts from other sites | Add your domain on the form's settings |
| Human check | Bots that run a real browser | Set Spam check to Ikiform. Browser fetch posts then need a token from challenge() |
Spam gets the same success answer as a real post, sends no email or webhook, and doesn't count toward usage. A real visitor who pastes three links into a comment can trip the link rule together with a repeat, so look at the Spam filter now and then. Marking an answer as not spam sends its webhook then. The spam docs explain each check.
Read the answers
Read every comment in the first weeks. After that, the useful view is the average score per page, lowest first, with the comments next to it.
Export
Open Submissions, pick the feedback form, choose a date range and Export CSV. Columns are id, created_at, form and status, then one per field, so with the fields above rating is column E and page is column H. One export holds up to 100,000 rows; the export guide covers the API for more.
Import the file into Google Sheets and put this in an empty sheet. It lists each page with its average score and the number of answers, worst first:
=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)- Ignore pages with only one or two answers. One angry visitor isn't a trend.
- Read the comments on the three lowest pages. The fix is usually in the first few.
- After you change a page, compare its average for the weeks before and after, not the all-time number.
Pick your situation
The form above covers quick reactions on any page. These pages cover feedback with a different job: a bug to reproduce, a request to weigh, a score to track or a customer on the way out.
- Bug report formA bug report form that asks people for steps and severity, and lets the page add the URL, browser, viewport and recent errors on its own.
- Feature request formA feature request form that asks for the problem instead of the solution, stores requests by theme, counts people not rows, and emails them when it ships.
- React feedback widgetA floating feedback button for a React app that opens a small panel, sends the screen, app version and account with each answer, and never loses a draft.
- Beta tester feedbackA beta feedback form with a personal link per tester and build, two rating scales and a weekly summary that shows whether each build got better.
- NPS surveyRun an NPS survey with a 0 to 10 score in the email, a follow-up question on your own page that changes with the score, and a script that calculates NPS.
- Cancellation surveyAsk why customers cancel inside your SaaS cancel flow, keep Skip one click away, save the reason to Stripe and Ikiform, and never delay the cancel.
- Next.js feedback formAn in-app Next.js feedback form: a server action that reads the session, takes the page from the request and forwards a rating with the user attached.
- Astro feedback formAn in-app Astro feedback form: an action that takes the user from middleware locals, the page from the request and the screen from the route pattern.
- TanStack Start feedback formAn in-app TanStack Start feedback form: a POST server function with session middleware, the route id as context, and the page read from request headers.
- React Router feedback formA React Router 8 feedback form: a resource route action reads the cookie session, a fetcher posts from the layout, and a no-JS post redirects back.
- SvelteKit feedback formAn in-app SvelteKit feedback form: a layout form posts to one form action, which adds the user from locals and the page from the request on the server.
- Nuxt feedback formAn in-app Nuxt feedback form: a server route that reads the user session, takes the page from Referer and still works when JavaScript hasn't loaded.
Check it works
Run through this before you put the form on every page. Ikiform has no free plan. Testing is free: a workspace without a plan accepts 50 test submissions a month from localhost. To go live, pick Pro from $6/mo for 500 submissions, or pay once: 10,000 submissions for $49 or 50,000 submissions for $149, with credits that never expire. See pricing.
- Under the form's Allowed domains, add your site's domain, plus
localhostwhile you develop. Leave it empty to accept posts from anywhere. - Pick a score, write a comment and submit. The thank-you line replaces the form, or the browser lands on your redirect page for the plain HTML version.
- Open Submissions in the dashboard. The answer shows
ratingas a number andpagewith the full path. - Post a score of
6withcurlor the browser console. Ikiform answers422namingrating. - Check the inbox of each notification recipient. Each gets an email with the fields, and Reply goes to the submitter when the form has an
emailfield. - Submit a score of 1. Your relay posts it to the team chat, and the delivery under Webhooks shows
succeeded. - Post with the honeypot filled in,
_gotcha=bot. It still gets a success answer, but the submission is stored as spam and sends no email or webhook. - Nothing arrived? Switch the dashboard's status filter to Spam. Spam gets the same success answer as a real submission.
Troubleshooting
| Problem | What to do |
|---|---|
403 origin_not_allowed, or every post lands in spam | The page's domain isn't in the form's Allowed domains. Add it, plus localhost for local tests. Subdomains of a listed domain are allowed. |
422 with Rating must be at most 5 | The form sends a higher score than the field's max. Change the radio values or raise max in the definition, up to 10. |
422 with an Unknown field | The form sends a name that isn't in the definition, often a new hidden input. Add a field with that id, or remove the input. |
page is empty | The script that fills it didn't run, or ran before the form existed. Put it after the form, or set page at submit time as the SPA samples do. |
meta.referrer only shows your domain | Expected. Browsers send only the origin on cross-site posts by default. Use the page field instead. |
| The submission is in the dashboard but no email or webhook arrived | It was stored as spam. Spam sends nothing. Open it under the Spam filter to see the reasons, and mark it as not spam to send its email and webhooks. |
429 rate_limited | One visitor sent more than the form's rate limit allows. Standard allows 5 a minute per visitor. Wait for Retry-After, or choose Relaxed or Custom for forms that post from one shared IP. |
402 test_limit_reached | The workspace has no plan and used its 50 free localhost tests this month. Pick a plan, or use Send test in the dashboard. |
Questions
Point the form's action at a form backend endpoint. The form can be plain HTML on a static site; the backend validates, stores and emails each answer, and sends a webhook if you want it somewhere else.
Send the page address as a field. Ikiform records the referrer too, but browsers usually send only your domain on a cross-site post, so a page field set from location is the reliable way.
Make the email optional. People are more candid without it, and the ones who want a reply will leave it. Ikiform still records the browser, origin, referrer and country with each answer.
Not as a file. Ikiform refuses posts with files with 422 files_not_supported. Ask for a link to a screenshot instead, or use a feedback tool built around screenshots if you need one on every report.
Not with a rating field, which runs from 1 to at most 10. A 0 to 10 score uses a number field with min 0, max 10 and integer true. The NPS guide shows the full setup.
Add a webhook to a small relay that checks the rating and posts only low scores to Slack or Discord. Notification emails cover every answer; the relay covers the ones that need a response today.
No, there is no free plan. Testing is free: you can send 50 test submissions a month from localhost before you pay. To go live, Pro starts at $6/mo for 500 submissions, or you can pay once for 10,000 submissions for $49 or 50,000 submissions for $149, with credits that never expire.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.