# Add a feedback widget to a Next.js Pages Router app

> Load the widget once in pages/_app.tsx with next/script and afterInteractive. The script stays mounted across client-side route changes, so the Feedback button keeps working. Identify the signed-in user from a small hook that pushes an identify call onto the widget queue.

Source: https://escutaproduto.com/resources/feedback-widget-nextjs-pages-router
Last updated: 2026-10-11

## Where does the widget script go in a Pages Router app?

In a Pages Router project, the file that wraps every page is `pages/_app.tsx`. Put the widget script there, not in each page and not in `pages/_document.tsx`. Next.js renders `_app` once and keeps it mounted while the visitor moves between pages with `next/link` or `router.push`, so a script placed there loads one time per full page load and survives every client-side navigation.

If you are on the App Router (the `app/` folder), read the [Next.js App Router guide](/docs/nextjs) instead. The steps below are only for projects that use `pages/`.

## How do I load the script once in pages/_app.tsx?

Render the `Script` component from `next/script` next to the page component. The `afterInteractive` strategy loads the widget after the page becomes interactive, so it does not compete with your first render:

```tsx
// pages/_app.tsx
import type { AppProps } from "next/app";
import Script from "next/script";
import "../styles/globals.css";

export default function App({ Component, pageProps }: AppProps) {
  return (
    <>
      <Component {...pageProps} />
      <Script
        src="https://escutaproduto.com/widget.js"
        data-key={process.env.NEXT_PUBLIC_ESCUTA_KEY}
        strategy="afterInteractive"
      />
    </>
  );
}
```

The `data-*` props are forwarded to the script tag, so the widget reads its options as usual. The `data-key` is your public product key. It ships to the browser by design, which is why the variable needs the `NEXT_PUBLIC_` prefix. Next.js only exposes variables with that prefix to client code.

The `_document` file is the wrong place for this script. Only the `beforeInteractive` strategy belongs there, and the widget does not need to run before the page is interactive.

## How do I identify the signed-in user from a hook?

Identify is a call that you push onto the widget queue. The queue form works before or after the script loads, so you do not need to wait for the widget. Put the logic in a hook so every page can use it:

```ts
// lib/use-escuta-identity.ts
import { useEffect } from "react";

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

type EscutaWindow = Window & {
  EscutaProduto?: { q?: unknown[][] };
};

export function useEscutaIdentity(user: FeedbackUser | null) {
  const id = user?.id;
  const email = user?.email;
  const name = user?.name;

  useEffect(() => {
    if (!id || !email) return;

    const w = window as EscutaWindow;
    w.EscutaProduto = w.EscutaProduto || { q: [] };
    (w.EscutaProduto.q ||= []).push(["identify", [{ id, email, name }]]);
  }, [id, email, name]);
}
```

The hook depends on the three primitive fields instead of the user object. Your auth layer may create a new object on every render, and depending on the object would push a new identify call each time. Comparing strings avoids that.

Next, call the hook in `_app` with the user your app already loads. Most Pages Router apps fill it in `getServerSideProps` from the session cookie and pass it as a prop named `currentUser`. Add `useEscutaIdentity` and the `FeedbackUser` type to the imports at the top of `_app.tsx`, pointing at `../lib/use-escuta-identity`, then call the hook inside the component:

```tsx
// pages/_app.tsx, inside the App component
const currentUser = (pageProps as { currentUser?: FeedbackUser }).currentUser ?? null;
useEscutaIdentity(currentUser);
```

Pages that are statically generated have no session, so they send feedback as anonymous. That is the right behavior for public pages. If you want the name and email on every page, load the session in `getServerSideProps` or fetch it in a client component after the page mounts.

## Does the widget survive route changes in Pages Router?

Yes. Because the script lives in `_app`, a visitor who moves from `/pricing` to `/settings` with `next/link` keeps the same widget instance. Its Feedback button stays in place, and it does not load the script again.

The widget reads `location.href` at the moment the visitor sends a message. That means each item records the page the visitor was on, even though the document never reloaded. You do not need `router.events` listeners for the widget.

Identity works the same way. The hook runs again when the user object changes, and it does nothing when the fields stay the same. The widget has no sign-out call in its JavaScript API, so after the visitor signs out, reload the page. The next load starts without an identity, and messages from that browser are anonymous again.

## How do I open the form from a button or a menu?

