How to Add a Feedback Widget to a JavaScript Web App
By the end of this guide you will have a feedback panel inside your own web app: a list of requests sorted by vote count, a vote button on each row, and a form that submits a new one. It will be your markup and your CSS, because the FeedbackKit JavaScript SDK is a typed API client, not a drop-in UI component. It ships zero DOM code. Nothing will fight your design system, and nothing will render for you either.
One constraint below decides your architecture rather than your styling, and it is cheaper to read now than to discover at deploy time: the API's allowed browser origins are a fixed server-side list.
Install and initialise
The package is feedbackkit-js on npm. It has no runtime dependencies, uses native fetch, and ships its own TypeScript definitions, so there is no separate types package to add.
npm install feedbackkit-js
# or: yarn add feedbackkit-js / pnpm add feedbackkit-js
Create one client and keep it around for the life of the page.
import { FeedbackKit, FeedbackCategory, FeedbackSort } from 'feedbackkit-js';
const client = new FeedbackKit({
apiKey: 'sf_your_project_api_key', // required
userId: 'user_12345', // optional
timeout: 10000 // optional, defaults to 30000
});
Only apiKey is required; the constructor throws immediately if it is missing. timeout defaults to 30000 ms. There is also a baseUrl option, which defaults to the production API, so you normally leave it alone.
userId is optional but does real work. When it is set, the SDK sends an X-User-Id header, and the server uses that to fill in hasVoted on every item it returns. Without it, every row comes back as hasVoted: false and your vote buttons cannot show state. You can change it at runtime with client.setUserId(id) after login and client.setUserId(undefined) on logout.
On script tags: the package publishes ES module and CommonJS builds. There is no UMD or IIFE bundle and no global, so <script src="..."> followed by window.FeedbackKit does not exist. A module script pointed at any CDN that serves ESM from npm will load it:
<script type="module">
import { FeedbackKit } from 'https://esm.sh/feedbackkit-js';
const client = new FeedbackKit({ apiKey: 'sf_your_project_api_key' });
const items = await client.feedback.list();
console.log(items.length);
</script>
That is a property of the CDN, not something FeedbackKit hosts or tests — and loading the module is a separate question from whether its API calls are allowed from your page.
Render the feedback list
client.feedback.list() returns an array, sorted by vote count descending by default. It takes optional filters:
// Everything, most-voted first
const items = await client.feedback.list();
// Only open bug reports, newest first
const bugs = await client.feedback.list({
category: FeedbackCategory.BugReport,
sort: FeedbackSort.Newest
});
sort accepts Votes, Newest, Oldest and Comments. status and category filter server-side. Each item gives you id, title, description, status, category, voteCount, hasVoted, commentCount, createdAt and updatedAt.
Render it with textContent rather than innerHTML. Titles and descriptions are written by your users, and this is the one place a feedback widget can hand you an XSS bug:
const list = document.querySelector('#fk-list');
function renderList(items) {
list.textContent = '';
for (const item of items) {
const row = document.createElement('li');
const title = document.createElement('h3');
title.textContent = item.title;
const meta = document.createElement('p');
meta.textContent = item.status + ' - ' + item.voteCount +
' votes - ' + item.commentCount + ' comments';
const button = document.createElement('button');
button.textContent = item.hasVoted ? 'Voted' : 'Vote';
button.disabled = item.status === 'completed' || item.status === 'rejected';
button.addEventListener('click', () => toggleVote(item, button));
row.append(title, meta, button);
list.append(row);
}
}
Voting and submitting
Voting is two calls and three error cases worth handling explicitly. The SDK throws typed errors, so you can branch on the class instead of parsing status codes:
import { ConflictError, ForbiddenError } from 'feedbackkit-js';
async function toggleVote(item, button) {
try {
const result = item.hasVoted
? await client.votes.unvote(item.id, { userId: 'user_12345' })
: await client.votes.vote(item.id, { userId: 'user_12345' });
item.hasVoted = result.hasVoted;
item.voteCount = result.voteCount;
button.textContent = result.hasVoted ? 'Voted' : 'Vote';
} catch (error) {
if (error instanceof ConflictError) {
// 409 - this user has already voted on this item
} else if (error instanceof ForbiddenError) {
// 403 - completed or rejected feedback, or an archived project
} else {
throw error;
}
}
}
The full set is ValidationError (400), AuthenticationError (401), PaymentRequiredError (402), ForbiddenError (403), NotFoundError (404), ConflictError (409) and NetworkError, all extending FeedbackKitError with statusCode and code properties.
Submitting is one call. Note the last line — the submitter's own vote is cast automatically, so a brand new item arrives with a vote count of 1:
const created = await client.feedback.create({
title: form.title.value,
description: form.description.value,
category: FeedbackCategory.FeatureRequest,
userId: 'user_12345',
userEmail: form.email.value || undefined // optional
});
// created.voteCount === 1 - the creator's vote is already counted
Comments work the same way through client.comments.list(id) and client.comments.create(id, { content, userId }).
Mounting it in any framework
Because the SDK is just an async client, the framework-agnostic shape is a single mount function that owns a container element:
export function mountFeedbackWidget(container, { apiKey, userId }) {
const client = new FeedbackKit({ apiKey, userId });
client.feedback.list()
.then((items) => renderList(container, items))
.catch((error) => renderError(container, error));
return {
destroy() { container.textContent = ''; }
};
}
In React you call it from an effect and return destroy as the cleanup. In Vue, onMounted and onUnmounted. In Svelte, an action. The client itself does not care.
This is the main difference from the React Native SDK. feedbackkit-react-native is a separate package that takes feedbackkit-js as a peer dependency and adds the UI layer on top: a provider, ready-made views such as FeedbackList and VoteButton, hooks like useFeedbackList and useVote, theming and optimistic vote updates. Those components exist for React Native, not for the DOM. If you are building for mobile, start at the React Native docs.
The API key is in the browser. What that means.
If the widget runs client-side, your project API key is in your bundle. It is public by definition, and no amount of obfuscation changes that. So the honest question is not how to hide it, but what it grants.
The key travels as an X-API-Key header. The server uses it to look up which project the request belongs to. It identifies a project; it does not authenticate a person. With it, a caller can read that project's feedback and comments, create feedback, vote and unvote, comment, register SDK users and track events. FeedbackKit is confident enough in that boundary to do it publicly: our own roadmap page ships its project API key in the page source.
What the key cannot do is anything on the admin side. The admin API authenticates with Bearer tokens, on separate routes. Editing a title, changing a status, deleting or merging items, reading project settings, members or analytics all require an authenticated admin user. A leaked project key does not get you a dashboard.
Two consequences are worth designing around rather than ignoring:
userIdis asserted, not verified. Whatever string you pass is taken at face value. Anyone holding the key can vote as any identifier they like, once each. Treat vote counts as a strong signal about your users, not as a tamper-proof election.- Submitter emails come back in the list response. If a feedback item was created with
userEmail, that address is part of the payload returned to any caller with the key. If you auto-fill that field from your session, you are making those addresses retrievable by anyone who reads your bundle. Collect it only when you want it, and say so in your UI.
If a key is being abused, the project owner can regenerate it from project settings, which invalidates the old one.
Where the request is allowed to come from
Here is the constraint flagged at the top. The API's CORS configuration is a fixed allowlist of origins: FeedbackKit's own domains, plus a handful of localhost ports for local development. Arbitrary customer domains are not on it, and there is no project setting that adds one — it is server-side configuration.
In practice that means a fetch from your production origin gets no usable Access-Control-Allow-Origin back, and the browser rejects the response before your code sees a status code. Three ways to ship anyway:
- Call the SDK from your own server. The same package runs on Node 18+. Put a thin route in your backend, call
client.feedback.list()orcreate()there, and let your own frontend talk to your own origin. This also keeps the key server-side and lets you setuserIdfrom your authenticated session instead of trusting the client — which fixes the "asserted, not verified" problem above at the same time. - Use the hosted embed pages. FeedbackKit serves ready-made pages you can drop into an iframe, in the shape
/embed/<apiKey>/kanban,/embed/<apiKey>/roadmapand/embed/<apiKey>/form. They render on FeedbackKit's origin, so CORS never enters into it. Our roadmap is two of them in a page. - Get your origin added to the allowlist. It is a server change, so it is not instant and not self-service.
Option 1 is the one we would build on. It costs you one route and it is the only version where the identity attached to a vote is one you actually control.
What happens after a submit
Once the write lands, the item enters a lifecycle your widget only reads. There are six statuses: pending, approved, in_progress, testflight, completed and rejected. The last two block further voting server-side, which is why the render loop above disables the button for them — the API returns 403 regardless, but a disabled button is a better explanation than a failed request.
Voters can opt into email on status changes by passing email and notifyStatusChange: true to votes.vote(). Those notifications require the Team tier.
On the triage side, an item can sync to an issue tracker: GitHub, Notion, ClickUp, Linear, Monday.com, Trello, Airtable, Asana or Basecamp, plus Slack for notifications and HubSpot, Salesforce and Email Campaign on the CRM side. Capabilities are not uniform — status sync is broad, comment sync is not, and GitHub has no comment sync today. The integrations page has the per-provider grid. None of this needs anything from your widget: it is configured once in the project and runs server-side.
Troubleshooting
- The request fails with no status code, and the console mentions CORS. Your origin is not on the allowlist. See the three options above. This is by far the most common wall.
AuthenticationError, 401. The key is missing, mistyped, or belongs to a project that has been suspended. The server returns the same 401 for an unknown key and a suspended project, so check both before assuming a typo. Copy the key fresh from project settings.- The list comes back empty, but you just submitted something. On the Free tier, feedback past the per-project cap is still saved and returns 2xx — it is simply hidden from reads rather than refused. The creator keeps seeing their own item when
userIdis set, which is exactly why this looks like "it works for me, not for anyone else". Check the plan before you debug the code. Merged duplicates are also excluded from the default list. - Every row shows
hasVoted: false. NouserIdon the client, so noX-User-Idheader, so the server has no one to compute the flag for. - A vote returns 409. That user has already voted. It is a state you should render, not an error you should retry.
For the full method-by-method reference, see the JavaScript SDK docs. If you would rather talk to the API directly instead of through a client, the REST reference covers the same surface, and the docs index lists every platform.