Skip to content
11 min read Guides JavaScript

How to Add a Feedback Widget to a JavaScript Web App

Last updated

This guide covers the JavaScript SDK 1.0.1 build from npm. Later releases, tagged 1.1.0, 1.1.1, 1.2.0 and 2.1.0, exist as git tags on the SDK repository but are not on npm yet, so npm install feedbackkit-js gives you 1.0.1. The changelog sums up what 1.0 contains and what the tagged releases change. This page is the walkthrough for the registry build: what it does today, and how to ship a widget on it, comments, MRR and testing included.

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 no 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. It has no runtime dependencies, uses the platform's fetch, and ships its own TypeScript definitions, so there is no separate types package to add. It needs Node.js 18 or newer, and it publishes ES module and CommonJS builds. There is no script-tag build and no window.FeedbackKit global.

npm install feedbackkit-js
# or: yarn add feedbackkit-js / pnpm add feedbackkit-js

Create one client and keep it for the life of the page. The SDK does not create user ids, so the module below makes one for signed-out visitors and keeps it in localStorage:

// feedback.ts
import { FeedbackKit } from 'feedbackkit-js';

const VISITOR_KEY = 'trailmate.feedbackUserId';

function visitorId(): string {
  try {
    const saved = localStorage.getItem(VISITOR_KEY);
    if (saved) return saved;
    const id = crypto.randomUUID();
    localStorage.setItem(VISITOR_KEY, id);
    return id;
  } catch {
    // Storage blocked: the id lasts for this page view.
    return crypto.randomUUID();
  }
}

export const feedbackKit = new FeedbackKit({
  apiKey: 'sf_your_project_api_key',
  userId: visitorId(),
  timeout: 10000
});

export function currentUserId(): string {
  return feedbackKit.getUserId() ?? visitorId();
}

apiKey is required, and a missing key makes the constructor throw a plain Error before any request. timeout is in milliseconds and defaults to 30000. baseUrl defaults to https://feedbackkit.swiftly-workspace.com/api/v1, which reaches FeedbackKit's production server, where projects made in the admin app live, so leave it out. If a lockfile holds you on 1.0.0, pass that address yourself: 1.0.0's default names a host that does not resolve.

The id is used in two places. The client sends it as an X-User-Id header, which is how the server works out whether this person has voted on each row. And each write takes it in its body as userId: in 1.0.1 that field is required on feedback.create, votes.vote, votes.unvote and comments.create. After a sign-in, call feedbackKit.setUserId(accountId). Votes cast under the visitor id stay with that id.

Read the fields the way 1.0.1 returns them

This is the one 1.0.1 defect every widget runs into. The TypeScript types describe camelCase fields such as voteCount, hasVoted, commentCount and createdAt. The server answers in snake_case, and 1.0.1 passes its JSON through unchanged, so at runtime those values sit on vote_count, has_voted, comment_count and created_at, and the camelCase names read as undefined. One-word fields such as id, title, description, status and category are the same either way. The vote response is affected too: its count is on vote_count.

Read the two-word fields through a small helper that tries the camelCase name and falls back to the snake_case one, so the code keeps working on a later release that converts the keys itself (1.1.0 does):

// feedback.ts, continued
export function field<T>(item: object, camel: string, snake: string, fallback: T): T {
  const raw = item as Record<string, unknown>;
  return (raw[camel] ?? raw[snake] ?? fallback) as T;
}

The other route is to map every response to camelCase once, and read the typed fields from then on:

// feedback.ts, continued
export function camel<T>(value: unknown): T {
  if (Array.isArray(value)) {
    return value.map((item) => camel<unknown>(item)) as T;
  }
  if (value !== null && typeof value === 'object') {
    const entries = Object.entries(value).map(([key, item]) => [
      key.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase()),
      camel<unknown>(item)
    ] as const);
    return Object.fromEntries(entries) as T;
  }
  return value as T;
}

A key that is already camelCase passes through unchanged, so this helper keeps working on a release that converts the keys too. Either works. What does not work on 1.0.1 is reading item.voteCount directly.

