JavaScript Feedback Widget Tutorial: JavaScript SDK 1.0
This tutorial covers JavaScript SDK 1.0. If you are starting today, use the current JavaScript tutorial, which covers the latest release. What 1.0 contains is summed up in Introducing the FeedbackKit JavaScript SDK.
By the end of this guide your web app has a panel where people read the requests others have filed, vote for the ones they want, comment, and file their own. The SDK is a typed API client and ships no DOM code, so the panel is your markup and your CSS. Your part is the package, a client, a user id and the rendering.
The short version
- Install
feedbackkit-jsfrom npm. It needs Node.js 18 or newer to build and runs in current browsers. - Create one
FeedbackKitclient with your project's API key and a user id. - The SDK does not create user ids. Generate one for signed-out visitors and keep it in
localStorage. - In 1.0 the server answers in snake_case while the types describe camelCase. Map each response once, with the helper in step 3.
- Render with
textContent, neverinnerHTML: titles, descriptions and comments are written by your users.
What you need
You need a FeedbackKit project and its API key. Create the project in the FeedbackKit admin app for iPhone, iPad or Mac, and copy the key from the project's settings. Keys start with sf_. The examples are TypeScript with no framework, so they drop into a Vite, webpack or plain ES module setup, and the same calls work in React, Vue or Svelte.
The examples use Trailmate, a fictional hiking app whose users keep asking for offline maps. Trailmate has a website with a signed-in area, and the panel lives there.
1. Install the package
npm install feedbackkit-js
# or: yarn add feedbackkit-js / pnpm add feedbackkit-js
npm installs 1.0.1, the release this tutorial is tested against. It has the same calls as 1.0.0, with its type re-exports declared with export type and the production server as its default address. The package has no runtime dependencies, uses the platform's fetch, and includes its TypeScript definitions, so there is no separate types package to add. It ships ES module and CommonJS builds, and no script-tag build.
2. Create the client and a user id
Every request, vote and comment belongs to a user id, and the SDK leaves the choice of id to you. For a signed-in user, use your own account id. For a signed-out visitor, generate one and keep it, so a returning visitor keeps their votes. Put the client and the id in one module:
// feedback.ts
import { FeedbackKit, FeedbackStatus, type Feedback } 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_api_key',
userId: visitorId()
});
export function currentUserId(): string {
return feedbackKit.getUserId() ?? visitorId();
}
export function signIn(accountId: string) {
feedbackKit.setUserId(accountId);
}
export function signOut() {
feedbackKit.setUserId(visitorId());
}
export const isClosed = (item: Feedback) =>
item.status === FeedbackStatus.Completed || item.status === FeedbackStatus.Rejected;
The id is used in two places. The client sends it as an X-User-Id header on every call, which is how the server fills in whether this person has voted on each request. And each write takes it in its body, as userId. currentUserId() keeps the two in step. Votes cast under a visitor id stay with that id after a sign-in: the SDK does not move them.
With 1.0.1 the default baseUrl is the production server, where projects made in the admin app live, so the example leaves it out. If you are on 1.0.0, pass baseUrl: 'https://feedbackkit.swiftly-workspace.com/api/v1', because its default named a host that does not resolve. The constructor also takes timeout, in milliseconds, which defaults to 30 seconds.
The key travels as an X-API-Key header, so anyone who opens your bundle or the network tab can read it. It is a client key for one project, made to ship in front-end code, and it covers end-user actions: reading, filing, voting and commenting. Your team's actions, such as changing a status or deleting a request, need a signed-in team account.
3. Map the responses to camelCase
The SDK's types describe fields such as voteCount, hasVoted, commentCount and createdAt. The server sends vote_count, has_voted, comment_count and created_at, and 1.0 returns the JSON as it arrives, so the camelCase names read as undefined. One-word fields such as id, title, status and category are unaffected. Add a helper to the same module and pass every response through it:
// 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 the helper keeps working if a later release converts the keys itself.
4. Render the list
feedback.list() returns the project's requests with the most votes at the top. It takes an optional status or category to narrow the list. Here is a list with a vote button per row:
// board.ts
import { FeedbackStatus, type Feedback } from 'feedbackkit-js';
import { feedbackKit, camel, isClosed } from './feedback';
import { toggleVote, drawVote } from './votes';
const statusLabels: Record<FeedbackStatus, string> = {
[FeedbackStatus.Pending]: 'Pending',
[FeedbackStatus.Approved]: 'Approved',
[FeedbackStatus.InProgress]: 'In Progress',
[FeedbackStatus.TestFlight]: 'TestFlight',
[FeedbackStatus.Completed]: 'Completed',
[FeedbackStatus.Rejected]: 'Rejected'
};
export async function loadBoard(container: HTMLElement) {
const items = camel<Feedback[]>(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 = statusLabels[item.status] + ' - ' + item.commentCount + ' comments';
const button = document.createElement('button');
button.type = 'button';
button.disabled = isClosed(item);
drawVote(button, item);
button.addEventListener('click', () => {
void toggleVote(item, button);
});
row.append(title, meta, button);
return row;
}
Mount it wherever the panel lives:
const list = document.querySelector('#fk-list');
if (list instanceof HTMLElement) {
await loadBoard(list);
}
The server sorts by votes. For a Newest tab, sort the mapped array yourself, with items.sort((a, b) => b.createdAt.localeCompare(a.createdAt)), since createdAt is an ISO 8601 string.
5. Voting
votes.vote() and votes.unvote() answer with the new count and whether the user has voted. The server enforces the rules, and each one has its own error class:
- A new request starts with its submitter's vote, so it shows a count of 1.
- A second vote from the same user id is a
ConflictError. - Votes on Completed and Rejected requests, and on any request in an archived project, are a
ForbiddenError. - Removing a vote that does not exist is a
NotFoundError.
// votes.ts
import {
ConflictError,
ForbiddenError,
NotFoundError,
type Feedback,
type VoteResponse
} from 'feedbackkit-js';
import { feedbackKit, camel, currentUserId, isClosed } from './feedback';
export function drawVote(button: HTMLButtonElement, item: Feedback) {
button.textContent = (item.hasVoted ? 'Voted' : 'Vote') + ' (' + item.voteCount + ')';
button.setAttribute('aria-pressed', String(item.hasVoted));
}
export async function toggleVote(item: Feedback, button: HTMLButtonElement) {
let locked = isClosed(item);
button.disabled = true;
try {
const userId = currentUserId();
const raw = item.hasVoted
? await feedbackKit.votes.unvote(item.id, { userId })
: await feedbackKit.votes.vote(item.id, { userId });
const result = camel<VoteResponse>(raw);
item.hasVoted = result.hasVoted;
item.voteCount = result.voteCount;
} catch (error) {
if (error instanceof ConflictError) {
item.hasVoted = true;
} else if (error instanceof NotFoundError) {
item.hasVoted = false;
} else if (error instanceof ForbiddenError) {
locked = true;
} else {
throw error;
}
} finally {
drawVote(button, item);
button.disabled = locked;
}
}
Here is how voting looks in FeedbackKit, with sample data from Trailmate:
Votes from every client
6. The submit form
feedback.create() takes a title, a description, a category and the user id, plus an optional email. Start with the markup:
<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>
The option values are the FeedbackCategory enum's values. Then send it:
// submit.ts
import { FeedbackCategory, ValidationError } from 'feedbackkit-js';
import { feedbackKit, currentUserId } 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 {
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 = 'Thanks, your request is on the board.';
await loadBoard(list);
} catch (error) {
if (error instanceof ValidationError && status) {
status.textContent = error.message;
} else {
throw error;
}
}
});
}
The server refuses an empty title, description or user id, and a malformed email, with a ValidationError whose message says which. Reloading the list after a submit shows the new request with its submitter's vote already counted.
7. Comments
comments.list(id) returns a request's comments, oldest at the top. Replies from your team carry isAdmin, after mapping, so you can label them:
// comments.ts
import type { Comment } from 'feedbackkit-js';
import { feedbackKit, camel, currentUserId } from './feedback';
export async function loadComments(feedbackId: string, list: HTMLElement) {
const comments = camel<Comment[]>(await feedbackKit.comments.list(feedbackId));
list.textContent = '';
for (const comment of comments) {
const item = document.createElement('li');
item.textContent = comment.content;
if (comment.isAdmin) {
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 out the request's optional isAdmin field when a user comments. The comment count on each row comes with the list, so you can fetch comments when a row is opened rather than for every row.
8. 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.
9. Status emails
Telling people what happened to their request is what brings them back. FeedbackKit sends status emails to two groups, under two separate rules:
- The submitter. If the form sent
userEmail, they are emailed when their request moves to a status in the project's email list, on every plan. Choosing which statuses are in that list needs Pro. - Voters. A vote can carry an email and an opt-in. The opt-in is saved, and voters are included in the send, when the project owner is on Team.
await feedbackKit.votes.vote(item.id, {
userId: currentUserId(),
email: 'hiker@example.com',
notifyStatusChange: true
});
Ask for the address in your own UI, and send notifyStatusChange: true when the person has ticked a box saying they want these emails. A Rejected request's reason goes into its email, and a voter's email carries a one-click unsubscribe link. Plans are on the pricing page.
10. Errors
| Class | Status | When |
|---|---|---|
ValidationError | 400 | An empty field, a malformed email or id |
AuthenticationError | 401 | A missing or wrong API key |
PaymentRequiredError | 402 | A plan limit refused the request |
ForbiddenError | 403 | A vote on a closed request, or a write to an archived project |
NotFoundError | 404 | No such request, or no vote to remove |
ConflictError | 409 | A repeat vote |
NetworkError | 0 | A timeout or a dropped connection |
All of them extend FeedbackKitError, which carries statusCode, code and the server's message, so one instanceof FeedbackKitError check catches anything the SDK throws.
11. 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.
Never use innerHTML for user text. Titles, descriptions and comments are typed by your users. textContent renders them as text, which is all a feedback panel needs.
Set a shorter timeout for an inline panel. 30 seconds is long for a panel on a settings page. timeout: 10000 turns a slow network into a NetworkError you can show as a retry button.
Where to go next
Once requests come in, the work moves to your side: triage them in the FeedbackKit admin app on iPhone, iPad or Mac, merge duplicates, and move the ones you build through Approved, In Progress and TestFlight to Completed. On Pro and up you can send requests to GitHub, Linear, Notion or another of the trackers FeedbackKit connects to, and post new ones to Slack. The reference for every call is in the JavaScript SDK docs, and the next release is described in What's New in JavaScript SDK 1.1.
Every plan includes the JavaScript SDK. Start free, copy your project key, and the panel can be on your site this week.