# Feedback Button in React

> Add a Nordva Launch feedback button to your React or Next.js app, with the script-tag widget or a custom component.

## Prerequisites

- Nordva Launch account with a publishable API key (`nv_pub_...`)
- React 17+ or Next.js

## Option A: Script-tag widget (zero code)

Drop this before `</body>` in your HTML:

```html
<script src="https://cdn.nordva.dev/v1/feedback.js"
  data-key="nv_pub_live_YOUR_KEY"
  data-position="bottom-right"
  data-accent="#F59E0B"
  data-user-id="OPTIONAL_USER_ID"
  data-user-email="OPTIONAL_USER_EMAIL">
</script>
```

A floating "Feedback" button appears in the corner. Clicking it opens a drop-up modal. Submitted feedback is classified automatically and routed to your configured destinations. It works on every plan: Free takes 50 messages a month per project, sorted by keywords and routed by email; on Indie and up, Claude Haiku sorts it and writes a one-line summary, and Slack, Linear and GitHub are available too.

Every option below is off until you set it, so the tag above looks the same as it always has. Here is one with all of them:

```html
<script src="https://cdn.nordva.dev/v1/feedback.js"
  data-key="nv_pub_live_YOUR_KEY"
  data-types="bug,idea,question"
  data-rating="stars"
  data-label="Help us improve"
  data-title="What should we fix or build?"
  data-placeholder="Tell us what happened..."
  data-send-label="Send it"
  data-thanks="Thank you! We read every message."
  data-show-on="/app/*,/pricing"
  data-hide-on="/app/billing/*"
  data-devices="desktop,tablet"
  data-delay="10"
  data-scroll="50"
  async>
</script>
```

**Attributes:**

| Attribute | Default | Description |
|---|---|---|
| `data-key` | required | Your publishable key (`nv_pub_...`) |
| `data-position` | `bottom-right` | `bottom-right` or `bottom-left` |
| `data-accent` | `#F59E0B` | Button, submit and selection color (hex) |
| `data-user-id` | none | Your app's user identifier, stored for filtering |
| `data-user-email` | none | The signed-in user's email, so you can reply to their feedback |
| `data-ask-email` | `false` | Set to `true` to add an optional "Email (if you want a reply)" field. Ignored when `data-user-email` is set |
| `data-types` | off | Type buttons above the message: a comma list of `bug`, `idea`, `praise`, `question`, `billing`, or `true` for `bug,idea,praise,question`. Picking one is optional |
| `data-rating` | off | `stars` (one to five stars) or `emoji` (five faces, unhappy to happy). Optional to answer; stored as 1 to 5 either way |
| `data-label` | `Feedback` | Text on the floating button (up to 40 characters) |
| `data-title` | `Send feedback` | Heading of the modal (up to 80) |
| `data-placeholder` | `Tell us what you think...` | Placeholder in the message box (up to 120) |
| `data-send-label` | `Send` | Text on the submit button (up to 30) |
| `data-thanks` | `Thanks, feedback received.` | Shown after a message is sent (up to 160) |
| `data-show-on` | every page | Show the button only on these paths: a comma list, `*` matches anything, so `/blog/*` is every post (but not `/blog` itself). Matched against the path only, without the query string; a trailing slash does not matter |
| `data-hide-on` | no page | Never show the button on these paths, same patterns. Wins over `data-show-on` |
| `data-devices` | every device | A comma list of `desktop`, `tablet`, `mobile`, decided by the width of the window: under 640 px is `mobile`, 640 to 1023 px `tablet`, 1024 px and wider `desktop`. Checked again when the window is resized |
| `data-delay` | `0` | Seconds to wait before the button appears |
| `data-scroll` | `0` | Show the button once this percentage of the page has been scrolled into view. A page too short to scroll counts as seen |

The button appears only while every rule you set holds. Paths are checked again when a single-page app changes the address (`history.pushState`, `replaceState` and the back button), so `data-show-on` and `data-hide-on` follow client-side navigation; an open modal closes on a page where the button is hidden.

Custom text is always set as plain text, never as HTML, so a label cannot inject markup.

### Open it from code (1.4.0)

`window.NordvaFeedback.open()` opens the form, and `window.NordvaFeedback.close()` closes it, so your own "Send feedback" link or menu item can use it. `open({ changelog_entry_id, title })` opens it about a changelog entry: the title shows above the text box and the message is sent with `changelog_entry_id`, which links it to the entry in your inbox. The [changelog widget](/guides/changelog-embed/#give-feedback-on-this)'s "Give feedback on this" does exactly this when both are on the page.

### What each message carries

The widget sends the message, the page's URL and, when you set them, the user id and email. With `data-types`, the chosen type goes as `category_hint`: `bug` as `bug`, `idea` as `feature_request`, `praise` as `praise`, `question` as `support`, `billing` as `billing`. Sorted by keywords (Free, or past a plan's monthly AI allowance), the type the person chose is the category, with confidence 1; the AI classifier takes it as a signal and may still disagree. With `data-rating`, the answer goes as `rating`, a whole number from 1 to 5.

