# Bug report form: HTML, React and Next.js code

> A bug report form with a summary, steps to reproduce, severity and the page URL, ready to forward to Linear or GitHub Issues by webhook.

Updated 2026-10-24 · https://ikiform.com/forms/bug-report

## Copy the code

Every version below is generated from the same field definition. Replace `frm_xxxxxxxxxxxxxxxx` with your form id, or enter it in the **Form ID** box and the code updates.

**HTML** (`bug-report.html`)

```html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <label for="summary">Summary</label>
  <input id="summary" name="summary" type="text" placeholder="Export button does nothing on Safari" minlength="8" maxlength="140" required />
  <label for="steps">Steps to reproduce</label>
  <textarea id="steps" name="steps" rows="6" placeholder="Open the dashboard, click Export, nothing downloads" minlength="20" maxlength="8000" required></textarea>
  <label for="expected">What did you expect?</label>
  <textarea id="expected" name="expected" rows="3" maxlength="4000"></textarea>
  <fieldset>
    <legend>How bad is it?</legend>
    <label><input type="radio" name="severity" value="blocker" required /> I can't continue</label>
    <label><input type="radio" name="severity" value="major" required /> Something important is broken</label>
    <label><input type="radio" name="severity" value="minor" required /> Small or cosmetic</label>
  </fieldset>
  <label for="page_url">Page where it happened</label>
  <input id="page_url" name="page_url" type="url" />
  <label for="screenshot_url">Screenshot or recording link</label>
  <input id="screenshot_url" name="screenshot_url" type="url" aria-describedby="screenshot_url-help" />
  <p id="screenshot_url-help">Optional. A link to an image or video, for example from a screenshot tool.</p>
  <label for="email">Email for follow-up questions</label>
  <input id="email" name="email" type="email" autocomplete="email" />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button>Report bug</button>
</form>
```

**React** (`BugReportForm.tsx`)

```tsx
import { submit } from "@ikiform/sdk";
import { type FormEvent, useState } from "react";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";

type Status = "idle" | "sending" | "sent" | "error";

export function BugReportForm() {
  const [status, setStatus] = useState<Status>("idle");
  const [errors, setErrors] = useState<Record<string, string[]>>({});
  const [message, setMessage] = useState("");

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setStatus("sending");
    setErrors({});
    const result = await submit(FORM_ID, new FormData(event.currentTarget));
    if (result.ok) return setStatus("sent");
    setErrors(result.error.fields ?? {});
    setMessage(result.error.message);
    setStatus("error");
  }

  if (status === "sent") return <p>Thanks. We'll look into it.</p>;

  return (
    <form onSubmit={onSubmit}>
      <label htmlFor="summary">Summary</label>
      <input id="summary" name="summary" type="text" placeholder="Export button does nothing on Safari" minLength={8} maxLength={140} required />
      {errors.summary ? <p>{errors.summary[0]}</p> : null}
      <label htmlFor="steps">Steps to reproduce</label>
      <textarea id="steps" name="steps" rows={6} placeholder="Open the dashboard, click Export, nothing downloads" minLength={20} maxLength={8000} required />
      {errors.steps ? <p>{errors.steps[0]}</p> : null}
      <label htmlFor="expected">What did you expect?</label>
      <textarea id="expected" name="expected" rows={3} maxLength={4000} />
      {errors.expected ? <p>{errors.expected[0]}</p> : null}
      <fieldset>
        <legend>How bad is it?</legend>
        <label><input type="radio" name="severity" value="blocker" required /> I can't continue</label>
        <label><input type="radio" name="severity" value="major" required /> Something important is broken</label>
        <label><input type="radio" name="severity" value="minor" required /> Small or cosmetic</label>
      </fieldset>
      {errors.severity ? <p>{errors.severity[0]}</p> : null}
      <label htmlFor="page_url">Page where it happened</label>
      <input id="page_url" name="page_url" type="url" />
      {errors.page_url ? <p>{errors.page_url[0]}</p> : null}
      <label htmlFor="screenshot_url">Screenshot or recording link</label>
      <input id="screenshot_url" name="screenshot_url" type="url" aria-describedby="screenshot_url-help" />
      <p id="screenshot_url-help">Optional. A link to an image or video, for example from a screenshot tool.</p>
      {errors.screenshot_url ? <p>{errors.screenshot_url[0]}</p> : null}
      <label htmlFor="email">Email for follow-up questions</label>
      <input id="email" name="email" type="email" autoComplete="email" />
      {errors.email ? <p>{errors.email[0]}</p> : null}
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {status === "error" && !Object.keys(errors).length ? <p role="alert">{message}</p> : null}
      <button disabled={status === "sending"}>{status === "sending" ? "Sending" : "Report bug"}</button>
    </form>
  );
}
```