Render the feedback list

feedbackKit.feedback.list() returns an array with the most votes at the top. It takes an optional status, category or includeMerged. 1.0.1 has no sort option, so for a Newest tab, sort the array yourself on created_at, which is an ISO 8601 string.

Render 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:

// board.ts
import {
  ConflictError,
  FeedbackKitError,
  FeedbackStatus,
  ForbiddenError,
  NotFoundError,
  type Feedback,
  type VoteResponse
} from 'feedbackkit-js';
import { feedbackKit, currentUserId, field } from './feedback';

const isClosed = (item: Feedback) =>
  item.status === FeedbackStatus.Completed || item.status === FeedbackStatus.Rejected;

export async function loadBoard(container: HTMLElement) {
  const items = await feedbackKit.feedback.list();
  container.textContent = '';
  for (const item of items) {
    container.append(renderRow(item));
  }
}

function renderRow(item: Feedback): HTMLLIElement {
  const row = document.createElement('li');

  const title = document.createElement('h3');
  title.textContent = item.title;

  const meta = document.createElement('p');
  meta.textContent = item.status + ' - ' +
    field(item, 'commentCount', 'comment_count', 0) + ' comments';

  let hasVoted = field(item, 'hasVoted', 'has_voted', false);
  let count = field(item, 'voteCount', 'vote_count', 0);

  const button = document.createElement('button');
  button.type = 'button';
  const draw = () => {
    button.textContent = (hasVoted ? 'Voted' : 'Vote') + ' (' + count + ')';
    button.setAttribute('aria-pressed', String(hasVoted));
  };
  draw();
  button.disabled = isClosed(item);

  button.addEventListener('click', async () => {
    button.disabled = true;
    try {
      const userId = currentUserId();
      const result: VoteResponse = hasVoted
        ? await feedbackKit.votes.unvote(item.id, { userId })
        : await feedbackKit.votes.vote(item.id, { userId });
      hasVoted = field(result, 'hasVoted', 'has_voted', !hasVoted);
      count = field(result, 'voteCount', 'vote_count', count);
    } catch (error) {
      if (error instanceof ConflictError) {
        hasVoted = true;
      } else if (error instanceof NotFoundError) {
        hasVoted = false;
      } else if (error instanceof FeedbackKitError && error.statusCode === 429) {
        meta.textContent = 'Too many votes right now. Try again in a minute.';
      } else if (!(error instanceof ForbiddenError)) {
        throw error;
      }
    } finally {
      draw();
      button.disabled = isClosed(item);
    }
  });

  row.append(title, meta, button);
  return row;
}

const list = document.querySelector('#fk-list');
if (list instanceof HTMLElement) {
  await loadBoard(list);
}

Voting and submitting

The server enforces the voting rules, and 1.0.1 maps each one to an error class you can branch on:

  • ConflictError is 409: that user id has already voted. Render it as voted rather than retrying.
  • ForbiddenError is 403: a vote on a Completed or Rejected request, or any write to an archived project. That is why the row above disables the button for those two statuses.
  • NotFoundError is 404: no such request, or no vote to remove.
  • A 429 has no class of its own in 1.0.1. It arrives as the base FeedbackKitError with statusCode 429 and code SERVER_ERROR, and the client does not retry.

The full set in 1.0.1 is ValidationError (400), AuthenticationError (401), PaymentRequiredError (402), ForbiddenError (403), NotFoundError (404), ConflictError (409) and NetworkError (status 0, a timeout or a dropped connection). Every other status is the base FeedbackKitError. All of them carry statusCode, code and the server's message.

Submitting is one call. The submitter's own vote is counted on the server, so a brand new item arrives with a vote count of 1. Start with the markup; the option values are the FeedbackCategory enum's values:

<form id="fk-submit">
  <label>Title <input name="title" required maxlength="200"></label>
  <label>Details <textarea name="description" required></textarea></label>
  <label>Type
    <select name="category">
      <option value="feature_request">Feature Request</option>
      <option value="bug_report">Bug Report</option>
      <option value="improvement">Improvement</option>
      <option value="other">Other</option>
    </select>
  </label>
  <label>Email for updates (optional) <input name="email" type="email"></label>
  <button>Send</button>
  <p id="fk-submit-status" role="status"></p>
