Short answer: put the script tag in your HTML shell, not inside a React component. In a plain React build that means the static index.html file. In a framework with its own script handling, use the framework's script component in the root layout. Mounting it from a component works but invites duplicate widgets, cleanup bugs and Strict Mode surprises.
The mental model: the widget is not a React component
A third-party chat widget creates its own DOM node, attaches it to the document body, and manages itself. It is a separate application that happens to share a page with yours.
That is why the HTML shell is the right home for it. Your React tree renders into a root element. The widget lives outside that root, next to it. It does not need to know React exists, and React does not need to know it exists. Keeping them separate is what makes this boring and reliable.
Every problem in this post comes from someone breaking that separation.
Plain React with a static HTML shell
If your build produces a static HTML file that your bundle mounts into, open it and add the script tag before the closing body tag.
That is the whole job. The widget loads on every route, because there is only one HTML document and client-side routing never reloads it. Navigation between pages in your app does not tear the widget down, so an open conversation survives a route change, which is exactly the behaviour you want.
Do not put it in the head. There is no benefit and you are adding work before your app can render.
Next.js and frameworks with a script component
Modern React frameworks provide their own component for third-party scripts, with loading strategies that control when the script fetches relative to hydration. Use it in your root layout so it applies across the whole app.
Pick the strategy that loads after the page becomes interactive. A chat widget is not needed during hydration and should not compete with it.
The exact component name, import path and strategy names have changed across framework versions, and this repo's own framework version may differ from what you remember. Check the docs shipped with your installed version rather than copying a snippet from a search result. The concept is stable, the API is not.
If your framework renders on the server, remember the widget is client-side only. It should not run during server rendering, which the framework's script component handles for you. This is another reason to use it rather than hand-rolling.
The component-mount approach, and why it bites
You can create an effect that builds a script element, sets its source, and appends it to the document. People do this constantly and it mostly works.
Here is what goes wrong.
Strict Mode double invocation. In development, React intentionally mounts, unmounts and remounts components to surface cleanup bugs. Without a proper cleanup or a guard, you append the script twice and get two widgets. Developers then blame the widget.
Remount on navigation. If the component lives inside a route rather than at the app root, every navigation to that route runs the effect again.
Cleanup that does not clean up. Removing the script element does not remove the widget it created, because the widget attached its own nodes elsewhere. You end up with orphaned UI.
Race conditions. People try to pass user data to the widget in the same effect, and it runs before the script has finished loading.
If you must do it this way, mount it once at the top of the tree, guard against double insertion by checking whether the script already exists, and do not attempt to remove it on unmount.
The HTML shell avoids all four problems by not being in React at all.
Verifying the install
Run a production build and serve it, then open it in a private browser window. Development servers can behave differently, particularly around Strict Mode and hot reloading.
Check the bubble appears, then navigate between several routes without a full page reload and confirm exactly one bubble persists. Open a conversation, navigate, and confirm it survives.
Open the browser console and look for errors. Then open the network tab and confirm the widget script is requested once, not twice.
Common gotchas
Two bubbles in development, one in production. Classic Strict Mode double mount. Move the script to the HTML shell.
Content security policy. If your app sends a CSP header, you must allow the widget's script source and its connection endpoint. A blocked script fails silently on the page and loudly in the console. This is the single most common cause of "it works locally, not in production", because the CSP usually only exists in production.
Server-side rendering errors. Code touching the document during server render throws. Use the framework's script component or guard for a browser environment.
Build strips the tag. Some pipelines transform or minify the HTML shell. Confirm the tag is present in your built output, not just your source file.
Route-scoped mounting. If you only want the widget on some routes, be deliberate. Conditionally rendering a mounting component is where the duplication bugs live. It is usually simpler to load it everywhere and configure visibility than to control it through React's lifecycle.
Style isolation. Not actually a problem. Clerkzo renders inside a Shadow DOM, so your design system's global CSS cannot bleed into the widget or the other way round.
What to do after it loads
Train it by crawling your marketing site, and upload knowledge files for documentation or policies that are not public pages. Test in the playground before shipping. Set guardrails so it stays on topic and hands off to a human rather than improvising, which matters in a product context where a confident wrong answer creates a support ticket.
For leads, the webhook route from lead capture is usually the right fit for a team that already has systems, since you can push captured details straight into whatever you use rather than rekeying from an inbox.
Frequently asked questions
Should I use an npm package instead of a script tag?
A script tag keeps the widget decoupled from your build. Nothing to upgrade, no version conflicts with your bundler, and no way for it to break a deploy.
Will it affect my Core Web Vitals?
Loaded after interactivity, it is off the critical rendering path. Placing it in the head with a blocking load would hurt. Do not do that.
Does it survive client-side navigation?
Yes, when installed in the HTML shell, because there is no full page load to tear it down.
Can I open the chat from a button in my app?
Widgets typically expose a small global API for this. Check the current documentation for the exact method rather than guessing at a name.
Is it appropriate for a logged-in product UI?
It can be, though the strongest case is on marketing and pricing pages where visitors are still deciding. See pricing for what running it costs.