**JavaScript** (`bug-report.js`)

```js
const form = document.querySelector('form[action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx"]');
const status = document.createElement("p");
status.setAttribute("role", "alert");
form.append(status);

const labels = {
  summary: "Summary",
  steps: "Steps to reproduce",
  expected: "What did you expect?",
  severity: "How bad is it?",
  page_url: "Page where it happened",
  screenshot_url: "Screenshot or recording link",
  email: "Email for follow-up questions",
};

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const button = form.querySelector("button");
  button.disabled = true;
  status.textContent = "";
  try {
    const response = await fetch(form.action, {
      method: "POST",
      headers: { Accept: "application/json" },
      body: new FormData(form),
    });
    const result = await response.json();
    if (result.ok) {
      form.replaceWith("Thanks. We'll look into it.");
      return;
    }
    const fields = Object.entries(result.error.fields ?? {});
    status.textContent = fields.length
      ? fields.map(([id, messages]) => `${labels[id] ?? id}: ${messages[0]}`).join(" ")
      : result.error.message;
  } catch {
    status.textContent = "Couldn't reach the server. Try again.";
  }
  button.disabled = false;
});
```

- **HTML** needs no JavaScript. After a successful post the browser is redirected to the form's redirect URL, or to Ikiform's hosted thank-you page.
- **React** uses the SDK: `npm install @ikiform/sdk`. Field errors from the server appear under each field.
- **JavaScript** enhances the HTML form with `fetch`, so the visitor stays on the page.
- The hidden `_gotcha` input is the honeypot. Leave it empty and hidden.

> If you set the form's spam check to **Ikiform**, the HTML form keeps working with no change and the server action skips the check because it sends a secret key. The React and JavaScript versions must get a token before each submit: call `challenge(FORM_ID)` from `@ikiform/sdk` and pass it to `submit` as `turnstileToken`, or add it to the `fetch` body as `cf-turnstile-response`. Without it the post is refused with `403 challenge_required`. See [the Next.js guide](https://ikiform.com/docs/guides/nextjs.md#client-human-check) and [Spam protection](https://ikiform.com/docs/spam.md#human-check).

## The fields

Paste this definition on the form's **Fields** tab in the dashboard. Ikiform then checks every post against it on the server, refuses bad data with `422 validation_failed`, refuses any field that isn't listed, and stores typed values.

| Field id | Type | Required | Accepts |
| --- | --- | --- | --- |
| `summary` | `text` | Yes | 8 to 140 characters |
| `steps` | `textarea` | Yes | 20 to 8,000 characters |
| `expected` | `textarea` | No | Up to 4,000 characters |
| `severity` | `radio` | Yes | One of: `blocker`, `major`, `minor` |
| `page_url` | `url` | No | An `http:` or `https:` URL |
| `screenshot_url` | `url` | No | An `http:` or `https:` URL |
| `email` | `email` | No | One valid address |

`Field definition`:

```json
{
  "version": 1,
  "title": "Bug report",
  "fields": [
    {
      "id": "summary",
      "type": "text",
      "label": "Summary",
      "required": true,
      "placeholder": "Export button does nothing on Safari",
      "validation": {
        "min_length": 8,
        "max_length": 140
      }
    },
    {
      "id": "steps",
      "type": "textarea",
      "label": "Steps to reproduce",
      "required": true,
      "rows": 6,
      "placeholder": "Open the dashboard, click Export, nothing downloads",
      "validation": {
        "min_length": 20,
        "max_length": 8000
      }
    },
    {
      "id": "expected",
      "type": "textarea",
      "label": "What did you expect?",
      "rows": 3,
      "validation": {
        "max_length": 4000
      }
    },
    {
      "id": "severity",
      "type": "radio",
      "label": "How bad is it?",
      "required": true,
      "options": [
        {
          "value": "blocker",
          "label": "I can't continue"
        },
        {
          "value": "major",
          "label": "Something important is broken"
        },
        {
          "value": "minor",
          "label": "Small or cosmetic"
        }
      ]
    },
    {
      "id": "page_url",
      "type": "url",
      "label": "Page where it happened"
    },
    {
      "id": "screenshot_url",
      "type": "url",
      "label": "Screenshot or recording link",
      "help_text": "Optional. A link to an image or video, for example from a screenshot tool."
    },
    {
      "id": "email",
      "type": "email",
      "label": "Email for follow-up questions"
    }
  ],
  "settings": {
    "submit_label": "Report bug",
    "success_message": "Thanks. We'll look into it."
  }
}
```

## Next.js server action

To keep the form working without JavaScript in a Next.js app, post from a server action with a secret key. Create the key under **API keys** with the `submissions:write` scope and keep it out of the browser.

`.env.local`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
```

`app/bug-report/actions.ts`:

```ts
"use server";

export interface BugReportFormState {
  ok: boolean;
  message: string;
  fields?: Record<string, string[]>;
  values?: Record<string, string[]>;
}

export async function sendBugReportForm(_previous: BugReportFormState, formData: FormData): Promise<BugReportFormState> {
  const body = new URLSearchParams();
  for (const [name, value] of formData) {
    if (typeof value === "string" && !name.startsWith("$ACTION")) body.append(name, value);
  }
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
    },
    body,
  });
  const result = await response.json();
  if (result.ok) return { ok: true, message: "Thanks. We'll look into it." };
  const values = Object.fromEntries([...new Set(body.keys())].map((name) => [name, body.getAll(name)]));
  return { ok: false, message: result.error.message, fields: result.error.fields, values };
}
```

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

```tsx
"use client";

import { useActionState } from "react";
import { type BugReportFormState, sendBugReportForm } from "./actions";

const initial: BugReportFormState = { ok: false, message: "" };

