Feedback form for a website or app6 min read
Add a bug report form that captures the page, browser and steps
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.
Ask the reporter only for what they know: what went wrong, what they did before, and how much it blocks them. Let the page fill in the rest at submit time: the page URL, the browser's user agent, the viewport and the last few JavaScript errors. Post it to a form backend and forward each report to your issue tracker by webhook.
What's different about a bug report
A bug report is only useful if you can reproduce it, and reporters rarely know the details you need. Ask someone which browser they use and you get "Chrome" or "the normal one". Ask for the URL and you get the name of the page. So split the form in two: questions only the reporter can answer, and context the page already knows.
- The reporter writes a one-line summary.
- They describe what they did right before it broke.
- They pick how badly it blocks them.
- They can paste a link to a screenshot or recording.
- At submit, the page adds its own address without the query string.
- It adds the browser's user agent.
- It adds the window size and pixel ratio.
- It adds the last few JavaScript errors it saw, with hostnames removed.
Three constraints shape the code:
- The referrer isn't the page. Browsers send only your origin on a cross-site post by default, so
meta.referrerwon't tell you the path. Sendpage_urlyourself. - A server post hides the browser. Ikiform records the user agent of whoever makes the request. From a server action, that's your server. Forward the visitor's
user-agentheader as a field. - Stack traces are full of links. Each frame has a script URL, and three or more links add 2 to the spam score. Strip the hosts before you send errors, or a report with a pasted screenshot link can tip into spam.
The fields
Preview
Runs Ikiform's validator in your browser. Nothing is sent.
Severity is phrased as what it does to the reporter, not as a priority label. People pick "I can't continue" accurately; they pick "Critical" for everything. Steps has a minimum of 20 characters, which turns "it's broken" into a sentence you can act on.
Capture the context
The browser fills the hidden fields in the submit handler, after the person is done typing, so the error list includes anything that went wrong while they wrote the report.
The form posts the plain way; the script only fills the hidden inputs during submit, before the browser builds the request. Set data-app-version on the <html> element at build time. The browser posts directly, so Ikiform's own meta.user_agent matches the user_agent field.
<form id="bug" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
<label for="summary">What went wrong?</label>
<input id="summary" name="summary" required minlength="8" maxlength="140" />
<label for="steps">What did you do right before?</label>
<textarea id="steps" name="steps" rows="5" required minlength="20"></textarea>
<label for="expected">What did you expect to happen?</label>
<textarea id="expected" name="expected" rows="3"></textarea>
<fieldset>
<legend>How much does it block you?</legend>
<label><input type="radio" name="severity" value="blocker" required /> I can't continue</label>
<label><input type="radio" name="severity" value="major" /> I found a workaround</label>
<label><input type="radio" name="severity" value="minor" /> It's cosmetic</label>
</fieldset>
<label for="screenshot_url">Link to a screenshot or recording</label>
<input id="screenshot_url" name="screenshot_url" type="url" />
<label for="email">Email for follow-up questions</label>
<input id="email" name="email" type="email" />
<input type="hidden" name="page_url" />
<input type="hidden" name="user_agent" />
<input type="hidden" name="viewport" />
<input type="hidden" name="app_version" />
<input type="hidden" name="recent_errors" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<button>Send report</button>
</form>
<script>
const recent = [];
function remember(text) {
recent.push(String(text).replace(/https?:\/\/[^/\s]+/g, "").slice(0, 400));
if (recent.length > 5) recent.shift();
}
window.addEventListener("error", (event) => remember(`${event.message} at ${event.filename}:${event.lineno}`));
window.addEventListener("unhandledrejection", (event) => remember(`Unhandled: ${event.reason?.stack ?? event.reason}`));
document.getElementById("bug").addEventListener("submit", (event) => {
const fields = event.currentTarget.elements;
fields.page_url.value = location.origin + location.pathname;
fields.user_agent.value = navigator.userAgent;
fields.viewport.value = `${innerWidth}x${innerHeight} @${devicePixelRatio}x`;
fields.app_version.value = document.documentElement.dataset.appVersion ?? "";
fields.recent_errors.value = recent.join("\n---\n");
});
</script>A small module keeps the last five errors in memory and returns the browser context. The form wraps the action so the context is added to the FormData at submit, and the server action adds the visitor's user agent from headers().
const recent: string[] = [];
function remember(text: string) {
recent.push(text.replace(/https?:\/\/[^/\s]+/g, "").slice(0, 400));
if (recent.length > 5) recent.shift();
}
export function installErrorBuffer() {
const onError = (event: ErrorEvent) => remember(`${event.message} at ${event.filename}:${event.lineno}`);
const onRejection = (event: PromiseRejectionEvent) => remember(`Unhandled: ${event.reason?.stack ?? event.reason}`);
window.addEventListener("error", onError);
window.addEventListener("unhandledrejection", onRejection);
return () => {
window.removeEventListener("error", onError);
window.removeEventListener("unhandledrejection", onRejection);
};
}
export function browserContext() {
return {
page_url: location.origin + location.pathname,
viewport: `${innerWidth}x${innerHeight} @${devicePixelRatio}x`,
recent_errors: recent.join("\n---\n"),
};
}"use server";
import { headers } from "next/headers";
const FIELDS = ["summary", "steps", "expected", "severity", "screenshot_url", "email", "page_url", "viewport", "recent_errors", "_gotcha"];
export interface ReportState {
reference: string;
error: string;
}
export async function reportBug(_previous: ReportState, formData: FormData): Promise<ReportState> {
const fields = Object.fromEntries(FIELDS.map((name) => [name, String(formData.get(name) ?? "")]));
const incoming = await headers();
const origin = incoming.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({
...fields,
user_agent: incoming.get("user-agent") ?? "",
app_version: process.env.NEXT_PUBLIC_APP_VERSION ?? "",
}),
});
const result = await response.json().catch(() => null);
if (result?.ok) return { reference: result.id, error: "" };
const errors: Record<string, string[]> = result?.error?.fields ?? {};
const first = Object.entries(errors)[0];
return { reference: "", error: first ? `${first[0]}: ${first[1][0]}` : "Couldn't send the report. Try again." };
}"use client";
import { useActionState, useEffect } from "react";
import { browserContext, installErrorBuffer } from "@/lib/error-buffer";
import { type ReportState, reportBug } from "./actions";
const initial: ReportState = { reference: "", error: "" };
export function BugReportForm() {
const [state, dispatch, pending] = useActionState(reportBug, initial);
useEffect(() => installErrorBuffer(), []);
function withContext(formData: FormData) {
for (const [name, value] of Object.entries(browserContext())) formData.set(name, value);
dispatch(formData);
}
if (state.reference) return <p role="status">Thanks. Quote {state.reference} if you write to us about it.</p>;
return (
<form action={withContext}>
<label htmlFor="summary">What went wrong?</label>
<input id="summary" name="summary" required minLength={8} maxLength={140} />
<label htmlFor="steps">What did you do right before?</label>
<textarea id="steps" name="steps" rows={5} required minLength={20} />
<label htmlFor="expected">What did you expect to happen?</label>
<textarea id="expected" name="expected" rows={3} />
<fieldset>
<legend>How much does it block you?</legend>
<label><input type="radio" name="severity" value="blocker" required /> I can't continue</label>
<label><input type="radio" name="severity" value="major" /> I found a workaround</label>
<label><input type="radio" name="severity" value="minor" /> It's cosmetic</label>
</fieldset>
<label htmlFor="screenshot_url">Link to a screenshot or recording</label>
<input id="screenshot_url" name="screenshot_url" type="url" />
<label htmlFor="email">Email for follow-up questions</label>
<input id="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 report"}</button>
</form>
);
}The same error-buffer.ts module goes in src/lib. use:enhance hands you the FormData before it's sent, so the context goes in there. The action reads the user agent from the request and the version from a public env variable. Declare both in src/env.ts with defineEnvVars, PUBLIC_APP_VERSION with public: true, so $app/env/private and $app/env/public can read them.
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import { PUBLIC_APP_VERSION } from "$app/env/public";
import type { Actions } from "./$types";
export const actions = {
default: async ({ request, fetch, url }) => {
const data = await request.formData();
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({
...Object.fromEntries(data),
user_agent: request.headers.get("user-agent") ?? "",
app_version: PUBLIC_APP_VERSION,
}),
});
const result = await response.json();
if (!result.ok) return fail(response.status, { errors: (result.error.fields ?? {}) as Record<string, string[]>, message: String(result.error.message) });
return { reference: String(result.id) };
},
} satisfies Actions;<script lang="ts">
import { enhance } from "$app/forms";
import { browserContext, installErrorBuffer } from "#lib/error-buffer.js";
import type { PageProps } from "./$types";
let { form }: PageProps = $props();
$effect(() => installErrorBuffer());
</script>
{#if form?.reference}
<p role="status">Thanks. Quote {form.reference} if you write to us about it.</p>
{:else}
<form
method="POST"
use:enhance={({ formData }) => {
for (const [name, value] of Object.entries(browserContext())) formData.set(name, value);
}}
>
<label for="summary">What went wrong?</label>
<input id="summary" name="summary" required minlength="8" maxlength="140" />
<label for="steps">What did you do right before?</label>
<textarea id="steps" name="steps" rows="5" required minlength="20"></textarea>
<label for="expected">What did you expect to happen?</label>
<textarea id="expected" name="expected" rows="3"></textarea>
<fieldset>
<legend>How much does it block you?</legend>
<label><input type="radio" name="severity" value="blocker" required /> I can't continue</label>
<label><input type="radio" name="severity" value="major" /> I found a workaround</label>
<label><input type="radio" name="severity" value="minor" /> It's cosmetic</label>
</fieldset>
<label for="screenshot_url">Link to a screenshot or recording</label>
<input id="screenshot_url" name="screenshot_url" type="url" />
<label for="email">Email for follow-up questions</label>
<input id="email" name="email" type="email" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{#if form?.message}
<p role="alert">{Object.values(form.errors)[0]?.[0] ?? form.message}</p>
{/if}
<button>Send report</button>
</form>
{/if}IKIFORM_SECRET_KEY in server-only environment variables.The success message shows the submission id. When the reporter writes to support later, that id finds the report in one search. The bug report template has a shorter version without the captured context.
Into your issue tracker
Reports belong where the fix will be tracked. Add a webhook that points at a relay which creates an issue: summary as the title, the steps and context as the body, severity as a label. The Linear and GitHub Issues guide has that relay, with signature checks and retries. The webhooks docs list every meta value the payload carries. Put the captured fields in a collapsed block at the end of the issue body so the steps stay readable.
| Field | In the issue |
|---|---|
summary | Title |
steps, expected | Body, first |
severity | A label, so blockers can be filtered |
page_url, user_agent, viewport, app_version | A details block under the steps |
recent_errors | A code block, last |
Check it works
- Open the page in a desktop browser, run
undefinedFunction()in the console, then send a report. - Open the submission in the dashboard.
recent_errorscontains theReferenceErrorwith nohttps://host in it. user_agentshows your browser, notnode, in the server versions too.page_urlhas the path but not the query string.- Send a report with a 10-character
steps. Ikiform answers422namingsteps, and the form shows the message.
Gotchas
- File uploads aren't supported yet. A post with a file gets
422 files_not_supported. Ask for a link to a screenshot, or upload the image to your own storage first and submit its URL. - Each value can be up to 16 KB. Five errors cut to 400 characters stay far below that; a full console dump wouldn't.
- Chromium browsers freeze parts of the user agent string, such as the macOS version, which always reads
10_15_7. Use the browser name and major version, not the OS version, when you triage. - Errors from scripts on another origin show only
Script error.unless that script is served with CORS headers and loaded withcrossorigin. - Don't capture form contents, local storage or cookies into the report. They can hold passwords and tokens, and you'd store them in plain text.
Troubleshooting
| Problem | What to do |
|---|---|
422 validation_failed | The 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. |
422 files_not_supported | The form has an <input type="file"> with a file chosen. File uploads aren't supported yet; remove the input or ask for a link instead. |
| Reports with screenshot links land in spam | Count the links across all fields. Three or more add to the score; with a repeat of the same report it reaches the threshold. Strip hosts from errors and mark the report as not spam. |
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
Read navigator.userAgent in the submit handler and send it as a field. When the form posts straight from the browser, Ikiform records the same value as meta.user_agent; when it posts from your server, forward the visitor's user-agent header.
Yes. Listen for error and unhandledrejection events, keep the last few messages in memory, and add them to a hidden field at submit. Remove hostnames first so the links don't count toward the spam score.
Not as a file; Ikiform refuses posts with files with 422 files_not_supported. Ask for a link to a screenshot or recording instead.
Yes, with a webhook to a small relay that calls the GitHub or Linear API. Ikiform signs each delivery and retries failures with the same webhook-id.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.