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.
By Rafael Thayto · Last updated
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.
// 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:
// 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:
<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:
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 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:
// 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
- Add
http://localhost:3000(the default Nuxt dev URL) to the allowed origins of your product. - Sign in, open the Feedback button and send a test praise item.
- Open the product inbox and check that the item carries your name and email, along with the page URL.
- 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 covers the same identify pattern for a client-only Vue app, and the performance notes 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.
Related
- Add a feedback widget to a React appInstall the Escuta Produto feedback widget in a Vite React app: the script in index.html, identify from your auth context, and how routes behave.
- Add a feedback widget to a Vue appAdd the Escuta Produto feedback widget to a Vue 3 app with Vite: script in index.html, identify in a composable after login, and Vue Router notes.
- Add a feedback widget to a SvelteKit appAdd the Escuta Produto feedback widget to a SvelteKit app: the app.html script, identify from layout data, and Content Security Policy in svelte.config.js.
- Add a feedback widget to an Astro siteAdd the Escuta Produto feedback widget to an Astro site: a base layout script with is:inline, view transitions, and static pages without login.