export function BugReportForm() {
  const [state, action, pending] = useActionState(sendBugReportForm, initial);

  if (state.ok) return <p>{state.message}</p>;

  return (
    <form action={action}>
      <label htmlFor="summary">Summary</label>
      <input id="summary" name="summary" type="text" placeholder="Export button does nothing on Safari" minLength={8} maxLength={140} required defaultValue={state.values?.summary?.[0]} />
      {state.fields?.summary ? <p>{state.fields.summary[0]}</p> : null}
      <label htmlFor="steps">Steps to reproduce</label>
      <textarea id="steps" name="steps" rows={6} placeholder="Open the dashboard, click Export, nothing downloads" minLength={20} maxLength={8000} required defaultValue={state.values?.steps?.[0]} />
      {state.fields?.steps ? <p>{state.fields.steps[0]}</p> : null}
      <label htmlFor="expected">What did you expect?</label>
      <textarea id="expected" name="expected" rows={3} maxLength={4000} defaultValue={state.values?.expected?.[0]} />
      {state.fields?.expected ? <p>{state.fields.expected[0]}</p> : null}
      <fieldset>
        <legend>How bad is it?</legend>
        <label><input type="radio" name="severity" value="blocker" required defaultChecked={state.values?.severity?.includes("blocker")} /> I can't continue</label>
        <label><input type="radio" name="severity" value="major" required defaultChecked={state.values?.severity?.includes("major")} /> Something important is broken</label>
        <label><input type="radio" name="severity" value="minor" required defaultChecked={state.values?.severity?.includes("minor")} /> Small or cosmetic</label>
      </fieldset>
      {state.fields?.severity ? <p>{state.fields.severity[0]}</p> : null}
      <label htmlFor="page_url">Page where it happened</label>
      <input id="page_url" name="page_url" type="url" defaultValue={state.values?.page_url?.[0]} />
      {state.fields?.page_url ? <p>{state.fields.page_url[0]}</p> : null}
      <label htmlFor="screenshot_url">Screenshot or recording link</label>
      <input id="screenshot_url" name="screenshot_url" type="url" aria-describedby="screenshot_url-help" defaultValue={state.values?.screenshot_url?.[0]} />
      <p id="screenshot_url-help">Optional. A link to an image or video, for example from a screenshot tool.</p>
      {state.fields?.screenshot_url ? <p>{state.fields.screenshot_url[0]}</p> : null}
      <label htmlFor="email">Email for follow-up questions</label>
      <input id="email" name="email" type="email" autoComplete="email" defaultValue={state.values?.email?.[0]} />
      {state.fields?.email ? <p>{state.fields.email[0]}</p> : null}
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {state.message && !state.fields ? <p role="alert">{state.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Report bug"}</button>
    </form>
  );
}
```

> A server action posts from your server's IP, so the per-visitor rate limit counts every visitor together. Use the Relaxed or Custom [rate limit](https://ikiform.com/docs/rate-limits.md) for forms you send through a server.

## Set it up

1. Create a form in the [dashboard](https://ikiform.com/dashboard) and copy its id.
2. Paste the field definition on the **Fields** tab.
3. Add your site under **Allowed domains**, plus `localhost` while you develop.
4. Set the rate limit to **Standard, 5 a minute per visitor** if visitors post from the browser. Testers sometimes file several bugs in a row. Standard allows that while stopping floods. With the server action, every post comes from your server's IP, so choose Relaxed or Custom instead.
5. Paste the code, send a test and check it arrives in the dashboard.

## The browser is recorded for you

Every submission records the visitor's user agent, origin, referrer and country alongside the fields. Webhooks include them under `data.meta`, so you don't need hidden fields for the page or the browser. The user agent tells you the browser and operating system, which is what most bug report forms ask for by hand.

## Screenshots

Ikiform doesn't accept file uploads yet. A post that contains a real file is refused with `422 files_not_supported`; an empty file input is ignored. Ask for a link, as this form does with `screenshot_url`, or upload the image to your own storage first and submit its URL. The [file upload form](https://ikiform.com/forms/file-upload) shows the pattern.

## Into your issue tracker

Point a webhook at a small relay that creates an issue from each submission: `summary` becomes the title, `steps` and `expected` the body, and `severity` a label. The [Linear and GitHub Issues guide](https://ikiform.com/blog/form-submissions-to-linear-and-github-issues.md) has the full relay with signature checks.

## Links and the spam score

This form can already send two links, `page_url` and `screenshot_url`. Three or more links across all fields add 2 to the spam score, so when both are filled in, one more link in the steps to reproduce is enough. With an identical repeat (+3) that reaches the threshold of 5, and the report is stored as spam without an error. Spam sends no webhooks or emails, so review spam submissions in the dashboard. Marking one as not spam sends its webhooks then.

## Sending extra context from your app

An in-app reporter can add details like the app version. With fields defined, any key that isn't in the definition is refused as `Unknown field`, so add a field for it first, for example a `text` field with the id `app_version`. Field ids are lowercase snake_case, up to 64 characters.

## Where to send it

Every submission can go to a signed [webhook](https://ikiform.com/docs/webhooks.md). These guides cover the tools that suit this form, with the relay code:

- [Create Linear or GitHub issues from form submissions](https://ikiform.com/blog/form-submissions-to-linear-and-github-issues.md)
- [Send form submissions to Slack](https://ikiform.com/blog/form-submissions-to-slack.md)
- [Send form submissions to Discord](https://ikiform.com/blog/form-submissions-to-discord.md)
- [Send form submissions to a Notion database](https://ikiform.com/blog/form-submissions-to-notion.md)

## Questions

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

Yes, through a webhook to a small relay that calls the Linear or GitHub API. Ikiform signs every delivery and retries failed ones for about 3 days.

### Can users attach screenshots?

Not as files; Ikiform refuses posts with files. Ask for a link to the screenshot, or upload it to your own storage first and submit the URL.

### Do I need to ask which browser they use?

No. Ikiform records the user agent with every submission and includes it in webhooks.
