Feedback form for a website or app

Feedback form for a website or app6 min read

Collect feature requests you can count, rank and answer

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.

Preet SutharFounder, Ikiform

Ask what the person is trying to do and how they do it today, not which feature they want, and require an email so you can tell them when it ships. Store each request in a table by webhook, tag requests into themes, and rank themes by how many different people are blocked. When a theme ships, email everyone who asked.

What's different about feature requests

A bug report is done when it's fixed. A feature request is a data point: one of many people describing the same gap in different words. Three things follow from that:

  • People describe solutions. "Add a CSV button" might mean "my accountant needs last month's invoices". Ask for the job, and you can pick a better fix than the one requested.
  • The same need arrives many times. Ikiform stores every post and doesn't merge repeats, and two people rarely use the same words anyway. You group requests yourself, by theme, and count distinct people.
  • The value is in the follow-up. Telling someone "you asked, we built it" is the best reason to ask at all. That needs an email on every request, so this form requires one.
How a feature request becomes a counted theme and a launch emailrequestpostedproblem,area,impact,emailrow inyourtablekeyed bysubmissionidfitsatheme?you tagitweeklythemecount+ 1distinctpeople,blockingfirstthemeshipsemaileveryonewhoaskednewthemestartsat onepersonwebhookyesbuildnoHow a feature request becomes a counted theme and a launch emailrequest postedproblem, area, impact,emailrow in your tablekeyed by submission idfits atheme?you tag itweeklytheme count + 1distinct people,blocking firsttheme shipsemail everyone whoaskednew themestarts atone personwebhookyesbuildno
  1. Someone describes what they're trying to do, picks an area and an impact, and leaves an email.
  2. A webhook relay inserts the request into your database, once per submission id.
  3. Each week you tag new requests with a theme, by hand or with a pattern.
  4. If it fits an existing theme, that theme now has one more person behind it.
  5. If not, it starts a new theme.
  6. When a theme ships, you select every email in it and send one message.
The ranking is people per theme, not raw requests. One customer asking five times is still one customer.

Ask for the problem

Preview

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

How much does it matter?

We'll write when it ships, or if we have a question.

problem has a 30-character minimum, which turns "dark mode pls" into a sentence with a reason in it.
FieldWhy it's there
problemThe job to be done, in their words. The most useful field by far
workaroundTells you how painful it is. "I copy it into a spreadsheet by hand" is a strong signal
areaA coarse first sort, so you tag within billing, not across everything
impactSeparates blocked customers from curious ones, phrased as outcomes people can judge
emailRequired. Without it you can't close the loop or ask a question

Leave out a priority or vote field. Everyone marks their own request as high priority, and the count of people per theme is a better vote than any button.

The form

The form needs no script: it posts and redirects. Set Redirect after submit to a page that says what happens next. For a version that stays on the page in your framework, take the pillar's form code and swap in these fields.

feature-request.html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <label for="problem">What are you trying to do?</label>
  <textarea id="problem" name="problem" rows="5" required minlength="30"
    placeholder="Send last month's invoices to our accountant in one go"></textarea>
  <label for="workaround">How do you do it today?</label>
  <textarea id="workaround" name="workaround" rows="3"></textarea>
  <label for="area">Which part of the product?</label>
  <select id="area" name="area" required>
    <option value="billing">Billing and invoices</option>
    <option value="reports">Reports</option>
    <option value="team">Team and permissions</option>
    <option value="integrations">Integrations and API</option>
    <option value="other">Something else</option>
  </select>
  <fieldset>
    <legend>How much does it matter?</legend>
    <label><input type="radio" name="impact" value="blocking" required /> It stops us using the product for something</label>
    <label><input type="radio" name="impact" value="slow" /> It costs us time every week</label>
    <label><input type="radio" name="impact" value="nice" /> It would be nice</label>
  </fieldset>
  <label for="email">Email</label>
  <input id="email" name="email" type="email" required />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button>Send request</button>
</form>

Store requests by webhook

The dashboard and CSV export work for the first few dozen requests. Once you're tagging themes and counting people every week, a table is easier. Create one in Postgres:

schema.sql
create table feature_requests (
  submission_id text primary key,
  area text not null,
  impact text not null,
  problem text not null,
  workaround text,
  email text not null,
  theme text,
  created_at timestamptz not null
);

Then add a webhook endpoint in Ikiform that points at this route. It runs on any Node host with the pg package, and your own server covers the same pattern in more depth:

app/api/feature-requests/route.ts
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
import { Pool } from "pg";

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

const INSERT = `insert into feature_requests (submission_id, area, impact, problem, workaround, email, created_at)
values ($1, $2, $3, $4, $5, lower($6), $7)
on conflict (submission_id) do nothing`;

export async function POST(request: Request) {
  const payload = await request.text();
  let event;
  try {
    event = await verifyWebhook({
      payload,
      headers: request.headers,
      secret: process.env.IKIFORM_WEBHOOK_SECRET!,
    });
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
    throw error;
  }
  if (event.type !== "submission.created") return new Response(null, { status: 204 });

  const { problem, workaround, area, impact, email } = event.data.fields;
  await pool.query(INSERT, [
    event.data.submission_id,
    area,
    impact,
    problem,
    workaround ?? null,
    email,
    event.data.created_at,
  ]);
  return new Response(null, { status: 204 });
}
  • verifyWebhook rejects anything not signed with your endpoint's whsec_ secret.
  • on conflict (submission_id) do nothing makes retries safe. Ikiform resends a failed delivery with the same submission, and the second insert is a no-op.
  • A database error throws, the route answers 500, and Ikiform retries later. You don't lose requests during a database restart.
  • The email is lowercased on the way in, so counting distinct people works.
Ikiform doesn't merge or reject repeat submissions by email. An identical repeat within 24 hours only adds to the spam score. Deduplicate where the list lives, or when you export.

Tag and rank

Tag new requests

Once a week, read the untagged rows and give each a theme. Patterns handle the obvious ones; read the rest yourself, because the interesting requests don't match a pattern.

tag.sql
update feature_requests
set theme = 'invoice-export'
where theme is null
  and area = 'billing'
  and problem ilike any (array['%export%', '%accountant%', '%download%']);

Rank themes

This ranks themes by how many requests say they're blocked, then by how many different people asked:

ranking.sql
select coalesce(theme, 'untagged') as theme,
       count(*) as requests,
       count(distinct email) as people,
       count(*) filter (where impact = 'blocking') as blocking
from feature_requests
group by 1
order by blocking desc, people desc;
  1. Read the top three themes' problem texts in full before you design anything.
  2. Look at the workarounds. Often one small change removes the worst of it.
  3. Weigh the ranking against your plan; it shows demand, not what's right to build.

Close the loop

When a theme ships, pull everyone who asked for it:

notify.sql
select distinct email
from feature_requests
where theme = 'invoice-export';

Send one short email from your email tool or your own inbox, saying what shipped and linking to it. Ikiform doesn't email the person who fills in the form. Notification emails go to you and your team only, so there is no autoresponder, confirmation email or double opt-in. Send those from your email tool, triggered by a webhook. The same applies here: the launch email comes from you, not from Ikiform.

Check it works

  1. Submit a request with a 10-character problem. Ikiform answers 422 naming problem.
  2. Submit a real request. It appears under Submissions and as a row in feature_requests.
  3. Under Webhooks, resend the delivery. The table still has one row for that submission.
  4. Submit twice with the same email in different case. The ranking query counts one person.

Gotchas

  • A public feedback board, such as Canny, adds voting and a public status page. If you want customers to see each other's requests, that's the better tool; this setup keeps requests private.
  • Requests that include several links can score as spam. Check the Spam filter weekly while volume is low.
  • If you add an area option later, add it to the definition first. A value that isn't listed is refused.
  • Store what you need and no more. A requests table with emails is personal data; say so in your privacy notice and delete rows you no longer need.

Troubleshooting

ProblemWhat to do
Rows missing from the tableOpen the endpoint under Webhooks. A 400 means the secret is wrong; a 500 with a database error means the insert failed and Ikiform is retrying.
422 validation_failedThe post doesn't match the form's field definition. error.fields names each field and the reason. Match the input name to the field id.
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.

Questions

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