React + Vite

How to add an AI chatbot to React
without an npm SDK

Heeya is a docs-grounded React AI chatbot widget delivered as one classic script tag. Put it in Vite's index.html, or inject it once from useEffect. There is no @heeya/react package to install, and no destroy method to call on unmount.

Create your React chatbot

Product setup (sources, guidance, RAG limits) lives on AI customer support for SaaS. This page is the React and Vite loading guide.

Connect snippet

<script
  async
  src="https://heeya.fr/agent/YOUR_AGENT_ID/embed.js"
  data-agent-name="Support"
></script>

Same tag the dashboard Connect page copies. Classic script, not type="module".

Put the widget in Vite's index.html

Vite treats index.html as the app entry, not a file buried in public/. The Heeya tag belongs there, next to the module entry, as a separate classic script. That loads the bubble once for the document lifetime, which is what you want on a SPA: React Router can change views without remounting the chat.

Copy the snippet from Connect

Create an agent, add the documents or pages it should retrieve from, then open Connect. Paste the snippet before </body>. Replace YOUR_AGENT_ID with the UUID from that URL. Colors and the optional logo come from the agent settings; the loader injects them when embed.js is served.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>App</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
    <script
      async
      src="https://heeya.fr/agent/YOUR_AGENT_ID/embed.js"
      data-agent-name="Support"
    ></script>
  </body>
</html>

Keep it a classic script

Do not write import 'https://heeya.fr/agent/.../embed.js' in main.tsx. Do not add type="module" to the Heeya tag. The loader reads document.currentScript to parse the agent UUID out of the src and to read data-* attributes. MDN: currentScript is null for JavaScript modules. Against the production embed, a module load sets window.Heeya.embedScript to null and never paints a widget.

Vite's own entry stays type="module". The Heeya tag is a third-party classic script. If a future Vite HTML transform tries to bundle that URL, the docs allow vite-ignore on the element so it is left as an external script.

Optional attributes the loader reads

The Connect page copies src and data-agent-name. The same script also honors these attributes if you add them:

  • data-position: bottom-right (default), bottom-left, top-right, or top-left.
  • data-agent-name: title shown in the chat header. Falls back to Assistant.
  • data-bubble-color and data-chat-color: hex overrides. If omitted, the values saved on the agent are injected by the server.
  • data-logo: image URL for the header. A relative path resolves against your origin, not heeya.fr.
  • data-widget-style="side-input": alternate launcher. Omit it for the default bubble.

If your app sets a Content-Security-Policy, allow https://heeya.fr in script-src and connect-src. The widget also injects Inter from Google Fonts.

Load it from a React component without duplicating bubbles

Prefer index.html. Use an Effect only when the agent ID is not known until runtime. Current React docs treat this as synchronizing with an external system: setup on mount, cleanup that undoes setup, and an extra setup/cleanup cycle in Strict Mode during development.

Why Strict Mode creates two widgets

Heeya's loader appends /static/js/embed/bundle.js, which immediately creates a div.heeya-widget.heeya-container on document.body. There is no destroy(). A naive Effect that appends the tag on every setup, then only removes that tag on cleanup, does not cancel the in-flight bundle. Verified against the production embed: that sequence leaves two bubbles.

Do not copy. This mounts twice in development.

import { useEffect } from 'react';

// Broken: Strict Mode + no destroy API = two bubbles.
export function BrokenHeeyaLoader({ agentId }) {
  useEffect(() => {
    const script = document.createElement('script');
    script.src = `https://heeya.fr/agent/${agentId}/embed.js`;
    script.async = true;
    script.setAttribute('data-agent-name', 'Support');
    document.body.appendChild(script);
    return () => {
      script.remove();
    };
  }, [agentId]);
  return null;
}

A singleton Effect

Guard on the widget node or on an existing embed script with that src. Skip setup if either is already in the document. Leave cleanup empty: removing the tag does not unload bundle.js, and it does not remove the message listener the widget registers on window. Mount this component once in the root tree, not inside a route element that unmounts on every navigation.

import { useEffect } from 'react';

const WIDGET = '.heeya-widget.heeya-container';

export function HeeyaChatbot({ agentId, agentName = 'Assistant' }) {
  useEffect(() => {
    const src = `https://heeya.fr/agent/${agentId}/embed.js`;
    if (
      document.querySelector(WIDGET) ||
      document.querySelector(`script[src="${src}"]`)
    ) {
      return;
    }

    const script = document.createElement('script');
    script.src = src;
    script.async = true;
    script.setAttribute('data-agent-name', agentName);
    document.body.appendChild(script);
  }, [agentId, agentName]);

  return null;
}

Same component in the root, next to your router:

import { HeeyaChatbot } from './HeeyaChatbot';

export default function App() {
  return (
    <>
      {/* routes */}
      <HeeyaChatbot agentId="YOUR_AGENT_ID" agentName="Support" />
    </>
  );
}

Why removing the script in cleanup is not enough