It also sends two short labels, worked out in the visitor's browser: `browser`, the browser's name and major version (such as `Safari 17` or `Chrome 128`), and `device`, one of `desktop`, `mobile` or `tablet` (from the browser's own report, not the window width). The full user agent string never leaves the page, and the API refuses anything longer than a name and a version. Both show in the dashboard next to the page URL and come back on `GET /v1/feedback` as `browser` and `device`, with `rating` beside them (null when not given).

A message is up to 5,000 characters: the widget stops typing there and shows a count near the end (1.5.0), and a refused message shows the API's reason. Your own code can send the same fields to `POST /v1/feedback`: `rating` (1 to 5), `browser` (a name and a major version) and `device` (`desktop`, `mobile` or `tablet`), all optional. `GET /v1/insights` reports the average rating for the period as `feedback.rating`.

The email is optional in every form. When one is given it is returned as `email` on `GET /v1/feedback`, shown under **Reply to** in the dashboard and included in the MCP `list_feedback` results. It is not forwarded to your routing destinations, so it never lands in a public GitHub issue.

If your site sends a `Content-Security-Policy` header, allow `https://cdn.nordva.dev` in `script-src` and `https://api.nordva.dev` in `connect-src`: [Content Security Policy](/guides/content-security-policy).

## Triage

Every submission has a `status`. Launch sets `received`, `classified`, `routed`, `partial_routed` or `failed_classification`; you set `reviewed` or `resolved`:

```bash
curl -X PATCH https://api.nordva.dev/v1/feedback/fb_01J... \
  -H "Authorization: Bearer nv_live_YOUR_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "status": "resolved" }'
```

Send `classification` in the same call to correct the classifier. A correction gets confidence 1 and is not routed again, so it never creates a second issue. `GET /v1/feedback?status=open` lists everything not yet reviewed or resolved. From an MCP client, ask your assistant to mark an item resolved; it uses `update_feedback`.

## Option B: Custom React component

For full UI control:

```tsx
'use client';

interface FeedbackButtonProps {
  publishableKey: string;
  userId?: string;
  /** Optional. Lets you reply to the person who sent the feedback. */
  userEmail?: string;
}

  const [open, setOpen] = useState(false);
  const [message, setMessage] = useState('');
  const [status, setStatus] = useState<'idle' | 'loading' | 'done' | 'error'>('idle');

  async function submit() {
    if (message.trim().length < 5) return;
    setStatus('loading');

    const res = await fetch('https://api.nordva.dev/v1/feedback', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${publishableKey}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        feedback_text: message.trim(),
        user_id: userId,
        email: userEmail,
        page_url: window.location.href,
      }),
    });

    if (res.ok) {
      setStatus('done');
      setTimeout(() => { setOpen(false); setStatus('idle'); setMessage(''); }, 2000);
    } else {
      setStatus('error');
    }
  }

  return (
    <>
      <button
        onClick={() => setOpen(o => !o)}
        style={{ position: 'fixed', bottom: 20, right: 20 }}
      >
        Feedback
      </button>

      {open && (
        <div style={{ position: 'fixed', bottom: 72, right: 20, width: 280, background: '#fff', borderRadius: 12, boxShadow: '0 8px 32px rgba(0,0,0,0.18)', padding: 20 }}>
          <button onClick={() => setOpen(false)} style={{ position: 'absolute', top: 10, right: 14, background: 'none', border: 'none', cursor: 'pointer', fontSize: 18 }}>×</button>
          <h3 style={{ margin: '0 0 12px', fontSize: 14 }}>Send feedback</h3>

          {status === 'done' ? (
            <p style={{ color: '#10b981', fontSize: 13 }}>Thanks, feedback received.</p>
          ) : (
            <>
              <textarea
                value={message}
                onChange={e => setMessage(e.target.value)}
                placeholder="Tell us what you think..."
                style={{ width: '100%', boxSizing: 'border-box', minHeight: 80, padding: 10, fontSize: 13, borderRadius: 6, border: '1px solid #e5e7eb', resize: 'vertical' }}
              />
              <button
                onClick={submit}
                disabled={status === 'loading'}
                style={{ marginTop: 10, width: '100%', padding: 9, background: '#F59E0B', color: '#000', border: 'none', borderRadius: 6, fontWeight: 600, cursor: 'pointer' }}
              >
                {status === 'loading' ? 'Sending...' : 'Send'}
              </button>
              {status === 'error' && <p style={{ color: '#ef4444', fontSize: 12, marginTop: 8 }}>Failed. Please try again.</p>}
            </>
          )}
        </div>
      )}
    </>
  );
}
```

Use it in your layout:

```tsx
// app/layout.tsx

  return (
    <html>
      <body>
        {children}
        <FeedbackButton
          publishableKey={process.env.NEXT_PUBLIC_NORDVA_KEY!}
          userId="user_abc123"
        />
      </body>
    </html>
  );
}
```

## Option C: A hosted form (a link)

No code on your site: a page of its own at `https://widget.nordva.dev/feedback/<your-slug>`, for an email footer, a support page or a QR code. It is off until you switch it on, in the dashboard under **Feedback Routing** → **Hosted form**, or with `PATCH /v1/project` and `{ "hosted_feedback": true }` (an AI agent uses `update_project`). Off again, the link shows "not switched on".

The page has type buttons (Bug, Idea, Praise, Question) and carries the browser and device labels, like the widget. Options in the link:

| Option | Example | |
|---|---|---|
| `types` | `?types=bug,idea,billing` | Which type buttons, in order. `?types=false` for none |
| `rating` | `?rating=stars` or `?rating=emoji` | A 1 to 5 rating |
| `email` | `?email=1` | An email field for a reply |
| theme | `?accent=%23e11d48&mode=dark` | Same as the other hosted widgets |

A Cloudflare Turnstile check and a limit of 10 messages per visitor per hour guard it, and messages count toward the monthly feedback limit. They are stored and sent as the `feedback.created` webhook; to also send them to your routing destinations, tick **Send to the destinations below** (`"route_hosted_feedback": true`).

## View and triage feedback

In the [Nordva Launch dashboard](https://app.nordva.dev) → **Feedback**, or via the API:

```bash
curl "https://api.nordva.dev/v1/feedback?classification=bug&status=open" \
  -H "Authorization: Bearer nv_live_YOUR_SECRET_KEY"
```

## Reply from the inbox

Open the item in the dashboard and write under **Reply**, or send `POST /v1/feedback/<id>/reply` with `{ "body": "…", "channel": "…" }` (an AI agent uses `reply_to_feedback`). Pick how it reaches them:

| `channel` | Reaches | Needs |
|---|---|---|
| `email` (default) | One plain-text email with your words and theirs quoted under them, from your project's name. Their answer goes to your project's reply-to address (Settings), or to `reply_to` if you send one. With neither, nothing is sent (`REPLY_TO_REQUIRED`): your sign-in address is never used. | The email they left. |
| `notification` | A notification in that person's bell, titled with `subject` (default "Reply to your feedback"), at most 2,000 characters, category `feedback`. Counts against the month's notifications. | The `user_id` the feedback was sent with (`data-user-id` on the widget). Otherwise `FEEDBACK_NO_USER_ID`. |
| `none` | Nobody: the reply is kept on the item, for when you answered somewhere else. | Nothing. |

The item becomes reviewed, and the reply stays listed on it with its `channel`.

At most 20 replies a day per project. Past that, or when the day's email allowance is used up, nothing is sent (`REPLY_LIMIT_REACHED`) and the address is right there to answer from your own email.

## Many at once, and CSV

Tick items in the inbox to mark them reviewed or resolved, or set their category, together; the API takes up to 100 ids in `PATCH /v1/feedback/bulk` (`bulk_update_feedback` over MCP). **Export CSV** (Indie and up) downloads the inbox as it is filtered: id, date, category, status, rating, the text, summary, email, user id, page, browser, device, the entry it shipped in and the number of replies. `GET /v1/feedback/export` gives the same file as a link that works for 15 minutes.

## Tell people when it ships

When feedback turns into a changelog entry, link the two: in the dashboard, pick the entry under **Shipped in** on the feedback item, or send `{ "linked_entry_id": "ce_…" }` to `PATCH /v1/feedback/<id>`. An AI agent can do it with `update_feedback`.

When that entry is published, the item is marked resolved and, if the person left an email with their feedback, they get one message: what they asked, the entry's title and its link. It is sent within a few minutes of publishing, once, and never for an entry that is still scheduled. Nothing is sent to people who left no email.

## Route feedback to Slack or Linear

In the dashboard → **Feedback Routing**, connect a destination and tick the categories it should get: for example bugs to Linear, and ideas and praise to Slack. With every category ticked (the default) it gets everything. Over the API, send `categories` beside the destination's fields, such as `{ "destination": "linear", …, "categories": ["bug"] }`; left out, every category is sent. Feedback from the [hosted form](#option-c-a-hosted-form-a-link) is forwarded only when you switched that on.

Source: https://docs.nordva.dev/guides/feedback-react/
