# Add a feedback widget to a Vue app

> Load the widget script from index.html in a Vite Vue 3 app. Call identify from a composable that watches the signed-in user, so each feedback item carries their name and email. Vue Router moves between routes without a page reload, so the widget keeps working. Any button with data-escuta-open opens the form.

Source: https://escutaproduto.com/resources/feedback-widget-vue
Last updated: 2026-10-09

## Add the script to index.html in a Vite Vue project

A Vite Vue 3 project has one HTML entry file at the root, next to `package.json`. Put the widget script in its `head` with `defer`. The 5 KB (compressed) script then loads without blocking the first render, and it stays loaded for the life of the page.

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

The `data-key` is your public product key. It can only create feedback, so shipping it in the page is expected. Do not add the script inside a component. A component that mounts twice would load the widget twice, and the script belongs to the whole document anyway.

## Identify the user from a composable after login

Identify attaches a name and an email to the next feedback item and hides the email field in the form. A composable keeps that logic in one place. It accepts any reactive source, so it works with a Pinia store, a ref in a plain module or a computed value:

```ts
// src/composables/useFeedbackIdentity.ts
import { watch, type WatchSource } from "vue";

type FeedbackUser = { id: string; email: string; name?: string };

declare global {
  interface Window {
    EscutaProduto?: { q?: unknown[][] };
  }
}

export function useFeedbackIdentity(source: WatchSource<FeedbackUser | null>) {
  watch(
    source,
    (user) => {
      if (!user) return;
      window.EscutaProduto = window.EscutaProduto || { q: [] };
      (window.EscutaProduto.q ||= []).push([
        "identify",
        [{ id: user.id, email: user.email, name: user.name }],
      ]);
    },
    { immediate: true },
  );
}
```

Call it once in your root component, where the session lives:

```vue
<script setup lang="ts">
import { storeToRefs } from "pinia";
import { useAuthStore } from "./stores/auth";
import { useFeedbackIdentity } from "./composables/useFeedbackIdentity";

const { currentUser } = storeToRefs(useAuthStore());
useFeedbackIdentity(currentUser);
</script>

<template>
  <RouterView />
</template>
```

Using `immediate: true` matters. A user who is already signed in when the app starts gets identified on the first run, not only after their next login. The watch fires again only when the reference changes, which happens when your store sets a new user object at login or logout.

Keep the payload to what you would show the person. The identify call runs in the browser, so anything in it can be read with developer tools. Send an id, an email, a name and maybe a plan label. Never send tokens or internal role data.

## Vue Router navigation and the widget

Vue Router changes the address with the History API and swaps route components without a full reload. The `index.html` script stays loaded, so the Feedback button is present on every route. You do not need to re-run the script in `router.afterEach`, and doing so would only load it again.

The page URL saved with each feedback item comes from the page the person was on when they sent it. A message sent from `/teams/acme/settings` shows that path in the inbox, which helps when a bug only appears on one screen.

## Open the form from a Vue template

Vue passes `data-*` attributes straight through to the element, so a button can open the form with the preselected type:

```vue
<template>
  <button type="button" data-escuta-open="idea">Suggest a feature</button>
  <button type="button" data-escuta-open="bug">Report a problem</button>
</template>
```

Valid values are `bug`, `idea`, `praise` and `other`. For a floating button you do not want, add `data-trigger="none"` to the script tag and keep only your own buttons. The widget handles focus and the Escape key, so you do not need a custom modal for the form.

## Set a Content Security Policy for a Vue build

A Vite production build compiles single-file component templates ahead of time and loads your code from external files. The policy therefore only needs the widget host:

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

The unsafe-eval keyword is needed only if your app compiles templates in the browser, which happens when you import the full build of Vue with a runtime template string. Most Vite projects do not. If your CSP blocks something in development, check whether the Vite dev server's inline preamble is the cause. The [CSP guide for widgets](/resources/content-security-policy-widgets) explains how to debug blocked requests.

## Link to the hosted page from a Vue menu

Some teams want a feedback link in a help menu rather than a floating button. The [hosted feedback page](/docs/hosted-page) works for that, and it takes the language and a prefilled email as query parameters:

```ts
const url = new URL("https://escutaproduto.com/f/your-product-slug");
url.searchParams.set("lang", "en");
if (user.value?.email) url.searchParams.set("email", user.value.email);
window.open(url.toString(), "_blank", "noopener");
```

Be careful with the email. A query string can end up in server logs, browser history and shared links. For signed-in users, the widget with identify is the better choice, because it sends the email in the request body. Use the hosted link for people who are not signed in, or for a public page where you want a clean URL.

## Check the Vue install in your Escuta Produto inbox

1. Add `http://localhost:5173` to the allowed origins of your product, so the widget works in `npm run dev`.
2. Sign in, open the Feedback button and send a test idea.
3. In the product inbox, check that the item carries the name and email from identify, plus the page URL.
4. Repeat the test after navigating to another route. The page URL should change to match the route.

If the name is missing, confirm that the composable is called in a component that is mounted for the whole session. A component inside a route view unmounts when the user leaves that route, which stops the watch.

The [widget reference](/docs/widget) lists every option, and the [Nuxt guide](/resources/feedback-widget-nuxt) covers the same pattern for server-rendered Vue apps.

## Frequently asked questions

### Where do I put the feedback widget script in a Vue 3 project?

In the head of index.html, the entry file Vite serves for the app, with the defer attribute. The script then loads once and stays available on every Vue Router route.

### How do I identify a logged-in user in Vue without repeating code?

Write a composable that takes a reactive user source and watches it. When a user appears, it pushes identify onto the widget queue. Call it once in your root component and every page inherits the identity.

### Does Vue Router navigation affect the feedback widget?

No. Router navigation changes the URL with the History API and swaps components without reloading the document, so the widget stays loaded. Do not add the script again in navigation guards.

### Can I use the Escuta Produto widget with a strict Content Security Policy in Vue?

Yes. Single-file component templates compile at build time, so the app does not need unsafe-eval. Allow the widget host in script-src and connect-src, and the script and feedback requests will pass.
