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

Published 2026-10-25 · https://ikiform.com/guides/feedback-form

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.

Diagram: How one piece of feedback travels from your page to your team

1. A visitor picks a score, maybe writes a comment, and the form posts it with the page address.
2. Ikiform checks the score is a whole number from 1 to 5, runs the spam checks and stores the answer.
3. The answer shows up in the dashboard, where you can filter it and export it as CSV.
4. Ikiform emails your team. When the visitor left an email, Reply goes straight to them.
5. Ikiform sends a signed submission.created webhook with the fields and the browser metadata.
6. A small relay posts only the low scores to your team chat, so they get read today.

The relay is optional. Start with the email and the dashboard, and add the chat alert when answers start to pile up.

## 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](https://ikiform.com/alternatives/typeform), [Tally](https://ikiform.com/alternatives/tally) or [Google Forms](https://ikiform.com/alternatives/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.

**HTML**

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.

`feedback.html`:

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

**Next.js**

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.

`app/feedback/actions.ts`:

```ts
"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." };
}
```

`app/feedback/feedback-form.tsx`:

```tsx
"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](https://ikiform.com/guides/feedback-form/nextjs.md).

**Astro**

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.

`src/actions/index.ts`:

```ts
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 };
    },
  }),
};
```

`src/components/FeedbackForm.astro`:

```astro
---
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](https://ikiform.com/guides/feedback-form/astro.md).

**TanStack Start**

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.

`src/components/FeedbackForm.tsx`:

```tsx
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](https://ikiform.com/guides/feedback-form/tanstack-start.md).

**React Router**

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.

`app/routes/api.feedback.ts`:

```ts
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 },
  );
}
```

`app/components/feedback-form.tsx`:

```tsx
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](https://ikiform.com/guides/feedback-form/react-router.md).

**SvelteKit**

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.

`src/routes/api/feedback/+server.ts`:

```ts
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 });
};
```

`src/lib/FeedbackForm.svelte`:

```svelte
<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](https://ikiform.com/guides/feedback-form/sveltekit.md).

**Nuxt**

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.

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

```ts
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 };
});
```

`app/components/FeedbackForm.vue`:

```vue
<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](https://ikiform.com/guides/feedback-form/nuxt.md).

**React**

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.

`src/FeedbackForm.tsx`:

```tsx
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>
  );
}
```

**Vue**

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.

`src/components/FeedbackForm.vue`:

```vue
<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_xxxxxxxxxxxxxxxx` with your form's id from the dashboard.
- `rating` is a radio group of five values. Ikiform stores it as a number, so averages need no parsing.
- `email` is 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 `_gotcha` input is the honeypot. People never see it; bots that fill every input mark themselves as spam.
- `page` records which page the answer is about. The next section explains why you send it yourself.

> The server-side versions post with a secret key, so Ikiform skips **Allowed domains** and its human check. Every post then comes from your server's IP, and the Standard limit of 5 a minute per visitor counts all your visitors together. Choose the Relaxed or Custom [rate limit](https://ikiform.com/docs/rate-limits.md) for a form you post through a server, and keep `IKIFORM_SECRET_KEY` in server-only environment variables.

Framework guides in the docs cover setup in more depth: [Next.js](https://ikiform.com/docs/guides/nextjs.md), [Astro](https://ikiform.com/docs/guides/astro.md), [SvelteKit](https://ikiform.com/docs/guides/sveltekit.md), [Nuxt](https://ikiform.com/docs/guides/nuxt.md), [React](https://ikiform.com/docs/guides/react.md), [Vue](https://ikiform.com/docs/guides/vue.md) and [plain HTML](https://ikiform.com/docs/html.md). The [feedback form template](https://ikiform.com/forms/feedback) generates the same fields as HTML, React and a server action; the [bug report](https://ikiform.com/forms/bug-report) and [survey](https://ikiform.com/forms/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.

Form: Page feedback

- **How useful was this page?** (`rating`, rating, required)
- **What would make it better?** (`comment`, textarea)
- **Email, if you'd like a reply** (`email`, email)
- **Page** (`page`, url)

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

> The server versions post from your server, so `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](https://ikiform.com/guides/feedback-form/bug-report.md) 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](https://ikiform.com/guides/feedback-form/react-feedback-widget.md) | 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](https://ikiform.com/guides/feedback-form/nps-survey.md) | 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

Diagram: What happens to an answer depending on its score

1. Every answer is stored, whatever its score and whichever page it came from.
2. Ikiform sends a signed submission.created webhook to your relay.
3. The relay reads the rating. Scores of 3 to 5 need nothing more right away.
4. A score of 1 or 2 goes to your team chat with the page and the comment.
5. If the visitor left an email, someone replies the same day.
6. Once a week, the export shows averages per page, which is where trends show up.

Alert on the bad scores only. A channel that pings for every 5 out of 5 gets muted within a week.

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:

`app/api/feedback-alerts/route.ts`:

```ts
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;
  }
}
```

- `verifyWebhook` checks the signature and the timestamp, so nobody else can post to your channel through this URL.
- Scores above 2 answer `204` and stop. Ikiform treats any 2xx as delivered.
- A failed Slack call answers `502`, and Ikiform retries with the same `webhook-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](https://ikiform.com/blog/form-submissions-to-slack.md), [Discord](https://ikiform.com/blog/form-submissions-to-discord.md) and [Google Sheets](https://ikiform.com/blog/form-submissions-to-google-sheets.md). For feedback that should become a ticket, [Linear and GitHub Issues](https://ikiform.com/blog/form-submissions-to-linear-and-github-issues.md) has a relay that creates one per submission.

> There are no one-click integrations. Every tool is reached through a signed [webhook](https://ikiform.com/docs/webhooks.md), directly or through an automation tool such as [Zapier](https://ikiform.com/blog/form-submissions-to-zapier.md) or [n8n](https://ikiform.com/blog/form-submissions-to-n8n.md).

## 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](https://ikiform.com/docs/spam.md) 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](https://ikiform.com/docs/guides/export.md) 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:

`Google Sheets`:

```text
=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)
```

1. Ignore pages with only one or two answers. One angry visitor isn't a trend.
2. Read the comments on the three lowest pages. The fix is usually in the first few.
3. After you change a page, compare its average for the weeks before and after, not the all-time number.

> Feedback isn't anonymous to Ikiform even without an email: each answer records the user agent, origin, referrer and country, and a hash of the IP that is never returned. Say what you collect in your privacy notice.

## 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 form](https://ikiform.com/guides/feedback-form/bug-report.md): A 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 form](https://ikiform.com/guides/feedback-form/feature-request.md): A 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 widget](https://ikiform.com/guides/feedback-form/react-feedback-widget.md): A 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 feedback](https://ikiform.com/guides/feedback-form/beta-tester-feedback.md): A 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 survey](https://ikiform.com/guides/feedback-form/nps-survey.md): Run 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 survey](https://ikiform.com/guides/feedback-form/cancellation-survey.md): Ask 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 form](https://ikiform.com/guides/feedback-form/nextjs.md): An 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 form](https://ikiform.com/guides/feedback-form/astro.md): An 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 form](https://ikiform.com/guides/feedback-form/tanstack-start.md): An 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 form](https://ikiform.com/guides/feedback-form/react-router.md): A 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 form](https://ikiform.com/guides/feedback-form/sveltekit.md): An 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 form](https://ikiform.com/guides/feedback-form/nuxt.md): An 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](https://ikiform.com/pricing).

1. Under the form's **Allowed domains**, add your site's domain, plus `localhost` while you develop. Leave it empty to accept posts from anywhere.
2. 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.
3. Open **Submissions** in the dashboard. The answer shows `rating` as a number and `page` with the full path.
4. Post a score of `6` with `curl` or the browser console. Ikiform answers `422` naming `rating`.
5. 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 `email` field.
6. Submit a score of 1. Your relay posts it to the team chat, and the delivery under **Webhooks** shows `succeeded`.
7. 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.
8. 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

### How do I add a feedback form to a website without a backend?

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.

### How do I know which page the feedback came from?

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.

### Should feedback be anonymous?

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.

### Can visitors attach a screenshot?

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.

### Can I use a 0 to 10 scale instead of stars?

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.

### How do I get alerted about bad feedback?

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.

### Is Ikiform free?

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.

## More guides

- [Bug report form](https://ikiform.com/guides/feedback-form/bug-report.md): A 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 form](https://ikiform.com/guides/feedback-form/feature-request.md): A 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 widget](https://ikiform.com/guides/feedback-form/react-feedback-widget.md): A 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 feedback](https://ikiform.com/guides/feedback-form/beta-tester-feedback.md): A 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 survey](https://ikiform.com/guides/feedback-form/nps-survey.md): Run 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 survey](https://ikiform.com/guides/feedback-form/cancellation-survey.md): Ask 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 form](https://ikiform.com/guides/feedback-form/nextjs.md): An 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 form](https://ikiform.com/guides/feedback-form/astro.md): An 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 form](https://ikiform.com/guides/feedback-form/tanstack-start.md): An 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 form](https://ikiform.com/guides/feedback-form/react-router.md): A 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 form](https://ikiform.com/guides/feedback-form/sveltekit.md): An 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 form](https://ikiform.com/guides/feedback-form/nuxt.md): An 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.
- [Lead capture form](https://ikiform.com/guides/lead-capture.md): A lead capture form on your own site that emails you with reply-to, pings team chat and feeds your CRM, so you answer while the lead is still there.
- [Newsletter signup form](https://ikiform.com/guides/newsletter-signup.md): Build a newsletter signup form in your own markup, record consent, block bot signups and pass each subscriber to Kit, MailerLite, beehiiv or any tool.
- [HTML form submission](https://ikiform.com/guides/html-form-submission.md): What a browser does when a form is submitted: which fields it sends, how it encodes them, what the server should answer, and how Ikiform answers.