</form>

Then send it:

// submit.ts
import { FeedbackCategory, ValidationError } from 'feedbackkit-js';
import { feedbackKit, currentUserId, field } from './feedback';
import { loadBoard } from './board';

export function wireSubmitForm(form: HTMLFormElement, list: HTMLElement) {
  const status = form.querySelector('#fk-submit-status');

  form.addEventListener('submit', async (event) => {
    event.preventDefault();
    const data = new FormData(form);
    const email = String(data.get('email') ?? '').trim();

    try {
      const created = await feedbackKit.feedback.create({
        title: String(data.get('title') ?? '').trim(),
        description: String(data.get('description') ?? '').trim(),
        category: data.get('category') as FeedbackCategory,
        userId: currentUserId(),
        userEmail: email || undefined
      });
      form.reset();
      if (status) {
        status.textContent = field(created, 'isHiddenOverLimit', 'is_hidden_over_limit', false)
          ? 'Saved. This board is over its plan limit, so the request is visible to you for now.'
          : 'Thanks, your request is on the board.';
      }
      await loadBoard(list);
    } catch (error) {
      if (error instanceof ValidationError && status) {
        status.textContent = error.message;
      } else {
        throw error;
      }
    }
  });
}

On the Free plan a project shows 10 requests. Past that, the write still succeeds and does not return 402: the item is saved with is_hidden_over_limit set, the submitter still sees it when their X-User-Id matches, and everyone else's list leaves it out. 1.0.1's type does not declare that field, which is why the form reads it through field.

Comments

feedbackKit.comments.list(id) returns a request's comments, oldest at the top, and comments.create adds one. A reply from your team carries is_admin on 1.0.1, read through the same helper, so you can label it:

// comments.ts
import type { Comment } from 'feedbackkit-js';
import { feedbackKit, currentUserId, field } from './feedback';

export async function loadComments(feedbackId: string, list: HTMLElement) {
  const comments: Comment[] = await feedbackKit.comments.list(feedbackId);
  list.textContent = '';
  for (const comment of comments) {
    const item = document.createElement('li');
    item.textContent = comment.content;
    if (field(comment, 'isAdmin', 'is_admin', false)) {
      const badge = document.createElement('strong');
      badge.textContent = 'Team';
      item.prepend(badge, ' ');
    }
    list.append(item);
  }
}

export async function addComment(feedbackId: string, content: string) {
  await feedbackKit.comments.create(feedbackId, {
    content,
    userId: currentUserId()
  });
}

Your team answers from the FeedbackKit admin app, so leave the optional isAdmin flag out of anything that runs in a browser. The comment count on each row comes with the list, so fetch comments when a row is opened rather than for every row.

MRR

MRR is what turns a vote count into a priority. Report what each signed-in user pays per month, and your team can sort requests by the total revenue of the people behind them. The JavaScript SDK takes a monthly figure, so convert a yearly plan yourself:

import { feedbackKit, currentUserId } from './feedback';

type TrailmatePlan = { price: number; period: 'month' | 'year' } | null;

export async function reportPlan(plan: TrailmatePlan) {
  await feedbackKit.users.register({
    userId: currentUserId(),
    mrr: plan === null ? undefined : plan.period === 'year' ? plan.price / 12 : plan.price
  });
}

Call it after sign-in and whenever the subscription changes. Each call replaces the stored amount, so a call without mrr clears it. That is what you want for a cancelled plan, so pass the amount on every call for a paying user.

Mounting it in any framework

Because the SDK is an async client, the framework-agnostic shape is a single mount function that owns a container element:

import { loadBoard } from './board';

