Feedback form for a website or app

Feedback form for a website or app6 min read

Collect beta tester feedback build by build

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.

Preet SutharFounder, Ikiform

Give each tester a personal link that carries an opaque tester code and the build number, so the form knows who and which build without asking. Ask what they used the build for, two 1 to 5 ratings for stability and usefulness, and what stopped them. Then compare builds, not individual answers, to see whether each release moved the numbers.

What's different about beta feedback

Public feedback comes from strangers, once. Beta feedback comes from the same small group of people, again and again, about software that changes every week. A single answer matters less than the trend: did build 1.4.0 feel more stable than 1.3.0 to the same people? That needs two things a normal feedback form doesn't have: who answered, and which build they were on.

Asking testers to type their email and version number fails in practice. They forget the version, mistype it, or write "latest". So both go in the link you send, and the page fills them in.

The weekly beta feedback loop, from a new build to the next onebuild1.4.0shipstothebetagroupinviteemailonelinkpertestertesteranswerscodeandbuildprefilledsummaryperbuildaverages,testers,would-missbuild1.5.0fixesthetopblockers?t=…&build=APIThe weekly beta feedback loop, from a new build to the next onebuild 1.4.0 shipsto the beta groupinvite emailone link per testertester answerscode and build prefilledsummary per buildaverages, testers, would-missbuild 1.5.0fixes the top blockers?t=…&build=API
  1. A new build goes out to the beta group.
  2. A script makes one link per tester with their code and the build number, and you send them by email.
  3. The tester opens the link. The page fills in the hidden tester and build fields and shows which build it's asking about.
  4. A summary script reads every answer through the API and averages the scores per build.
  5. The blockers from the lowest scores become the fix list for the next build.
Compare columns, not rows. One tester's bad week is noise; a drop across the group is a regression.

The fields

Preview

Runs Ikiform's validator in your browser. Nothing is sent.

How stable did it feel?
How useful was it?
If you could no longer use it, how would you feel?
tester and build are hidden inputs filled from the link. Their patterns reject anything that didn't come from a real invite.
FieldWhy
testerA random 10-character code. Lets you follow one person across builds without putting their email in a URL
buildTies every score to a release. The pattern only accepts versions like 1.4.0
triedWhat they actually did. Scores without this are hard to interpret
stability, usefulnessTwo rating fields, 1 to 5. Separate, because a stable build of the wrong thing is still the wrong thing
would_missHow disappointed they'd be without it. A rising share of "Very" is the strongest sign the beta is working
blockersOptional, and where the fix list comes from

Keep a plain list of tester emails in testers.txt. This script gives each a stable code the first time it sees them, stores the mapping in testers.json, and writes one invite link per tester for the build you name. Start testers.json as {}.

links.mjs
import { randomBytes } from "node:crypto";
import { readFileSync, writeFileSync } from "node:fs";

const BUILD = process.argv[2];
if (!/^\d+\.\d+\.\d+$/.test(BUILD ?? "")) throw new Error("Usage: node links.mjs 1.4.0");

const known = JSON.parse(readFileSync("testers.json", "utf8"));
const emails = readFileSync("testers.txt", "utf8").split(/\r?\n/).map((line) => line.trim().toLowerCase()).filter(Boolean);

for (const email of emails) {
  known[email] ??= randomBytes(5).toString("hex");
}
writeFileSync("testers.json", JSON.stringify(known, null, 2));

const rows = emails.map((email) => {
  const link = new URL("https://example.com/beta/feedback");
  link.searchParams.set("t", known[email]);
  link.searchParams.set("build", BUILD);
  return `${email},${link}`;
});
writeFileSync(`invites-${BUILD}.csv`, ["email,link", ...rows].join("\n"));
console.log(`${rows.length} links for build ${BUILD}`);

Send the CSV through your email tool's mail merge, or paste the links by hand for a small group. Keep testers.json private: it is the only place a code maps back to a person.

The feedback page

Put this on a page at /beta/feedback on your site. It reads the code and build from the link, refuses to show the form without them, and posts the plain way. Set Redirect after submit to a thank-you page. It works on any static host or builder that allows a script.

beta/feedback.html
<p id="beta-missing" hidden>This page needs the link from your invite email.</p>

