Dropping a third-party widget into a React app is where most integrations quietly go wrong. It works on the first page, then you navigate, and there are two launchers. Navigate again and there are three.
The fix is not complicated, but it is specific. Here is how to do it properly, and why each line is there.
The shape of the problem
The widget is imperative: you call mount() and it puts DOM on the page. React is declarative and owns the DOM. Those two models do not automatically agree, and the disagreements show up as:
- Duplicate widgets after client-side navigation
- Two launchers in development but one in production
- Errors on unmount when the widget outlives its container
- The script loading repeatedly on every component mount
All four come from the same root cause: no cleanup, or cleanup that does not account for async.
Step 1: Load the SDK once per page
The CDN files should be requested once, not once per component instance. A module-scope promise gives every caller the same in-flight request:
// chatbotSdk.js
let sdkPromise = null;
export function loadChatbotSdk() {
if (sdkPromise) return sdkPromise;
sdkPromise = new Promise((resolve, reject) => {
if (window.ChatbotSDK) return resolve(window.ChatbotSDK);
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = "https://cdn.cortexvia.in/chatbotsdk-web.css";
document.head.appendChild(link);
const script = document.createElement("script");
script.src = "https://cdn.cortexvia.in/chatbotsdk-web.iife.js";
script.onload = () => resolve(window.ChatbotSDK);
script.onerror = () => reject(new Error("Failed to load Chatbot SDK"));
document.head.appendChild(script);
});
return sdkPromise;
}Caching the promise, not the result, is the important part. Two components mounting in the same tick share one network request instead of racing.
Step 2: Mount in an effect, unmount in the cleanup
import { useEffect, useRef } from "react";
import { loadChatbotSdk } from "./chatbotSdk";
export function Chatbot({ chatbotKey }) {
const containerRef = useRef(null);
const handleRef = useRef(null);
useEffect(() => {
let cancelled = false;
loadChatbotSdk().then((sdk) => {
if (cancelled || !sdk?.mount || !containerRef.current) return;
handleRef.current = sdk.mount(containerRef.current, {
chatbotKey,
config: { mode: "chatbot" },
});
});
return () => {
cancelled = true;
handleRef.current?.unmount();
handleRef.current = null;
};
}, [chatbotKey]);
return <div ref={containerRef} />;
}Three things are doing real work here.
unmount() in the cleanup. This is what stops a route change leaving a widget behind. Without it, every navigation adds another.
The cancelled flag. The component can unmount while the CDN request is still in flight. Without the flag you mount into a node React has already discarded, which throws or leaks.
chatbotKey in the dependency array. Change the key, and the effect tears down the old widget before creating the new one.
Step 3: Know what StrictMode does
In development, React 18 and 19 run effects twice: mount, cleanup, mount. This is deliberate. It surfaces exactly the missing-cleanup bugs described above.
If you skip unmount(), you will see two launchers in dev and one in production. That looks like a widget bug and is not; it is your cleanup. The code above handles it correctly.
Do not disable StrictMode to make the symptom go away. It is telling you the truth.
Updating without remounting
If you need to change configuration on a live widget, switching modes or changing the placeholder, use update() rather than tearing down and remounting:
useEffect(() => {
handleRef.current?.update({
chatbotKey,
config: { mode: "chat", inputPlaceholder: "Ask anything..." },
});
}, [someSetting]);update() takes the same shape as mount(). Remounting loses the conversation; updating does not.
Floating vs inline
Two modes, and the choice affects your layout.
mode: "chatbot" is a floating launcher pinned to a corner. Best for site-wide support. Put it once in your root layout, not per route.
mode: "chat" is an inline panel that renders directly in your container and stays open. Best for a dedicated support page or an AI search panel. The container needs a stable height; inline mode fills its parent, and a parent with no height renders as nothing.
<div ref={containerRef} style={{ height: "72vh" }} />That missing height is the single most common inline-mode bug.
Where to put it
For a floating widget, mount once at the top of your tree:
function App() {
return (
<>
<Routes>{/* ... */}</Routes>
<Chatbot chatbotKey="ctx-cb-xxxxxxxxxxxxxxxx" />
</>
);
}Outside the router, so navigation does not remount it. Inside the router, every route change tears the widget down and rebuilds it, which works but throws away the conversation and looks janky.
A checklist before you ship
- Navigate between routes several times, then count the launchers. Should be one.
- Check the network tab: the CDN files should load once, not per navigation.
- In inline mode, confirm the container has a height.
- Confirm it survives a fast route change during the initial load; that is the
cancelledflag doing its job.
Frequently asked questions
Can I use this with Vite, Create React App, or Remix?
Yes. Nothing here depends on the bundler. Remix and other server-rendering frameworks need the mount to stay inside useEffect, which it already does. Next.js has its own wrinkles, covered in the Next.js guide.
Do I need TypeScript types for window.ChatbotSDK?
A minimal declaration is enough: declare global { interface Window { ChatbotSDK?: { mount: Function } } }. The React guide page has a typed drop-in component.
Where does the chatbot key come from? From the Chatbots area after your first source has processed. The free plan gives you a real key to build against.


