# 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.

Published 2026-10-25 · https://ikiform.com/guides/feedback-form/bug-report

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.

Diagram: Which parts of a bug report the person types and which the page adds

1. The reporter writes a one-line summary.
2. They describe what they did right before it broke.
3. They pick how badly it blocks them.
4. They can paste a link to a screenshot or recording.
5. At submit, the page adds its own address without the query string.
6. It adds the browser's user agent.
7. It adds the window size and pixel ratio.
8. It adds the last few JavaScript errors it saw, with hostnames removed.

The top row is all the reporter sees. The bottom row is hidden inputs, filled in the moment they press Send.

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.referrer` won't tell you the path. Send `page_url` yourself.
- **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-agent` header 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

Form: Bug report

- **What went wrong?** (`summary`, text, required)
- **What did you do right before?** (`steps`, textarea, required)
- **What did you expect to happen?** (`expected`, textarea)
- **How much does it block you?** (`severity`, radio, required)
- **Link to a screenshot or recording** (`screenshot_url`, url)
- **Email for follow-up questions** (`email`, email)
- **Page** (`page_url`, url)
- **Browser** (`user_agent`, text)
- **Viewport** (`viewport`, text)
- **App version** (`app_version`, text)
- **Recent errors** (`recent_errors`, textarea)

The last five fields are hidden inputs in the real form. With them defined, Ikiform checks their length and type like any other field.

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.

**HTML**

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.

`report-bug.html`:

```html
<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>
```

**Next.js**

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()`.

`lib/error-buffer.ts`:

```ts
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"),
  };
}
```

`app/report/actions.ts`:

```ts
"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." };
}
```

`app/report/bug-report-form.tsx`:

```tsx
"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>
  );
}
```

**SvelteKit**

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.

`src/routes/report/+page.server.ts`:

```ts
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;
```

`src/routes/report/+page.svelte`:

```svelte
<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}
```

> 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](https://ikiform.com/docs/rate-limits.md) for a form you post through a server, and keep `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](https://ikiform.com/forms/bug-report) 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](https://ikiform.com/blog/form-submissions-to-linear-and-github-issues.md) has that relay, with signature checks and retries. The [webhooks docs](https://ikiform.com/docs/webhooks.md) 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

1. Open the page in a desktop browser, run `undefinedFunction()` in the console, then send a report.
2. Open the submission in the dashboard. `recent_errors` contains the `ReferenceError` with no `https://` host in it.
3. `user_agent` shows your browser, not `node`, in the server versions too.
4. `page_url` has the path but not the query string.
5. Send a report with a 10-character `steps`. Ikiform answers `422` naming `steps`, 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 with `crossorigin`.
- 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

### How do I capture the browser and OS in a bug report form?

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.

### Can I include console errors in a bug report?

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.

### Can reporters attach a screenshot?

Not as a file; Ikiform refuses posts with files with 422 files_not_supported. Ask for a link to a screenshot or recording instead.

### Can bug reports create GitHub or Linear issues automatically?

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.

## More guides

- [Feedback form for a website or app](https://ikiform.com/guides/feedback-form.md): 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.
- [React feedback widget](https://ikiform.com/guides/feedback-form/react-feedback-widget.md): A floating feedback button for a React app that opens a small panel, sends the screen, app version and account with each answer, and never loses a draft.
- [Feature request form](https://ikiform.com/guides/feedback-form/feature-request.md): 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.
- [Beta tester feedback](https://ikiform.com/guides/feedback-form/beta-tester-feedback.md): 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.
- [NPS survey](https://ikiform.com/guides/feedback-form/nps-survey.md): Run an NPS survey with a 0 to 10 score in the email, a follow-up question on your own page that changes with the score, and a script that calculates NPS.
- [Cancellation survey](https://ikiform.com/guides/feedback-form/cancellation-survey.md): Ask why customers cancel inside your SaaS cancel flow, keep Skip one click away, save the reason to Stripe and Ikiform, and never delay the cancel.
