Skip to content

Install the feedback widget

Embed FeedLog in your product so users can submit feedback without leaving your app. A user opens the launcher and describes what they need; AI converts the message into structured feedback on your board.

The integration requires one function call. The SDK is a loader: approximately 5 KB gzipped, with no runtime dependencies. It ships as ES modules with TypeScript declarations. The SDK renders the launcher, unread badge and panel; the feedback UI is hosted by FeedLog and loaded in an iframe. This architecture allows FeedLog to add widget features without requiring an SDK upgrade.

Want a coding agent to handle the integration?

Use the Widget integration prompt. Copy it into a coding agent that has access to your project, and it will inspect the codebase, implement the frontend and server changes, and verify the result.

Install

bash
npm install @feedlog/widget

ESM only

The package ships ES modules and type declarations and requires a bundler. It does not provide a <script src="…"> build or a global variable, so direct installation through a CMS code snippet is not supported.

Configure the widget

ts
import { createWidget } from '@feedlog/widget'

createWidget({
  baseUrl: 'https://acme.feedlog.ai',
  auth: {
    getToken: async () => {
      const res = await fetch('/api/feedlog-token')
      // A reachability problem, not a sign-out — see below.
      if (!res.ok) throw new Error('token endpoint unavailable')
      return (await res.json()).token ?? null
    },
    // Must resolve when the modal closes, not when it opens.
    login: () => openYourSignInModal(),
  },
  theme: 'auto',
})

createWidget is the package's only export. It has no return value, instance methods or events. The launcher opens the panel, and the panel's close button closes it. The widget manages this state internally, which allows its behaviour to change without requiring changes to your integration code.

Options

OptionRequiredNotes
baseUrlYesYour FeedLog address, e.g. https://acme.feedlog.ai. A custom domain works the same way.
auth.getTokenYes() => Promise<string | null>. Reports the current sign-in state. Review the three possible outcomes below before implementing it.
auth.loginNo() => void | Promise<void>. Opens your own sign-in UI when someone asks to sign in from inside the panel.
themeNo'light', 'dark' or 'auto' (default). Follows the OS on auto.

getToken has three outcomes, not two

getToken() reports the current sign-in state. FeedLog handles its three possible outcomes differently:

Your getToken()FeedLog reads it asWhat the widget does
resolves with a JWTSigned in, as the email in that tokenExchanges it for a session and opens the panel as that user
resolves with nullSigned outClears the cached session immediately and shows a sign-in prompt
rejects or throwsTemporary failureKeeps the session; offers a retry, or leaves a working panel alone

Never return null for a failure

null means that the user is signed out. Return it only when you know that no user is signed in. If the token route returns a 500 response, the request times out or fetch rejects, throw an error.

Returning null for a temporary failure clears the cached session. This can cause intermittent sign-outs with little diagnostic evidence because the original network error is no longer reported as an error.

getToken() runs when the panel is opened for the first time and on every subsequent open. This keeps the widget synchronized with your app's sign-in state, so the function should complete quickly. The SDK caches the FeedLog session obtained from the JWT; subsequent opens normally call your function without making another exchange request.

Opening your own sign-in UI

auth.login is optional. It is called when a user selects sign-in in the panel while getToken() returns null. It can open your existing sign-in UI, such as a modal, popup or full-page redirect to your identity provider. For a full-page redirect, the SDK stores a short-lived marker and reopens the panel when the user returns to the page, provided the round trip takes less than approximately five minutes.

A settled login() call does not indicate successful sign-in. It only tells the SDK that the interaction has ended. The SDK then calls getToken() again and uses that result to determine the sign-in state. When the user cancels, resolving and rejecting have the same effect. You do not need to return the outcome of the sign-in attempt.

An expired session does not call login() automatically. The SDK first retries getToken() without opening any UI because the user may have signed in in another tab. The sign-in UI opens only after the user explicitly selects sign-in inside the panel; it will not appear while the user is only reading the page.

Sign the token on your server

Your backend must sign the JWT returned by getToken() with an SSO secret from Developer → Single Sign-On. Use HS256 and the four supported claims, with exp set no more than 24 hours in the future:

ts
// Server-side only — the secret must never reach the browser.
import jwt from 'jsonwebtoken'

export default handler(async (req) => {
  const user = await currentUser(req)      // your own session, however you read it
  if (!user) return { token: null }        // becomes `null` in getToken()

  return {
    token: jwt.sign(
      { email: user.email, name: user.name, picture: user.avatarUrl },
      process.env.FEEDLOG_SSO_SECRET,
      { algorithm: 'HS256', expiresIn: '1h' },
    ),
  }
})

