Guide

Live Chat on a Single-Page App

9 minute read · Updated August 15, 2026

The page never reloads, and everything follows from that

In a traditional website every navigation is a demolition: the browser throws away the page, fetches a new document, and runs every script again from scratch. A single-page app does the opposite. The document loads once, and from then on your router swaps content in and out of a page that never goes away.

That single difference is the whole story for a chat widget. The embed script runs once, at first load, and the widget it creates stays mounted for the entire session no matter how many routes the visitor moves through. Everything that feels surprising about chat on a React, Vue, Angular or Svelte app is a consequence of that one fact — some of it pleasant, some of it not.

Worth saying plainly: none of this makes an SPA a bad place for live chat. It is a better place than a multi-page site in at least one important way. But the assumptions baked into most setup advice are page-load assumptions, and on an SPA those assumptions quietly stop being true.

What actually gets better

On a multi-page site, a visitor who is mid-conversation and clicks a link is taking a small risk. The page tears down and rebuilds, the widget re-initialises, and the conversation has to be restored rather than simply continued. It usually works, and it is still a seam.

On an SPA there is no seam, because there is nothing to rebuild. A visitor can start a chat on your pricing route, be walked to a documentation route by an agent, open a comparison route, and never once lose the thread. For a support conversation that involves navigating the product, that is genuinely valuable: the agent can say try the billing screen and watch the visitor go there without the conversation flickering.

It also means the widget is loaded once rather than on every navigation, which is the cheaper arrangement for a visitor who moves through ten routes in a session.

What quietly stops working, and what does not

The flip side of a page that never reloads is that anything keyed to a page load happens exactly once. Your router changing the URL is not a page load. It updates the address bar and re-renders part of the DOM, and code written to run on load does not run again.

The intuitive conclusion is that page-targeted invitations must therefore be broken on an SPA, and that is worth correcting because it is the reverse of what actually happens. Invitation rules are not evaluated on page load. They are evaluated on a repeating timer, roughly once a second, and each evaluation re-reads the browser's current location. A client-side route change updates that location, so the next evaluation sees the new URL and matches against it. A rule targeted at your pricing route fires when the router moves someone there, with no method to call and no integration code to write.

What genuinely does not reset is the pair of signals most people reach for as the SPA-safe alternative. Time on page is measured from the moment the widget started, and on an SPA that is the moment the application loaded, not the moment the current route appeared. Scroll depth is a high water mark for the session rather than for the route: scroll to the bottom of a long documentation page, navigate to a short one, and the widget still considers the visitor deeply scrolled.

Neither is a bug, but both change what a rule means. On a multi-page site thirty seconds on page and sixty percent scrolled describes engagement with one page. On an SPA it describes engagement with the visit, and it will keep being true on every route the visitor reaches afterwards. If a rule like that feels like it fires too eagerly on your app, this is why. Pair the behavioural condition with a URL condition so the rule still has to be on the right route to match, and it behaves the way you meant.

One thing not to reach for: there is no public method for firing an invitation from your own router code. The invitation engine is configured in the dashboard and evaluated in the browser, and it is not driven by a call from your application. Advice to trigger it from wherever you fire your analytics page-view sounds reasonable and produces code that does not work. Configure the rule instead and let the timer find it.

All of this describes the current generation of the widget. If your site is running an older installation, confirm the behaviour on your own routes rather than assuming it, and see which version you are running if you are not sure.

Where the snippet goes

Put the embed in the application shell — the HTML document your build produces, typically index.html, or the root layout that never unmounts. What you must avoid is mounting it inside a component that belongs to one route.

The reason is worth internalising. A component that owns the widget will unmount when the visitor navigates away from that route, and its cleanup takes your chat with it, quite possibly in the middle of a conversation. Then it mounts again when they come back, and now you have the question of whether you have one widget or two. Keeping the script at the shell level side-steps the entire class of problem, because nothing in your component tree owns it.

If your framework has a strict content-security policy — and SPA teams are more likely than most to have one — the widget host has to be allowed in script-src, and the styles it loads in style-src. A CSP that blocks the script does not produce a broken-looking page; it produces no widget at all, silently, which is a genuinely difficult symptom to diagnose if you are not looking at the browser console.

The failures that actually happen

Four things account for most SPA chat problems, and all four are cheap to rule out.

The double mount. Development-mode double rendering and hot module replacement can run your mounting code twice. If you see two widgets in development and one in production, this is why — but verify in a production build rather than assuming.

The widget that vanishes after a navigation. Almost always the mounting-inside-a-route problem described above.