embed.js runs, then appends bundle.js. If cleanup runs before that second file executes, you can delete the first tag and every .heeya-widget node and still get a widget when the orphaned bundle finishes. A second setup then injects another copy. The singleton guard avoids that race. React's own rule still holds: if a third-party widget had a disconnect method, you would call it in cleanup. This one does not.

Hide the bubble on selected screens

The widget is not a React child. Route changes do not unmount it. To hide it on login, billing, or a focused editor, toggle CSS on the node the loader created. Do not remount the script.

Toggle display, do not remount

Pass a boolean from whatever router you use. Keep the matcher stable (a module-level list, not an inline array created every render).

import { useEffect } from 'react';

const WIDGET = '.heeya-widget.heeya-container';

export function HeeyaVisibility({ visible }) {
  useEffect(() => {
    const el = document.querySelector(WIDGET);
    if (!el) return;
    el.style.display = visible ? '' : 'none';
  }, [visible]);
  return null;
}

The container is position: fixed with z-index: 999999. If a modal in your app sits under that, hide the widget while the modal is open using the same helper.

Conversations persist in localStorage

The widget stores the conversation id as heeya_conv_<agent-id>. Client-side navigation does not reset it. A full reload on the same origin resumes that thread unless the visitor starts a new conversation from the widget header. Do not clear that key on every route change unless you intend to drop the transcript.

If you want a dedicated help route instead of a bubble, the same agent has a full-page assistant at https://heeya.fr/agent/YOUR_AGENT_ID/assistant. That page allows embedding in an iframe. It is still Heeya's hosted UI, not a component you restyle with Tailwind.

What the embed script does in the browser

This is the actual boot path, not a fictional SDK.

  1. Your page loads https://heeya.fr/agent/<uuid>/embed.js (async).
  2. That file stores document.currentScript on window.Heeya, sets language and colors from the agent, and appends /static/js/embed/bundle.js.
  3. The bundle reads the agent id from the script URL, applies data-* attributes, injects widget CSS, and appends the bubble to document.body.
  4. Chat traffic goes to https://heeya.fr/api/chat/<uuid>/... with CORS open for browser origins. Answers are retrieval-augmented from that agent's sources.

The only tools behind that chat are search over your knowledge base and, if you enable it, a form shown in the conversation. The agent does not call your React query client, does not read Redux or context, and does not open tickets in a helpdesk.

For why a retrieval widget is the product shape, and when it is the wrong shape, use the AI customer support for SaaS page. For the retrieval model itself, see RAG expertise.

Fit for a React app, and not a fit

Use this guide when

  • You have a client-rendered React app, typically Vite, and you want a site-wide support bubble.
  • Answers should come from docs you already maintain, not from a model you host.
  • You can ship a third-party script. You do not need a first-party component from npm.

Use something else when

  • You need a Heeya package, typed props, or a renderless headless chat you style yourself.
  • The agent must execute account actions through your API.
  • You are on Next.js App Router or Pages Router and care about next/script and SSR. That loading model is not this page.

Building your own retrieval stack, chunking, and bubble is a different decision. The criteria are on the custom AI chatbot build vs buy guide. Current plans, without copying amounts here, are on Heeya pricing.

FAQ about the React AI chatbot widget

Is there a Heeya React SDK or npm package?

No. Heeya does not publish an npm package, React component library, or public SDK. Integration is the same script tag the Connect page gives you. You can place that tag in Vite's index.html or inject it once from a useEffect.

Why do two chat bubbles appear in development?

React Strict Mode runs Effects twice in development: setup, cleanup, then setup again. Heeya's embed has no destroy method. If the Effect injects the script on every setup and only removes the script tag on cleanup, the first bundle can still finish loading and paint a second widget. Load the script once, or guard with a document query for an existing .heeya-widget.heeya-container or the embed script.

Can I import embed.js as a Vite module or use type="module"?

No. The loader reads document.currentScript to find the agent ID and data attributes. Module scripts leave currentScript null, so the widget does not initialize. Keep a classic script tag, separate from Vite's type="module" entry.

How do I hide the widget on login or checkout screens?

Do not unmount the loader. Query .heeya-widget.heeya-container and set display to none or back to empty. The widget lives on document.body, outside the React tree.

Does the chat survive client-side navigation?

Yes, if you load the script once for the document. The bubble is appended to document.body, so React Router does not remove it. The conversation id is stored in localStorage under a key that includes the agent id, so a later visit on the same origin can resume that thread.

Can the widget call functions in my React app?

No. There is no supported method to open the chat from your React code, subscribe to messages, or pass application state into the model. The agent answers from the sources you added in Heeya. If you enabled the contact form, visitors can leave details in the conversation.

Where do I get the agent ID?

In the Heeya dashboard, open the agent and use the Connect page. The snippet URL contains the UUID: https://heeya.fr/agent/YOUR_AGENT_ID/embed.js.

Create the agent, then paste the script

Register, add the sources your product already has, copy the Connect snippet into Vite, and keep a single widget instance for the document.

Create your React chatbot