← All posts
Guides7 min read

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

Add live chat to Remix: the React Router v7 install guide

Where the widget script goes in app/root.tsx, why dangerouslySetInnerHTML is the right move, and how one init survives every client-side navigation.

Tm

The muro team

muro.chat

#remix#react router#live chat#widget install#react#hydration

You shipped a Remix app. Now you want live chat on it, and the two questions every React dev asks are the same: where does a third-party script live when React owns the entire document, and will it re-fire every time a user navigates? Short answer: Remix (now React Router v7 in framework mode) is one of the friendliest stacks for a chat widget, precisely because of how it handles navigation. This guide gives you the exact code for app/root.tsx, a hydration explanation with no hand-waving, and the follow-up step that stops the chat from becoming a second inbox you dread opening.

Why Remix is a good host for a chat widget

Remix renders the full document on the server for the first request, then hydrates the entire page, from the <html> tag down. After that first load, every navigation is client-side: React Router swaps route components and fetches loader data, but the document itself never reloads. That second part is the one that matters for chat. The widget script loads once, the chat state (open conversation, unread badge, visitor identity) lives in that single page context, and it survives your user browsing from /pricing to /dashboard to /settings without ever re-initializing.

Compare that to a classic multi-page app, where every click tears the widget down and boots it again. In Remix you get the ideal case for free: one init per session, zero per-route work.

What you are actually installing

The muro loader is a tiny inline snippet that does two things: it defines a muro() command queue on window, and it appends an async script tag that pulls the real widget (about 6 KB) from muro.chat. Because that tag is async, it never blocks parsing, rendering, or hydration. Behind the bubble you get a shared inbox for your team, saved replies, automation rules, email forwarding with reply-by-email, and an AI layer that answers from your docs and hands off to a human when it is unsure. More on that below. If you want to poke at the widget before touching code, there is a live demo.

Here is the plain HTML snippet, the same one you would paste into any static site:

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>

In Remix you cannot paste that directly, because your document is JSX. Here is the translation.

Option 1: the loader in app/root.tsx, inside the head

Your app/root.tsx exports the document shell. Put the loader code in a string, then render it with a script tag using dangerouslySetInnerHTML:

js// app/root.tsx
import {
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
} from "react-router";

const muroLoader = `
  (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'});
`;

export function Layout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />
        <Links />
        <script dangerouslySetInnerHTML={{ __html: muroLoader }} />
      </head>
      <body>
        {children}
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

export default function App() {
  return <Outlet />;
}

Still on Remix v2 rather than React Router v7? The only difference is the import source: pull Links, Meta, Outlet and friends from @remix-run/react instead of react-router. The JSX is identical.

Option 2: end of body, even simpler

If you would rather keep your head reserved for meta and link tags, drop the same script at the end of the body, after <Scripts />:

jsexport function Layout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <Meta />
        <Links />
      </head>
      <body>
        {children}
        <ScrollRestoration />
        <Scripts />
        <script dangerouslySetInnerHTML={{ __html: muroLoader }} />
      </body>
    </html>
  );
}

Functionally the two placements are nearly identical because the widget file is async either way. Head placement starts the download a few milliseconds earlier; body placement keeps the head tidy. Pick one and move on.

The hydration story, honestly

React devs are right to be suspicious of inline scripts in a hydrated document, so here is exactly what happens, step by step:

  1. 01The server renders the document, including your inline loader, as plain HTML.
  2. 02The browser parses that HTML and executes the loader immediately: window.muro now exists as a command queue, and the async request for widget.js goes out.
  3. 03React hydrates the document. Your inline script was rendered via dangerouslySetInnerHTML with a string that never changes, so server markup and client markup are identical. No mismatch, no console warning.
  4. 04widget.js finishes downloading, boots, drains the queued muro('init') call, and appends its own DOM (the bubble, the panel) to the page. React never rendered those nodes, so it never reconciles or touches them.
  5. 05Your user clicks around. Client-side navigations swap route components inside <Outlet /> and leave the rest of the document alone. The widget, and any open conversation, persists untouched.

A few non-obvious consequences fall out of this. You do not need a useEffect, which means React StrictMode's double-invoked effects in development are irrelevant: the loader is not an effect, it runs when the browser parses it, once per document load. You do not need a typeof window guard either, because the loader lives inside a string; the server renders the string, it never executes it. And there is nothing to clean up on unmount, because root.tsx never unmounts.

Per-environment widget IDs

You probably do not want staging chats mixed into your production inbox. Widget IDs in muro are scoped per site, so create a second site for staging and swap the ID with an env var. React Router v7 apps build with Vite, so a client-exposed variable does the job:

js// app/root.tsx
const widgetId = import.meta.env.VITE_MURO_WIDGET_ID;

const muroLoader = widgetId
  ? `
  (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:'${widgetId}'});
`
  : "";

// then, in the Layout JSX:
{muroLoader ? (
  <script dangerouslySetInnerHTML={{ __html: muroLoader }} />
) : null}

Leave the variable unset locally and the widget simply does not render in dev. Everything else about the widget (color, position, size, the agent name and avatar, the display language, and it speaks 12+ of those) is configured from the muro dashboard, so appearance tweaks never require a redeploy.

Now make the chat answer itself

Installing the bubble is the easy 20 percent. The reason chat tools rot on indie projects is the other 80: every "how do I..." message lands while you are mid-refactor, and each one costs you twenty minutes of focus. muro's answer is an AI first responder grounded on your published help articles and product context. Train it by importing a URL (your docs site or FAQ page) and it starts answering the repetitive questions inside the chat, at any hour, whether or not you are awake.

The part that matters for trust: when the AI is not confident, or the topic is one a bot should not touch (refunds, account issues, an angry customer), it hands the conversation to a human instead of improvising. The thread lands in your shared inbox with the AI's context attached, and you can reply from there or straight from your email client via reply-by-email.

  • Import your docs or FAQ by URL; the AI answers from them, not from the open internet.
  • Automatic handoff to you on uncertainty, refunds, account issues, or frustration.
  • Saved replies and automation rules for the patterns you would rather script than delegate.
  • A full REST API and webhooks if you want conversation events flowing into your own stack.

What this costs

Pricing is deliberately simple: Solo is $19/mo for up to 2 projects, Fleet is $59/mo for unlimited projects, and both come with unlimited agents. No per-seat pricing, so adding a cofounder or a part-time support person costs nothing extra. Managed AI credits are included (1,000/mo on Solo, 3,000/mo on Fleet, where 1 credit equals 1 AI action), or paste your own Claude API key and run unlimited AI at zero markup with BYOK. Full details on the pricing page. The trial is 14 days, no card.

✦ ✦ ✦

The whole install is one string and one script tag in root.tsx, and it behaves correctly through hydration and every client-side navigation after it. Create a site in muro, drop your widget ID into the loader, deploy, and then spend five minutes importing your docs so the AI is already answering the first question while you get back to shipping.

✦ 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 — browse every install guide.

Tm

✎ Written by

The muro team

muro.chat