The CSP block. Nothing renders, no error is visible on the page, and the console has the answer.

Server-side rendering. If your app renders on the server, the embed needs to run in the browser rather than during the server render, where there is no document to attach to.

Your CSS and the widget share a page

The widget renders inline in your document rather than inside an isolated frame. It creates its own container element and attaches it to the page body, outside your application root, which is what keeps a framework re-render from tearing the conversation down. It also means your stylesheet and the widget's stylesheet are in the same cascade, and application CSS is usually the more aggressive of the two.

Broad element selectors are the usual offender. A design-system rule for every button, input or focus outline in the document also applies to the controls inside the widget, and because the widget's own rules are written to be polite, a heavier host rule wins. The symptom is never reported as a CSS conflict. It is reported as a focus ring that looks too thick, a send button with the wrong corner radius, or a field that changes height when the visitor clicks into it.

Two habits avoid nearly all of it. Scope your global resets to your own application root rather than to bare element selectors, and look at the widget at least once with the production stylesheet loaded rather than only in an isolated component preview where your reset does not exist. Widget appearance is configured on our side, so when something looks wrong the first question is whether your page is overriding it, not which setting to change.

A test plan you can run in ten minutes

Do this on a production build, on a real phone as well as a desktop, and do it after every deploy that touches the shell or the CSP.

Load the app on its default route and confirm the widget appears. Start a chat and send a message. Now navigate through three routes without refreshing and confirm the conversation is still there and still connected. Use the browser back button, which is the case most people forget, then a deep link straight into a route to prove the widget loads there too. Refresh mid-conversation and check the thread is recovered. Finally, if you use page-targeted invitations, navigate into the targeted route from elsewhere and watch whether the invitation fires.

Write the result down. This is a ten-minute walk that catches the failures your monitoring never will, because none of them are errors — the app is fine, the widget is simply absent or silent.

How MyLiveChat fits

MyLiveChat installs as a script tag, so an SPA needs no special build integration: put it in the shell and it mounts once for the session. Because the widget lives outside your component tree, your router can do whatever it likes without disturbing an open conversation.

Where “the shell” actually lives differs by framework, and so do the ways the install quietly fails. There are step-by-step guides for React, Next.js, Vue, Angular, Astro, SvelteKit, Gatsby and Nuxt, each covering the file to edit and the framework-specific reason a script tag can end up parsed but never run.

Invitation rules can be built on time on page, scroll depth, referrer and returning-visitor status as well as URL matching. The part that matters on an SPA is how the URL rules are evaluated: they are re-checked on a short repeating interval and read the browser’s current address each time, rather than remembering the address the page was loaded with. A client-side route change is therefore picked up on the next check, within about a second, with no reload and nothing for you to call. The full set is described on the proactive invitations page, and the timing question is covered in our guide to when to trigger an invitation.

Two consequences are worth knowing before you design around it. First, the URL and page title recorded against a conversation are captured at the moment the conversation starts, not continuously — so if a visitor opens chat on the pricing view and then navigates on through your app, the agent still sees the pricing view. That is usually what you want, because it is the page that prompted the question, but it does mean the record is a starting point rather than a live trail. Second, if your app explicitly sets a source URL in the widget configuration, that fixed value wins over the live address, and every conversation will report the same page forever. On a server-rendered site pinning it is harmless; on an SPA it silently destroys the page context, so leave it unset and let the widget read the real address.

Note that URL-rule evaluation of this kind belongs to the current widget generation. If your account was created some years ago it may still be running an older widget, in which case the behaviour above does not apply — our guide to which widget version you are running explains how to tell and what moving involves.

One caution that applies to every framework: your app may know exactly who the signed-in user is, and it is tempting to treat what arrives in chat as equally trustworthy. It is not. A public chat widget is not an authentication channel, so verify anything that matters through the account itself rather than through the conversation.

What to measure

Two numbers tell you whether chat is healthy on an SPA. Chats started per route shows whether the widget is reaching people where they hesitate, and a route with heavy traffic and no chats is usually a configuration problem rather than a contented audience.

The second is a deploy check rather than a metric: add the widget to your post-release smoke test. SPA chat rarely breaks gradually. It works, then a shell refactor or a tightened CSP removes it entirely, and because nothing errors, the first person to notice is a customer who cannot find you.

Put it into practice

MyLiveChat is free forever for one agent, with unlimited chats and the embed code ready in about a minute.

Free forever for 1 agent

Give every visitor an instant way to reach you.

Launch live chat, connect your knowledge base, and add AI answers when you are ready. No credit card, no trial clock.