Your React app is live, users are trickling in, and support currently means someone finding your email in the footer and waiting a day for an answer. A chat widget fixes that. The catch: most install guides assume WordPress or a site builder with a plugin store. A plain React SPA built with Vite or Create React App has neither. It has something better. You own the HTML and every line of JavaScript, so installing live chat takes one paste and zero plugins.
This guide shows two clean ways to add live chat to a React app: paste the snippet into index.html, or load it from a useEffect. Then it answers the question every SPA developer asks (what happens on route changes?), and covers the part that actually saves your evenings: AI replies grounded on your own docs, with a human handoff when the AI is out of its depth.
Before you start: get a widget ID
Sign up for muro (the signup takes about a minute, 14-day trial, no card), create a site, and copy the widget ID from the install screen. While you are in there you can set the widget color, position, size, agent name, avatar, and language (it ships with 12+ languages). None of that requires touching code later; it is all dashboard config, so your React bundle never changes when you tweak the look.
Option 1: paste the snippet into index.html
This is the right default for most apps. Every React SPA still has exactly one real HTML file. In a Vite project it is index.html at the project root. In Create React App it is public/index.html. Open it and paste this before the closing </head> tag:
html<script>
(function(w,d,s,o){
w.MuroChat=o;w[o]=w[o]||function(){(w[o].q=w[o].q||[]).push(arguments)};
var j=d.createElement(s);j.async=1;j.src='https://muro.chat/widget.js';
d.getElementsByTagName('head')[0].appendChild(j);
})(window,document,'script','muro');
muro('init',{widgetId:'YOUR_WIDGET_ID'});
</script>Replace YOUR_WIDGET_ID with the ID from your dashboard and deploy. That is the whole install. The script loads async, weighs about 6 KB, and does not block first paint, so your Lighthouse score will not notice it.
Because the loader lives in the HTML shell rather than the React tree, it is immune to anything your app does: re-renders, route changes, error boundaries, hot reloads. It initializes once when the page loads and stays out of the way.
Option 2: load it from a useEffect
Sometimes you want the loader inside React. The usual reasons: you keep the widget ID in an env var instead of hardcoding it, you only want chat in production builds, or you need to wait for a cookie consent banner before loading anything third party. In that case, run the same loader from a useEffect at the top of your component tree, in App:
jsimport { useEffect } from 'react';
export default function App() {
useEffect(() => {
// The loader sets window.MuroChat, so this guard
// makes the effect safe to run more than once.
if (window.MuroChat) return;
(function (w, d, s, o) {
w.MuroChat = o;
w[o] = w[o] || function () { (w[o].q = w[o].q || []).push(arguments); };
var j = d.createElement(s);
j.async = 1;
j.src = 'https://muro.chat/widget.js';
d.getElementsByTagName('head')[0].appendChild(j);
})(window, document, 'script', 'muro');
window.muro('init', { widgetId: 'YOUR_WIDGET_ID' });
}, []);
return <div>{/* your routes */}</div>;
}Two things are worth understanding about this code. First, there is no cleanup function, and that is deliberate. The widget is a global singleton that attaches to the document, not a React-owned resource. Tearing it down on unmount and re-injecting it on mount would make the chat bubble flicker for zero benefit. Second, the window.MuroChat check makes the effect idempotent: if it runs again for any reason, it sees the flag and returns before doing anything.
For env vars, swap the hardcoded ID for import.meta.env.VITE_MURO_WIDGET_ID in Vite or process.env.REACT_APP_MURO_WIDGET_ID in Create React App, and return early from the effect when the variable is empty. That gives you chat in production and silence in local dev with one line of config.
The SPA question: what happens on route changes
This is the part that worries people coming from server-rendered sites, and the answer is boring in the best way. React Router (or whatever router you use) swaps components inside your root div. The widget does not live there. The loader appends a script tag to the document head, and the widget renders its own container on the document, outside the React tree entirely. Client-side navigation never touches it.
So the rule is: one init, at the top level. The widget then survives every route change with its state intact. A visitor can open a conversation on your pricing page, click through to your features page, and the chat stays open with the full thread, because from the widget's point of view the page never changed.
- →Do init once, in
index.htmlor inApp. Both options above do this correctly. - →Do not call
muro('init')inside individual page components. The window guard would prevent duplicates, but the widget would be missing on any route that forgot the call. - →Do not mount and unmount the loader based on the current route. If you want chat hidden on certain pages, that is a widget behavior question, not a script-injection question.
Verify the install
- 01Run
npm run dev(Vite) ornpm start(CRA) and open the app in your browser. - 02Look for the chat bubble in the corner you picked. If it is missing, check the browser console for a blocked request (ad blockers sometimes eat third-party scripts in dev) and confirm the widget ID matches your dashboard.
- 03Send yourself a test message. It should show up in your muro inbox within a second or two.
- 04Click through three or four routes. The bubble should never flicker, remount, or lose the open conversation.
If you want to see the expected behavior before wiring anything up, the live demo runs the same widget on a real page, so you know what a correct install looks and feels like.
Turn on the AI before you ship
An installed widget without automation just means you now answer chats instead of emails. The upgrade that changes your week is the AI layer. muro's auto-reply is grounded on your published help articles and product context: point it at a URL (your docs site or FAQ page), it imports the content, and it answers visitor questions from that material instead of improvising.
The grounding matters more than the model. When the AI is not confident, or when the topic is one you should personally see (a refund request, an account problem, an annoyed customer), it hands the conversation to you instead of guessing. The message lands in the shared inbox, and with email forwarding turned on you can answer straight from your mail client using reply-by-email. Saved replies and automation rules cover the rest of the repetitive work.
What it costs
muro's pricing is by project count, not seats: Solo is $19/mo for up to 2 projects, Fleet is $59/mo for unlimited projects, and both include unlimited agents. Managed AI credits are included (1,000/mo on Solo, 3,000/mo on Fleet, one credit per AI action), and if you would rather run the AI on your own terms, paste your own Claude (Anthropic) API key: BYOK gives you unlimited AI at zero markup. The trial is 14 days, no card.
That is the whole job: create a site in muro, paste the snippet into index.html (or drop the loader in a useEffect if you need env vars or consent gating), point the AI at your docs URL, and push. Your React app gets live chat that survives every route change, and you get a support inbox that answers the easy half by itself. Start the trial and you can have it live before your dev server finishes restarting.
