Add a feedback widget to a React app

Put the widget script in index.html of your Vite React app with defer. Then render a small component inside your auth provider that calls identify when a user signs in. Any element with data-escuta-open opens the form. The script loads once, so the widget keeps working across client-side routes.

By · Last updated

Where the script tag goes in a Vite project

Put the script in index.html, the file Vite uses as the entry point for the whole app. Add it to the head with defer, so the 5 KB (compressed) widget never blocks the first render. A single-page app loads this file once, which means the widget is ready on every route without any extra work.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>My app</title>
    <script src="https://escutaproduto.com/widget.js" data-key="pk_your_product_key" defer></script>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

In a Create React App project the file is public/index.html. CRA is no longer maintained, so new projects should start with Vite. The data-key is the public key from your product settings. It is safe to ship in the bundle because it can only create feedback, never read it.

Pass the signed-in user from your auth context

Identify attaches a name and email to the feedback the person sends next, and it hides the email field in the form. Render a component inside your auth provider so it can read the user without prop drilling:

// src/components/feedback-identity.tsx
import { useEffect } from "react";
import { useAuth } from "../auth";

export function FeedbackIdentity() {
  const { user } = useAuth();
  const id = user?.id;
  const email = user?.email;
  const name = user?.name;

  useEffect(() => {
    if (!id || !email) return;
    const w = window as typeof window & { EscutaProduto?: { q?: unknown[][] } };
    w.EscutaProduto = w.EscutaProduto || { q: [] };
    (w.EscutaProduto.q ||= []).push(["identify", [{ id, email, name }]]);
  }, [id, email, name]);

  return null;
}

Then render it beside your app, inside the provider:

// src/main.tsx
createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <AuthProvider>
      <App />
      <FeedbackIdentity />
    </AuthProvider>
  </StrictMode>,
);

Keep identify to the fields you would show the person themselves. The call runs in the browser, so anything you pass can be read with developer tools. Send an id, an email, a name and, if you want it, a plan label. Never send session tokens, API keys or internal role data. Passing the values as an object to push means nothing gets concatenated into code, so a name with quotes or angle brackets is stored as plain data.

Why the effect depends on user fields

The dependency array holds the id, email and name, not the user object. An auth provider can create a new user object on every render, and depending on the object would push identify on every render. With primitive dependencies, the push happens only when a field actually changes.

React 18 and later run effects twice in development under StrictMode, so you will see two identical identify pushes in dev. Production runs the effect once. Both are harmless, because the same fields are sent again.

The widget has no call that clears identity. If your app shares a browser between accounts, reload the page after sign-out so the next person starts with a clean page.

Open the form from your own buttons

The floating Feedback button appears by default. For a settings menu, a help page or an empty state, use the data-escuta-open attribute on any element. The value preselects the type: bug, idea, praise or other.

<button type="button" data-escuta-open="bug">Report a problem</button>

Add data-trigger="none" to the script tag if you want only your own buttons. Once the script has loaded, the JavaScript API is also available as window.EscutaProduto.open({ kind }). Call it only after load, or check that the object exists first, because the script may still be downloading when a user clicks early.

Routes, navigation and what the widget sees

React Router, TanStack Router and similar libraries change routes with the browser History API. The document does not reload, so index.html and the widget stay in place. Nothing needs to re-run on each route.

Each feedback item saves the page URL, so a message sent from /projects/42/billing shows that path in the inbox, not just the home page. Combined with the browser name the widget saves, that is usually enough to reproduce a layout bug without asking the customer for a screenshot.

Content Security Policy for a React build

A production Vite build loads your code from external files and has no inline scripts. The policy only needs the widget host added to two directives:

script-src 'self' https://escutaproduto.com;
connect-src 'self' https://escutaproduto.com;

The widget loads its script from the first host and posts feedback to the second. If you already allow other origins, add this one next to them. For more on how these directives behave with third-party widgets, read Content Security Policy for third-party widgets.

The Vite dev server is different. It injects a small inline script for React Fast Refresh, so a strict policy can break npm run dev even when production works. Allow that inline script only in development, or set a nonce in your dev server headers.

Send feedback from your own React form

If you want a custom form, skip the widget and call the REST API from your component:

await fetch("https://escutaproduto.com/api/v1/feedback", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    key: "pk_your_product_key",
    kind: "idea",
    message,
    email,
    pageUrl: window.location.href,
  }),
});

A JSON content type triggers a CORS preflight, so your site's origin must appear in the allowed origins for the product. Otherwise the API returns 403. Localhost is a different origin, so add your dev URL with its port too. The API returns 201 with an id on success and 429 when one IP sends more than 10 requests a minute for the product. Show a short retry message on 429 instead of silently failing.

For most React apps the widget is the simpler choice. It already handles layout, the Escape key and focus return, so you only write code for your own entry points.

Check your first item in the Escuta Produto inbox

  1. In the product settings, add http://localhost:5173 (the default Vite dev URL) to the allowed origins.
  2. Start the app, sign in, open the Feedback button and send a test idea.
  3. Open the product inbox. The new item shows the page URL, the browser, and the name and email from identify.
  4. Optionally, set a Slack or Discord webhook so each new item posts to a channel. See Slack and Discord notifications.

If the item shows no name, the identify effect did not run. Check that FeedbackIdentity sits inside the provider that has the user, and that the user object is not null when the page renders.

For the basic widget options, read the widget reference. If your team also builds with Vue, the Vue install guide follows the same pattern with a composable.

Frequently asked questions

Where should the feedback widget script go in a Vite React app?

In index.html, inside the head, with the defer attribute. Vite serves that file for the whole single-page app, so the script loads once and stays loaded while people move between routes.

How do I pass the logged-in user to the feedback widget in React?

Render a component inside your auth provider that reads the current user and pushes an identify call onto the widget queue inside a useEffect. The effect runs again only when the user id, email or name changes.

Does the feedback button keep working after React Router navigation?

Yes. Client-side routes change the URL without reloading the page, so the widget stays loaded. Feedback sent from any route records the page URL where the person was when they sent it.

Will a strict Content Security Policy block the widget in a React app?

Not in production builds, which load your code from files. The policy only needs the widget host in script-src and connect-src. The Vite dev server injects a small inline script for Fast Refresh, so allow that in development only.