If you want your own button in place of the floating one, set `data-trigger="none"` on the script tag. Then any element with a `data-escuta-open` attribute opens the form. The value preselects the type, and valid values are `bug`, `idea`, `praise` and `other`:

```tsx
// components/report-bug-button.tsx
export function ReportBugButton() {
  return (
    <button type="button" data-escuta-open="bug">
      Report a bug
    </button>
  );
}
```

To set the attribute only in one environment, read the variable in `_app` and pass `data-trigger` conditionally. For the complete list of options, see the [widget reference](/docs/widget).

## How do I keep the environment key out of the code?

Use one product per environment, each with its own `pk_` key. Create a product for production and another for staging or local work. Put the development key in `.env.local` and the production key in the environment variables of your hosting dashboard. Never commit a production key to a shared branch. The file needs one line:

```bash
NEXT_PUBLIC_ESCUTA_KEY=pk_your_dev_product_key
```

Each product has its own allowed origins. Add `http://localhost:3000` to the development product, and your production domain to the production product. Requests from any other site get a 403, so a copied key cannot send feedback from an unknown domain. If you only have one product, add `localhost` to its allowed origins and accept that local test items appear in the same inbox.

## Which Content Security Policy does Next.js need?

Pages Router apps can set headers in `next.config.js` with the `headers` function. The widget needs its host in `script-src` and `connect-src`:

```js
// next.config.js
module.exports = {
  async headers() {
    return [
      {
        source: "/:path*",
        headers: [
          {
            key: "Content-Security-Policy",
            value:
              "default-src 'self'; script-src 'self' 'unsafe-inline' https://escutaproduto.com; connect-src 'self' https://escutaproduto.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:",
          },
        ],
      },
    ];
  },
};
```

Next.js writes small inline scripts for hydration, so a policy without `'unsafe-inline'` breaks pages unless you add nonces through middleware. Development mode also uses `eval`, so the dev server needs `'unsafe-eval'` as well. Apply the strict version in production only if you have set up nonces. The [CSP guide for widgets](/resources/content-security-policy-widgets) explains how to read a blocked request in the browser console.

## What should I check when the widget does not appear?

Work through this list in order. Most problems are one of these four:

1. The button is missing. Open the page source and search for `escutaproduto.com/widget.js`. If it is absent, the environment variable is empty at build time. Check that the name starts with `NEXT_PUBLIC_` and that you rebuilt after adding it.
2. The console shows a Content Security Policy error. Add the host to `script-src` and `connect-src`, as shown above.
3. Sending fails with a 403 error. The origin of the page is not in the allowed origins of that product. Add the exact origin, including the port in development.
4. The name is missing from the inbox item. The hook received `null`. Log `currentUser` in `_app` to confirm what `getServerSideProps` returned.

The [widget reference](/docs/widget) lists every option, and the [best practices guide](/resources/feedback-widget-best-practices) covers where to place the button. For a React project that uses a different router, see the [React guide](/resources/feedback-widget-react).

## Confirming the first message in your Escuta Produto inbox

After the steps above, send one test item from a signed-in page, then open a second page through a link and send another. In the inbox, the first item should show your name and email, and both items should record the page you sent them from. Sign out, reload, and send a third message. That one should have no name.

Escuta Produto gives you one inbox for every product, with a filter by type and status, so the Next.js app shows up next to your other products. You can also read the same feedback through the [REST API](/docs/api) if you later want to forward items to your own tools.

## Frequently asked questions

### Where should the feedback widget script go in a Next.js Pages Router app?

In pages/_app.tsx, rendered with the Script component from next/script and the afterInteractive strategy. The App component stays mounted during client-side navigation, so the script loads once and the Feedback button survives route changes.

### How do I identify a signed-in user in a Next.js Pages Router app?

Write a small hook that pushes an identify call onto the widget queue, using the id, email and name. Load the user in getServerSideProps, pass it as a page prop, and call the hook from _app with that value.

### Does the feedback widget keep working when I change pages in Next.js?

Yes. Because the script is mounted in _app, client-side navigation does not reload it. The widget reads the current page address each time someone sends a message, so every item records the page it came from.

### Why does my feedback widget not show up after I deploy a Next.js app?

The most common cause is a missing NEXT_PUBLIC_ variable at build time, or a Content Security Policy that blocks the widget host. Check the page source for the script tag, then look for a blocked request in the browser console.