The SDK posts the token to FeedLog, caches the returned session in localStorage under the signed-in email address, and exchanges the token again when the session expires. You do not call the exchange endpoint directly. The session is passed to the iframe in the URL fragment, keeping it out of access logs and Referer headers.

For secret creation and rotation, the full claim list and all exchange errors, see Single sign-on (JWT handoff). If browser handoff is already configured in your product, reuse the same signing code, secret and token.

Where to call it

Call createWidget() once per page on the client. It does nothing when window is unavailable, so it can be imported in a Nuxt or Next app. Place the call in a client-only lifecycle hook such as onMounted or useEffect, or in a .client plugin.

A second call logs a warning and returns without creating another widget. This prevents React StrictMode double mounts and HMR module reloads from adding a second launcher. Calling the function again does not update baseUrl or theme; reload the page to change either option.

DOM structure and appearance

The SDK adds one <div> with a shadow root to document.body. It contains the launcher, badge and panel. The shadow root prevents the host page's CSS resets from affecting the widget and prevents widget styles from affecting the host page; no additional z-index configuration is required. The launcher appears in the bottom-right corner. The panel is 400 px wide on desktop and becomes full-screen when the viewport is narrower than 520 px.

The brand colour comes from your FeedLog workspace, not from the SDK. Configure it in workspace settings. FeedLog also selects a readable foreground colour. theme is the SDK's only appearance option. It is passed to the iframe when the frame is created, before the first paint, so provide the final value in the createWidget() call. Resolving it later from a store or a media-query listener that runs after mount can cause the panel to display the wrong theme briefly. Use auto unless your app has its own light/dark toggle; auto follows the OS and updates when the OS setting changes.

The badge shows the number of unread replies. While the panel is closed, the SDK refreshes the count on page load and when the tab regains focus, subject to a 60-second cache. When the panel is open, the iframe updates the count and clears items as their threads are read. The displayed count is capped at 9+.

Enable the widget

Enable the widget in Settings → Widget. The SDK checks this setting before it renders. When the widget is disabled, it renders nothing and logs nothing. The setting applies to every site where the SDK is installed and does not require a new deployment of your product.

No domain allowlist is required. FeedLog does not maintain a registry of customer domains: the widget API accepts requests from any origin, and any site can frame the embed page. Authentication does not rely on cookies; the session is supplied as an explicit bearer token.

The widget supports current versions of Chrome, Firefox, Safari and Edge. The build targets ES2020 and uses Shadow DOM, fetch and Web Storage without polyfills. If Web Storage throws in private browsing mode, the SDK uses in-memory storage instead. The session cache then does not survive page reloads, resulting in one additional token exchange per page load.

Common errors you may encounter

Two launchers on the page. The guard covers a second call in the same page load, and prints createWidget() was already called; ignoring this call when it fires. Two launchers without this warning indicate two documents, such as an iframe or micro-frontend that loads the integration a second time.

Users are signed out intermittently. getToken() is returning null on a temporary failure instead of throwing. See the three outcomes above.

createWidget requires a baseUrl, or requires auth.getToken (TypeError). These errors are thrown synchronously and indicate that an option is missing or has the wrong type. getToken must be a function, not a promise or token string. baseUrl is not a valid URL means the value could not be parsed; include the scheme.

Nothing renders, and the console says could not load widget config. The SDK could not reach GET /api/widget/config at your baseUrl, so it cannot determine whether the widget is enabled and does not render the launcher. Open that URL directly. The same symptom can result from an incorrect baseUrl, a host that does not resolve to a workspace or an unavailable FeedLog instance.

Nothing renders, and there's no warning either. Either the widget is switched off in Settings → Widget, or the call only ever ran on the server. Confirm the client reached it by putting a console.log next to it.

The panel continues to show a loading spinner. The iframe loaded but did not report itself ready. Check the embed page request in the network panel. A 404 or 500 response renders as an empty frame rather than an error because FeedLog's error pages do not include the header that allows framing.

"Feedback could not be loaded." with a Try again button. getToken() threw, or the exchange failed. The status is in the console as Widget token exchange failed with status …; the reason is JSON in that response body, so read the request in the network panel. A 403 there means the widget is disabled, and a 400 usually indicates a token problem. The SSO page lists the exact messages.

A sign-in prompt inside the panel, for a user who is signed in to your app.getToken() is returning null. Most often the token route can't see your session cookie: a different subdomain, or a SameSite setting that drops it. Call the route from the browser console and check what it actually returns.

The badge is up to one minute out of date. The count is cached for 60 seconds while the panel is closed. Opening the panel refreshes the count.

Open-source feedback management. Self-host it or let us run it.