Guides

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.

Preet SutharFounder, Ikiform

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.

How one piece of feedback travels from your page to your teamany page orapp screenrating, comment,page addressIkiformvalidates,filters spam,storesdashboardfilter by formand date, exportCSVnotificationemailto your team,reply goes tothe visitorsignedwebhooksubmission.createdwith fields andmetateam chatonly scores of1 or 2postrelayHow one piece of feedback travels from your page to your teamany page orapp screenrating,comment, pageaddressIkiformvalidates,filters spam,storesdashboardfilter by form anddate, export CSVnotificationemailto your team, replygoes to the visitorsigned webhooksubmission.createdwith fields andmetateam chatonly scores of 1 or2postrelay
  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.

ApproachGood atTrade-offs
Website feedback tools, such as Hotjar or UsersnapScreenshots, 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 FeedbackTies 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 FormsPolished 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 CannyPublic voting and status updates on feature requests.Built for ideas, not quick reactions. Answers are public by design.
Your own API route and databaseFull 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 IkiformYour 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.

feedback.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>
  • 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 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, 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.

How useful was this page?

Filled in by the script with the address of the page, without the query string.

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.
FieldTypeWhy
ratingrating, max 5One click. Stored as a number, so you can average it per page
commenttextarea, up to 4,000 charactersThe reason behind the score
emailemail, optionalReply to people who ask a question. Becomes the reply-to of the notification
pageurl, optionalWhich 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:

ValueWhere it comes fromWhat you get
meta.user_agentRecorded by IkiformThe browser and operating system, from a browser post
meta.originRecorded by IkiformYour site's origin, such as https://example.com
meta.referrerRecorded by IkiformUsually only the origin. Browsers default to the strict-origin-when-cross-origin policy, which drops the path when posting to another site
meta.countryRecorded by IkiformA two-letter country code
pageSent by your formThe 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 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.

PlacementGood forWatch out for
End of a docs page or articleFinding pages that don't answer the questionLow volume per page. Read it per section, not per page
After a task in your app, such as an export or a first projectHow a specific flow feels, while it's freshAsk once per person, not every time they finish the task
A floating button on every screenAnything, any time. See the React widgetIt must not cover your own buttons, cookie banners or chat bubbles
A link in an emailScores you want to track over time, like NPSThe 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

What happens to an answer depending on its scoreanswerstoredevery score,every pagewebhooksubmission.createdrelaychecksrating3, 4 or 5:nothingmoreweeklyreviewaverages perpage from theCSVteam chatscore, page,commentsomeonereplieswhen an emailwas leftsignedexport1 or 2What happens to an answer depending on its scoreanswer storedevery score, everypagewebhooksubmission.createdrelay checksrating3, 4 or 5:nothing moreweekly reviewaverages per page fromthe CSVteam chatscore, page,commentsomeonereplieswhen anemail wasleftsignedexport1 or 2
  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
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, Discord and Google Sheets. For feedback that should become a ticket, Linear and GitHub Issues has a relay that creates one per submission.

There are no one-click integrations. Every tool is reached through a signed webhook, directly or through an automation tool such as Zapier or n8n.

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:

DefenceWhat it stopsSetup
HoneypotBots that fill every inputThe hidden _gotcha input in the samples
Spam scoringThree or more links (+2), known spam phrases (+2), disposable emails (+2), identical repeats (+3). At 5 it's stored as spamNone
Rate limitsScripts that post in a loopOn by default: Standard, 5 a minute per visitor, and 300 a minute for the whole form
Allowed domainsPosts from other sitesAdd your domain on the form's settings
Human checkBots that run a real browserSet 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:

Google Sheets
=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.

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.

  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

ProblemWhat to do
403 origin_not_allowed, or every post lands in spamThe 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 5The 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 fieldThe 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 emptyThe 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 domainExpected. 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 arrivedIt 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_limitedOne 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_reachedThe workspace has no plan and used its 50 free localhost tests this month. Pick a plan, or use Send test in the dashboard.

Questions

Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.