Add a feedback widget to a React Router or Remix app
Put the widget script in the head of the root Layout in app/root.tsx. Return the signed-in user from the root loader, then pass that user to a component that calls identify in an effect. Client-side navigation does not reload the document, so the Feedback button works on every route.
By Rafael Thayto · Last updated
Place the script in the root Layout
In React Router framework mode, app/root.tsx exports a Layout function that renders the HTML document for every route. Add the widget to its head. The script then arrives with the first response on every page:
// app/root.tsx
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router";
import type { Route } from "./+types/root";
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
<script src="https://escutaproduto.com/widget.js" data-key="pk_your_product_key" defer />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
The Layout export is the document shell, not a route component. Anything you place in it runs on every page, including error pages. That is the right place for a script that should always be present. The data-key is your public product key, which can only create feedback.
Return the signed-in user from the root loader
The root loader runs on every request, so it is the single place to read the session. Return only the fields the widget needs:
// app/root.tsx (continued)
import type { Route } from "./+types/root";
import { getCurrentUser } from "./session.server";
export async function loader({ request }: Route.LoaderArgs) {
const user = await getCurrentUser(request);
return {
user: user ? { id: user.id, email: user.email, name: user.name } : null,
};
}
getCurrentUser stands for your own session lookup. React Router serializes loader data into the page and escapes it, so the object reaches the browser as data. Keep the returned object small, because it travels in every page response.
Identify the user in a component
Pass the user to a component that calls identify in an effect. Render it from the default export so it sits inside the app once:
// app/root.tsx (continued)
import { useEffect } from "react";
import { Outlet } from "react-router";
import { FeedbackIdentity } from "./components/feedback-identity";
export default function App({ loaderData }: Route.ComponentProps) {
return (
<>
<FeedbackIdentity user={loaderData.user} />
<Outlet />
</>
);
}
// app/components/feedback-identity.tsx
import { useEffect } from "react";
type FeedbackUser = { id: string; email: string; name?: string | null };
export function FeedbackIdentity({ user }: { user: FeedbackUser | null }) {
const id = user?.id;
const email = user?.email;
const name = user?.name ?? undefined;
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;
}
The effect depends on primitive values rather than the user object, so it pushes identify only when a field changes. A new loader result with the same user does not repeat the call. React runs effects twice in development under StrictMode, which sends the same identify twice there. That is harmless.
Keep identity in the loader data, not in a client-side store that you fill yourself. The loader is the one source that always matches the session cookie the server checked.
Client-side navigation in React Router
Links and form submissions in React Router go through the router. It fetches the next route's loader data, renders the new route and updates the URL with the History API. The document stays the same, so the widget script keeps running and the Feedback button stays on screen.
When the loader data for the root route refreshes, for example after sign-in with a redirect, the effect sees the new user and identifies them. A feedback item then records the page URL for the page the person was on, which is what you want for triage.
Remix apps and the same root file
Remix v2 used the same root Layout pattern before it merged into React Router. The imports come from @remix-run/react instead of react-router, and the typed route module comes from the Remix type generator. The widget markup, identify component and loader code are the same. Check your Remix version's guide for the exact import paths.
Content Security Policy for a React Router app
Framework-mode apps render inline scripts for hydration. Those scripts need a nonce under a strict policy, and React Router's docs describe how to pass one through the Scripts component and your server entry. The widget only needs its own host:
script-src 'self' https://escutaproduto.com;
connect-src 'self' https://escutaproduto.com;
Put these directives in the same header that carries your nonce. Do not replace the nonce source with 'unsafe-inline', because that weakens protection for the whole page. The CSP guide for widgets explains how to read a blocked request in the browser console.
Open the form from a route module
Any route module can open the form with the attribute on a button. The value preselects the type, and the valid values are bug, idea, praise and other:
// app/routes/pricing.tsx
export default function Pricing() {
return (
<button type="button" data-escuta-open="praise">
Tell us what you like
</button>
);
}
React renders data-* attributes as written, so no extra prop is needed. Because the script is loaded in the layout, the attribute works on every route. Use the floating button for general feedback and these buttons where a route has a clear question to ask.
Confirm the React Router install in your inbox
- Add
http://localhost:5173(the default dev URL for React Router's Vite plugin) to the allowed origins of your product. - Sign in, open the Feedback button and send a test idea from a route that needs a user.
- In the inbox, check that the item shows the name and email from the loader, along with the page URL.
- Move to another route with a link, send a second item and confirm its page URL matches the new route.
If identity is missing, check that the root loader returns a user for the session you are testing. A logged-out visit returns null, which is correct and sends no identify call.
For the full options, read the widget reference. The React guide covers the Vite version of this setup, and the SvelteKit guide shows the same idea with page data.
Frequently asked questions
Where does the feedback widget script go in a React Router app?
In the head of the Layout export in app/root.tsx. That function renders the document shell for every route, so the script is in the first response and stays loaded during client-side navigation.
How do I identify a user with React Router loader data?
Return the user from the root loader, read it in the default export with loaderData, and pass the fields to a component that calls identify inside an effect. The effect depends on the id, email and name only.
Does client-side navigation in React Router reload the feedback widget?
No. React Router fetches route data and renders the next route without reloading the document. The script stays loaded, so the widget keeps working on each page without extra setup.
Does the same setup work in Remix?
Yes. Remix apps use the same root Layout pattern, with the Links, Meta, Outlet and Scripts components imported from the Remix package. The widget markup and identify code are identical.
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 Nuxt appAdd the Escuta Produto feedback widget to a Nuxt app: app.head script config, a client-only plugin for identify, and server rendering caveats.
- 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.