Skip to content

Feedback Button in React

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

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:

AttributeDefaultDescription
data-keyrequiredYour publishable key (nv_pub_...)
data-positionbottom-rightbottom-right or bottom-left
data-accent#F59E0BButton, submit and selection color (hex)
data-user-idnoneYour app’s user identifier, stored for filtering
data-user-emailnoneThe signed-in user’s email, so you can reply to their feedback
data-ask-emailfalseSet to true to add an optional “Email (if you want a reply)” field. Ignored when data-user-email is set
data-typesoffType 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-ratingoffstars (one to five stars) or emoji (five faces, unhappy to happy). Optional to answer; stored as 1 to 5 either way
data-labelFeedbackText on the floating button (up to 40 characters)
data-titleSend feedbackHeading of the modal (up to 80)
data-placeholderTell us what you think...Placeholder in the message box (up to 120)
data-send-labelSendText on the submit button (up to 30)
data-thanksThanks, feedback received.Shown after a message is sent (up to 160)
data-show-onevery pageShow 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-onno pageNever show the button on these paths, same patterns. Wins over data-show-on
data-devicesevery deviceA 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-delay0Seconds to wait before the button appears
data-scroll0Show 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.

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.

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.

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

Terminal window
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.

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:

app/layout.tsx
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>
);
}

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:

OptionExample
types?types=bug,idea,billingWhich type buttons, in order. ?types=false for none
rating?rating=stars or ?rating=emojiA 1 to 5 rating
email?email=1An email field for a reply
theme?accent=%23e11d48&mode=darkSame 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).

In the Nordva Launch dashboard → Feedback, or via the API:

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

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:

channelReachesNeeds
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.
notificationA 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.
noneNobody: 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.

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.

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.

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.