Feedback Button in React
Prerequisites
Section titled “Prerequisites”- Nordva Launch account with a publishable API key (
nv_pub_...) - React 17+ or Next.js
Option A: Script-tag widget (zero code)
Section titled “Option A: Script-tag widget (zero code)”Drop this before </body> in your 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:
<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)
Section titled “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’s “Give feedback on this” does exactly this when both are on the page.
What each message carries
Section titled “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.
Triage
Section titled “Triage”Every submission has a status. Launch sets received, classified, routed, partial_routed or failed_classification; you set reviewed or resolved:
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
Section titled “Option B: Custom React component”For full UI control:
'use client';
import { useState } from 'react';
interface FeedbackButtonProps { publishableKey: string; userId?: string; /** Optional. Lets you reply to the person who sent the feedback. */ userEmail?: string;}
export function FeedbackButton({ publishableKey, userId, userEmail }: FeedbackButtonProps) { 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:
import { FeedbackButton } from '@/components/FeedbackButton';
export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html> <body> {children} <FeedbackButton publishableKey={process.env.NEXT_PUBLIC_NORDVA_KEY!} userId="user_abc123" /> </body> </html> );}Option C: A hosted form (a link)
Section titled “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
Section titled “View and triage feedback”In the Nordva Launch dashboard → Feedback, or via the API:
curl "https://api.nordva.dev/v1/feedback?classification=bug&status=open" \ -H "Authorization: Bearer nv_live_YOUR_SECRET_KEY"Reply from the inbox
Section titled “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
Section titled “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
Section titled “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
Section titled “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 is forwarded only when you switched that on.