← All posts
Guides7 min read

[GUIDES] · Jul 8, 2026 · 14:30

Add live chat to a Vue app: the Vue 3 + Vite guide

Two ways to install a chat widget in a plain Vue 3 SPA: a one-line paste in index.html or an onMounted loader in App.vue. Plus the vue-router nuance most guides skip.

Tm

The muro team

muro.chat

#vue#live chat#vite#chat widget#spa#installation

Your product runs on Vue 3, Vite builds it in seconds, and your support channel is still a mailto link buried in the footer. Every question from a trial user lands in a personal inbox, threads get lost, and half of them never get answered. Adding live chat fixes that, and in a Vue SPA it is genuinely a five minute job if you know one thing about how widgets and vue-router interact.

This guide covers plain Vue 3 with Vite. Two install options, the single-page-app nuance that trips people up, and how to switch on AI replies once the widget is live.

What you are actually installing

A chat widget is not a Vue component. It is a small script that injects its own UI into the page, outside your app's root element. muro's loader is about 9 KB, loads async, and attaches the chat bubble to the document itself, not to your #app div. That detail matters later.

  • →No npm package needed. The widget is a script tag, so there is nothing to add to package.json and nothing that bloats your bundle.
  • →Async by default. The loader does not block rendering and does not hurt your Lighthouse score.
  • →Framework-agnostic. The same snippet keeps working if you later migrate to a meta-framework or bolt Vue onto a Rails or Django backend.

Option 1: paste the snippet into index.html (simplest)

In a Vite project, index.html lives at the project root, not inside public/. It is the actual entry point Vite serves and transforms, so anything you put in its <head> ships with every page. Open it and paste the snippet just 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 muro dashboard (create a site at signup and the snippet with your real ID is on the install screen). Run npm run dev, open the app, and the bubble appears in the corner. That is the whole install.

Because index.html is static, this option has one limitation: the widget ID is hardcoded. If you run separate staging and production sites in muro and want a different ID per environment, use option 2.

Option 2: load it from App.vue with onMounted

If you would rather keep third-party scripts in code, or you need the widget ID to come from an environment variable, load the widget from your root component. App.vue mounts exactly once per page load, which makes it the right home. Do not put this in a route component.

js// App.vue, inside <script setup>
import { onMounted } from 'vue'

onMounted(() => {
  if (window.muro) return // guard against double-init (HMR, remounts)
  ;(function (w, d, s, o) {
    w.MuroChat = o
    w[o] = w[o] || function () { (w[o].q = w[o].q || []).push(arguments) }
    const j = d.createElement(s)
    j.async = true
    j.src = 'https://muro.chat/widget.js'
    d.getElementsByTagName('head')[0].appendChild(j)
  })(window, document, 'script', 'muro')

  window.muro('init', { widgetId: import.meta.env.VITE_MURO_WIDGET_ID })
})

Then put the ID in your env files so each environment gets its own site:

bash# .env.production
VITE_MURO_WIDGET_ID=your_production_widget_id

# .env.development
VITE_MURO_WIDGET_ID=your_staging_widget_id

The if (window.muro) return guard is doing real work. Vite's hot module replacement can re-execute component setup during development, and without the guard you would stack duplicate init calls. In production it is harmless insurance.

The SPA nuance: init once, and it survives navigation

Here is the part generic install guides get wrong for single-page apps. Vue-router navigation does not reload the page. When a user clicks from /dashboard to /settings, vue-router swaps components inside your app root and updates the URL. No new document, no new <head>, no scripts re-run.

That is exactly why the widget should be initialized once, at the document level, and never inside a routed view:

  • →The widget lives on `document`, not in your component tree. It attaches its bubble outside #app, so vue-router swapping views never touches it. An open conversation stays open while the user navigates.
  • →Initializing in a route component is the classic mistake. Put the loader in HomeView.vue and users who land directly on /pricing never get the widget, while navigating back to home risks a second init.
  • →Conversations persist across the session. The visitor's thread carries across route changes and page refreshes, so nobody has to type their question twice.

One honest caveat: this guide is for a plain client-rendered Vue 3 SPA served by Vite. If your app is server-rendered, the same principle applies (init once, on the client, after mount), but where the code goes differs per framework, so follow the conventions of whatever is doing your SSR.

Make it look like your product, not a bolt-on

Out of the box the widget is deliberately neutral. In the muro dashboard you can set:

  • →Color, so the bubble matches your brand instead of screaming third-party.
  • →Position and size, bottom-right or bottom-left, compact or roomy.
  • →Language, with 12+ built in. If your Vue app serves French or German users, the widget chrome can match.
  • →Agent name and avatar, so replies come from a person (or a named bot), not from a generic 'Support'.

None of this requires touching your Vue code again. Change it in the dashboard and it applies on the next load.

Turn on AI replies, and know when they stop

A chat bubble nobody answers is worse than no chat bubble. The realistic setup for a small team is AI for the repetitive layer and a human for everything that actually matters.

