# Add a feedback widget to a Nuxt app

> Add the widget script under app.head in nuxt.config.ts with defer and your data-key. Call identify from a .client.ts plugin that watches the current user, because window does not exist on the server. Server-side code can send feedback through the REST API from a server route.

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

## Add the widget script under app.head in nuxt.config.ts

Nuxt lets you set head tags once for the whole app. Add the widget to `app.head.script` in `nuxt.config.ts`. Nuxt then includes the tag in every server-rendered page, and the browser loads the script after the HTML arrives.

```ts
// nuxt.config.ts
export default defineNuxtConfig({
  app: {
    head: {
      script: [
        {
          src: "https://escutaproduto.com/widget.js",
          defer: true,
          "data-key": "pk_your_product_key",
        },
      ],
    },
  },
});
```

The `defer` attribute keeps the widget from blocking hydration. The `data-key` attribute is passed through unchanged, so the widget reads its options as usual. If you prefer the script at the bottom of the body, add `tagPosition: "bodyClose"` to the same entry. Either position works, because the widget attaches its button after it loads.

Put this in `nuxt.config.ts` rather than a page, so every route inherits it. A script added inside a page component would appear only on that page, and it would be added again each time the page mounts.

## Identify the user from a client-only plugin

Identify must run in the browser, where the widget lives. A plugin whose file name ends in `.client.ts` runs only on the client. The plugin below watches the current user from Nuxt's shared state, so it identifies the person at login and again on any later change:

```ts
// plugins/feedback-identity.client.ts
type FeedbackUser = { id: string; email: string; name?: string };

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

export default defineNuxtPlugin(() => {
  const user = useState<FeedbackUser | null>("currentUser");

  watch(
    user,
    (next) => {
      if (!next) return;
      window.EscutaProduto = window.EscutaProduto || { q: [] };
      (window.EscutaProduto.q ||= []).push([
        "identify",
        [{ id: next.id, email: next.email, name: next.name }],
      ]);
    },
    { immediate: true },
  );
});
```

The key passed to `useState` must match the key that your session code writes to. If a server middleware or a composable fetches `/api/me` and sets `currentUser`, both sides use the same string. A mismatch is the most common reason for an item with no name.

## Why identify must not run on the server

During server rendering `window` is undefined, so a plain `<script>` block that touches the widget would throw on the server. Keeping the call in a `.client` plugin avoids that. It also avoids hydration mismatches, because the server never renders user-specific feedback code.

Identify on the server would not reach the feedback item anyway. The widget runs in the visitor's browser, so the identity has to be set there. Server code can still send feedback itself, as described below.

## Open the form from a Nuxt page

Nuxt renders the page on the server, and the attribute on the button is plain HTML, so it appears in the first response:

```vue
<template>
  <button type="button" data-escuta-open="praise">Tell us what you like</button>
</template>
```

Valid types are `bug`, `idea`, `praise` and `other`. The widget attaches its behavior once the script runs in the browser. A click before the script has loaded has nothing to open yet. For early visitors, keep the floating button as the fallback, and use the attribute buttons for places where people already have a reason to give feedback.

## Content Security Policy in a Nuxt app

Nuxt inlines a small bootstrap script in server-rendered HTML. A strict policy must allow that script too, usually with a nonce or hash generated per request. Nuxt's security module (nuxt-security) manages those headers for you, and it has options for nonces and the directives you list.

The widget needs its own two entries, whatever approach you use:

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

If you set headers yourself in a Nitro route rule or a server middleware, merge these values with the ones you already have rather than replacing the whole policy. The [CSP guide for widgets](/resources/content-security-policy-widgets) shows how to find the blocked request in the browser console when something fails.

## Send feedback from a Nuxt server route

Sometimes the feedback starts on the server, for example from a form that posts to your own API route. A Nitro server route can forward the message to the [REST API](/docs/api):

```ts
// server/api/feedback.post.ts
export default defineEventHandler(async (event) => {
  const body = await readBody<{ message?: string; email?: string }>(event);
  const message = body.message?.trim() ?? "";
  if (message.length < 2 || message.length > 4000) {
    throw createError({ statusCode: 400, statusMessage: "Message length is invalid" });
  }

  return await $fetch<{ ok: boolean; id: string }>("https://escutaproduto.com/api/v1/feedback", {
    method: "POST",
    body: { key: "pk_your_product_key", kind: "other", message, email: body.email },
  });
});
```

The check runs before the request leaves your server, so bad input never reaches the API. The API still enforces its own limits: it returns 400 for invalid data, 413 for a body over 16 KB and 429 above 10 requests per minute per IP and product. Let your form show a retry message on 429.

## Check the Nuxt install in your Escuta Produto inbox

1. Add `http://localhost:3000` (the default Nuxt dev URL) to the allowed origins of your product.
2. Sign in, open the Feedback button and send a test praise item.
3. Open the product inbox and check that the item carries your name and email, along with the page URL.
4. Navigate to another page, send a second item and confirm its page URL changed.

If nothing appears in the inbox, open the browser console on your page and look for blocked requests. A missing allowed origin shows up as a 403 from the API, and a CSP block appears as a console error about the widget host.

The [Vue guide](/resources/feedback-widget-vue) covers the same identify pattern for a client-only Vue app, and the [performance notes](/resources/feedback-widget-performance) explain how defer keeps the script from slowing your pages.

## Frequently asked questions

### Where should I add the feedback widget script in a Nuxt app?

In nuxt.config.ts under app.head.script, with src, defer and your data-key. Nuxt renders the tag in every server response, so the widget is available on all pages from the first load.

### Why does identify need a client-only plugin in Nuxt?

The identify push writes to window, which does not exist while Nuxt renders on the server. A plugin named with the client suffix runs only in the browser, so the call is safe there and never runs on the server.

### Can a Nuxt app send feedback from the server?

Yes. A server route can forward a feedback message to the REST API with your public key. Server requests send no Origin header, so the allowed origins list does not apply to them. Validate the message before forwarding it.

### Does the Nuxt widget keep working after client-side navigation?

Yes. Nuxt navigates between pages on the client without reloading the document, so the script stays loaded. The identify plugin also keeps working, because it watches shared state rather than running once per page.
