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.
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.
- A new build goes out to the beta group.
- A script makes one link per tester with their code and the build number, and you send them by email.
- The tester opens the link. The page fills in the hidden tester and build fields and shows which build it's asking about.
- A summary script reads every answer through the API and averages the scores per build.
- The blockers from the lowest scores become the fix list for the next build.
The fields
Preview
Runs Ikiform's validator in your browser. Nothing is sent.
tester and build are hidden inputs filled from the link. Their patterns reject anything that didn't come from a real invite.| Field | Why |
|---|---|
tester | A random 10-character code. Lets you follow one person across builds without putting their email in a URL |
build | Ties every score to a release. The pattern only accepts versions like 1.4.0 |
tried | What they actually did. Scores without this are hard to interpret |
stability, usefulness | Two rating fields, 1 to 5. Separate, because a stable build of the wrong thing is still the wrong thing |
would_miss | How disappointed they'd be without it. A rising share of "Very" is the strongest sign the beta is working |
blockers | Optional, and where the fix list comes from |
Make one link per tester
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 {}.
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.
<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>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:
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
- Run
node links.mjs 1.4.0and open one of the links. The form shows "Feedback for build 1.4.0". - Open the page without the query string. Only the invite message shows.
- Submit an answer, then change
buildtolatestin the browser's dev tools and submit again. Ikiform answers422with "Use a version like 1.4.0". - 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
| Problem | What 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_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. |
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. |
Questions
Give each tester a random code and put it in their personal link. The page copies it into a hidden field, and only your private mapping file connects the code to an email.
Put the build number in the link too, validate it with a pattern, and group answers by build. The SDK's submissions.iterate reads every answer for a summary.
What they used the build for, how stable and how useful it felt on a 1 to 5 scale, how disappointed they'd be without it, and what stopped them. Keep the same questions every week.
Keep a separate bug report form for anything that needs reproducing. The weekly form is for how the build feels overall; mixing the two makes both harder to read.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.