muro's AI auto-reply is grounded on content you give it. Point it at your docs or FAQ by importing a URL, add a little product context, and it answers the questions that fill most inboxes: how do I reset a password, is there an API, where do I find my invoice. Grounding is the important word. The AI answers from your published material instead of improvising.

Just as important is when it does not answer. muro hands the conversation to a human when the AI is unsure, and for the categories where a wrong answer costs you: refunds, account issues, or a visitor who is clearly angry. The handoff shows up in the shared inbox with the full conversation attached, so you reply with context instead of starting cold.

On cost: both plans include managed AI credits (1,000 per month on Solo, 3,000 on Fleet, one credit per AI action), and if you would rather run unlimited AI you can paste your own Claude API key. BYOK runs at zero markup. Plans are $19 and $59 with unlimited agents on both, no per-seat pricing; the details are on the pricing page.

After the install: answer from one place

The widget is the visible half. The half that keeps you sane is where messages land. Every conversation from your Vue app goes into a shared inbox, so a cofounder or a first support hire can jump in without forwarding threads around. Saved replies handle the answers you type weekly, and automation rules can tag, route, or auto-close the noise before you ever see it.

Two more pieces worth wiring up on day one. Email forwarding turns your existing support@ address into the same inbox, and reply-by-email means you can answer chat messages from your phone without opening a dashboard. And if you want conversations flowing into your own tooling, there is a full REST API with webhooks; the API docs cover both.

A five minute checklist before you ship

  1. 01Paste the snippet (option 1) or the loader (option 2) and confirm the bubble renders locally.
  2. 02Click through three or four routes and confirm the widget persists and an open conversation stays open.
  3. 03Send yourself a test message and answer it from the muro inbox.
  4. 04Import your docs URL, then ask the widget a question your docs answer. Check the reply matches reality.
  5. 05Ask something it should not handle ('I want a refund') and confirm it hands off to you instead of guessing.

If all five pass, deploy. The widget is the same static script in production, so there is nothing environment-specific to break, unless you went with env-based IDs, in which case double check that .env.production made it into your build.

✦ ✦ ✦

That is the whole job. Create a site in muro (there is a 14-day free trial, no charge today), paste the snippet into index.html or drop the loader into App.vue, and point the AI at your docs. If you want to poke at a working setup first, the live demo shows the widget and the inbox side by side. Your Vue app gets a support channel that actually gets answered, and you get out of the mailto business.

✦ Frequently asked

Questions people ask.

01Does this work with Nuxt, or with Vue 2?+

Yes to both, but the file changes. Nuxt renders on the server, so put the loader in a client-only plugin (plugins/muro.client.ts) or inside onMounted in app.vue, never in setup code that runs on the server. Vue 2 works with the same script tag in index.html, or mounted() on your root component. The widget is vanilla JS and does not care which version you run.

02TypeScript says Property 'muro' does not exist on type 'Window'. How do I fix it?+

Declare it once in a global type file. In env.d.ts (or any .d.ts under src) add: declare global { interface Window { muro?: (...args: unknown[]) => void } } followed by export {}. Then window.muro('init', ...) typechecks and vue-tsc stops failing your build. Casting to any at the call site also works, but you repeat it every time you touch the queue.

03Will the chat widget break my Tailwind or scoped component styles?+

No, the widget renders inside a Shadow DOM, so its CSS and yours cannot reach each other. Tailwind preflight, scoped styles and global resets stop at the shadow boundary, and the widget cannot restyle your app either. It also mounts outside #app, so Vue never diffs it and re-renders leave it alone. The loader is about 9 KB gzipped and loads async.

04Can I show the widget only on certain routes, or only to logged-in users?+

Yes, use the App.vue option and gate the init call. Read your auth store or route.meta there and only call muro('init', ...) when the condition holds. App.vue does not remount after login, so watch the auth flag and init on the first truthy value, keeping the if (window.muro) return guard so a mid-session login does not stack a second widget.

05What happens to messages when nobody is online?+

They land in the shared inbox and muro emails the workspace, so an unanswered message reaches you even with the dashboard closed. You reply straight from your mail client and the visitor sees it in the widget thread. With AI auto-reply on, most overnight questions get answered from your published docs immediately, and only the ones it is unsure about wait for a human.

06Where is chat data stored, and can I sign a DPA?+

Data is hosted in the EU and a signed DPA is available. The public sub-processor list names everyone else in the chain, including the AI provider when auto-reply is on, which is the part most vendor pages leave vague. On the Vue side there is nothing to audit in your dependency tree: the loader is a script tag, so nothing ships inside your Vite bundle.

✦ Try it

One support inbox for all your projects, one flat price.

muro is live chat, an AI that answers from your own docs, and a shared inbox for every site you run. See it both sides in the live demo, check the flat pricing, or see how muro compares. It's the same snippet on every platform, so you can browse every install guide.

Tm

✎ Written by

The muro team

muro.chat