Forms

7 fields

Bug report form

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.

Form ID

Preview

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

How bad is it?

Optional. A link to an image or video, for example from a screenshot tool.

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.

<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>
  • 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 and Spam protection.

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 idTypeRequiredAccepts
summarytextYes8 to 140 characters
stepstextareaYes20 to 8,000 characters
expectedtextareaNoUp to 4,000 characters
severityradioYesOne of: blocker, major, minor
page_urlurlNoAn http: or https: URL
screenshot_urlurlNoAn http: or https: URL
emailemailNoOne valid address
Field definition
{
  "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
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
app/bug-report/actions.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
"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 for forms you send through a server.

Set it up

  1. Create a form in the 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 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 has the full relay with signature checks.

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. These guides cover the tools that suit this form, with the relay code:

Questions

Point your form at Ikiform