export function mountFeedbackWidget(container: HTMLElement) {
  loadBoard(container).catch((error: unknown) => {
    container.textContent = error instanceof Error ? error.message : 'Request failed';
  });

  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 a provider, hooks and ready-made views on top. Those components exist for React Native, not for the DOM. If you are building for mobile, start at the React Native guide.

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, and register SDK users. 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. Editing a title, changing a status, deleting or merging items, reading project settings or members all require a signed-in team account. A leaked project key does not get you a dashboard. Reads made with the key also leave out each submitter's email address and the revenue behind each request; your team sees those in the admin app.

One consequence is worth designing around: userId is 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. 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.

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. 1.0.1 reports that as a NetworkError. Three ways to ship anyway:

  1. Call the SDK from your own server. The same package runs on Node 18 or newer. Put a thin route in your backend, call feedback.list() or create() there, and let your frontend talk to your own origin. This also keeps the key server-side and lets you set userId from your authenticated session instead of trusting the browser.
  2. Use the hosted embed pages. FeedbackKit serves ready-made pages you can drop into an iframe, in the shape /embed/<apiKey>/kanban, /embed/<apiKey>/roadmap and /embed/<apiKey>/form. They render on FeedbackKit's origin, so CORS never enters into it. The setup, the sizes and the theme are in our guide to embedding a feedback board and roadmap on your website.
  3. 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 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 reads but does not drive. There are six statuses: pending, approved, in_progress, testflight, completed and rejected. The last two close voting on the server. Here is how incoming requests look on the FeedbackKit side, with sample data from Trailmate, a fictional hiking app:

Where the requests land

Requests filed from your web app arrive in the same project as the ones from your other apps, under six statuses: Pending, Approved, In Progress, TestFlight, Completed and Rejected.

Voters can ask for an email when the status changes by passing email and notifyStatusChange: true to votes.vote() along with their userId. The server saves that opt-in when the address is non-empty and the project owner is on the Team plan. Below Team the vote still succeeds and the opt-in is dropped. A submitter who filled in userEmail is emailed when their request moves to a status in the project's email list.

On the triage side, Pro and up can send an item to GitHub, Linear, ClickUp, Notion, Monday.com, Trello, Airtable, Asana or Basecamp, and post new ones to Slack. 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 on the server.

Testing tips

Check the key from Node before you build the UI. The same package runs in Node.js 18 or newer, so a short script tells you whether the key and the server agree:

// check-key.mjs
import { FeedbackKit } from 'feedbackkit-js';

const client = new FeedbackKit({ apiKey: process.env.FEEDBACKKIT_KEY });
console.log((await client.feedback.list()).length, 'requests');

An AuthenticationError there means the key is wrong or belongs to another server.

Keep test requests off the real board. Everything you file while testing lands in the project your key belongs to. Pro includes two projects, so a development build can use a key from a second one, read from an environment variable at build time. Otherwise, delete test requests in the admin app afterwards.

Start over as a new visitor. Remove the trailmate.feedbackUserId entry from local storage in the browser's developer tools, or open a private window.

Troubleshooting

  • Counts show undefined, every button says Vote. The code reads voteCount or hasVoted directly. On 1.0.1 those values are on vote_count and has_voted. Use the field helper above.
  • The request fails with a NetworkError, 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 both, so check both before assuming a typo. Copy the key fresh from project settings.
  • ValidationError on a write. Usually an empty title, description or userId, or a malformed email. The message says which.
  • The list comes back without something you just submitted, for everyone but you. That is the Free plan's 10-request cap described above, not a bug. Check the plan before you debug the code. Merged duplicates are also left out of the default list; pass includeMerged: true to include them.
  • A vote returns 409. That user has already voted. It is a state you should render, not an error you should retry.
  • A vote returns 429. Feedback creates, votes and comments each allow 120 requests per minute for one API key on one server process. Wait and try again; the SDK will not retry for you.

For the 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.

Every plan includes the JavaScript SDK. Start free, copy your project key, and the widget can be on your site this week.

Keep reading

Collect feedback in your own app

FeedbackKit ships SDKs for Swift, Kotlin, React Native, Flutter and JavaScript, and syncs what your users ask for into the tracker your team already uses.