<form id="beta" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post" hidden>
  <h2>Feedback for build <span id="beta-build"></span></h2>
  <input type="hidden" name="tester" />
  <input type="hidden" name="build" />
  <label for="tried">What did you use this build for?</label>
  <textarea id="tried" name="tried" rows="4" required minlength="15"></textarea>
  <fieldset>
    <legend>How stable did it feel?</legend>
    <label><input type="radio" name="stability" value="1" required /> 1, it crashed a lot</label>
    <label><input type="radio" name="stability" value="2" /> 2</label>
    <label><input type="radio" name="stability" value="3" /> 3</label>
    <label><input type="radio" name="stability" value="4" /> 4</label>
    <label><input type="radio" name="stability" value="5" /> 5, no problems</label>
  </fieldset>
  <fieldset>
    <legend>How useful was it?</legend>
    <label><input type="radio" name="usefulness" value="1" required /> 1, not at all</label>
    <label><input type="radio" name="usefulness" value="2" /> 2</label>
    <label><input type="radio" name="usefulness" value="3" /> 3</label>
    <label><input type="radio" name="usefulness" value="4" /> 4</label>
    <label><input type="radio" name="usefulness" value="5" /> 5, I rely on it</label>
  </fieldset>
  <fieldset>
    <legend>If you could no longer use it, how would you feel?</legend>
    <label><input type="radio" name="would_miss" value="very" required /> Very disappointed</label>
    <label><input type="radio" name="would_miss" value="somewhat" /> Somewhat disappointed</label>
    <label><input type="radio" name="would_miss" value="not" /> Not disappointed</label>
  </fieldset>
  <label for="blockers">Anything that stopped you?</label>
  <textarea id="blockers" name="blockers" rows="3"></textarea>
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button>Send this week's feedback</button>
</form>

<script>
  const params = new URLSearchParams(location.search);
  const tester = params.get("t") ?? "";
  const build = params.get("build") ?? "";
  const form = document.getElementById("beta");
  if (/^[a-z0-9]{10}$/.test(tester) && /^\d+\.\d+\.\d+$/.test(build)) {
    form.elements.tester.value = tester;
    form.elements.build.value = build;
    document.getElementById("beta-build").textContent = build;
    form.hidden = false;
  } else {
    document.getElementById("beta-missing").hidden = false;
  }
</script>
Anyone with a tester's link can answer as that tester. That's acceptable for a beta: the codes are random, the group is small, and an odd answer stands out. Don't use the code to grant access to anything.

Compare builds

The dashboard shows individual answers. To compare builds, read them all with the SDK and group by build. Run this with a secret key in IKIFORM_SECRET_KEY:

summary.mjs
import { createClient } from "@ikiform/sdk";

const ikiform = createClient({ apiKey: process.env.IKIFORM_SECRET_KEY });
const builds = new Map();

for await (const submission of ikiform.submissions.iterate({ formId: "frm_xxxxxxxxxxxxxxxx", status: "received" })) {
  const { build, tester, stability, usefulness, would_miss } = submission.fields;
  const row = builds.get(build) ?? { testers: new Set(), stability: 0, usefulness: 0, very: 0, answers: 0 };
  row.testers.add(tester);
  row.stability += stability;
  row.usefulness += usefulness;
  row.very += would_miss === "very" ? 1 : 0;
  row.answers += 1;
  builds.set(build, row);
}

console.table(
  [...builds].map(([build, row]) => ({
    build,
    testers: row.testers.size,
    stability: (row.stability / row.answers).toFixed(1),
    usefulness: (row.usefulness / row.answers).toFixed(1),
    "very disappointed": `${Math.round((row.very / row.answers) * 100)}%`,
  })),
);

Ratings arrive as numbers, so the averages need no parsing. Count testers next to each average: a build with three answers can't be compared fairly with one that has thirty.

Check it works

  1. Run node links.mjs 1.4.0 and open one of the links. The form shows "Feedback for build 1.4.0".
  2. Open the page without the query string. Only the invite message shows.
  3. Submit an answer, then change build to latest in the browser's dev tools and submit again. Ikiform answers 422 with "Use a version like 1.4.0".
  4. Run summary.mjs. The table shows build 1.4.0 with one tester and your two scores.

Gotchas

  • Beta groups often test from one office, which shares an IP. The Standard limit counts them together, so choose Relaxed, 20 a minute per visitor for this form.
  • Mobile betas on TestFlight already let testers send screenshot feedback to App Store Connect. Use that for screen-level bugs and this form for the weekly questions.
  • Ask the same questions every week. Changing the wording breaks the comparison between builds.
  • Testers answer less as the beta goes on. Keep the form to a couple of minutes, and thank people by name in release notes.
  • Ikiform doesn't merge repeat answers. If a tester answers twice for one build, keep the later one in your summary.

Troubleshooting

ProblemWhat to do
422 with "Open the link from your invite email"The tester value doesn't match the pattern, usually because the link was cut off in an email client. Resend the link.
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.
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.

Questions

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