How to Add a Chat Widget to a Next.js, React, Vue or Astro Site
Load a chat widget once at the root of a Next.js, React, Vue, Nuxt, Astro or SvelteKit app, avoid double init and SSR errors, and keep it across route changes.

Short answer
To add a chat widget to a Next.js, React, Vue or Astro site, load its script once at the root, never inside a page component. As of October 2026, Next.js 16 uses next/script in app/layout.tsx; React 19 uses one effect with a cleanup; Vue and Nuxt use index.html, a plugin or useHead; Astro 7 uses an is:inline script, plus an astro:after-swap listener with its client router; SvelteKit uses app.html. Then add localhost and preview hosts to its allowed domains.
On a plain website, a chat widget is one script tag before </body>. In a JavaScript framework, components mount, unmount and render on a server, so where that tag goes decides whether the launcher appears once, twice or not at all. This guide is for developers and technical founders adding a support chat to a Next.js, React, Vue, Nuxt, Astro or SvelteKit app; the one-script-tag install guide covers WordPress, Shopify and site builders.
It covers placement, a recipe per framework, route changes, Content Security Policy, allowed domains and testing.
The examples use PepoChat, our product. Its loader behaviour below was read in its source code and documentation, framework APIs in each project's official docs and versions on npm, all on 22 October 2026. Most chat widgets ship the same way, a loader that draws a launcher and an iframe, so the rules carry over, though each vendor names its API and handles duplicates differently.
Why should a chat widget load once, at the root of your app?
A chat widget should load once, from the root layout or the HTML template, because its loader is page-wide state: it draws one launcher on document.body and exposes one global API, while framework components mount, unmount and remount many times.
A loader is the small script you paste: it reads its settings, draws the launcher and manages the chat window, an iframe served from the vendor's domain. PepoChat's loader is 8,249 bytes, or 3,387 bytes compressed as served (measured 22 October 2026). It reads data-organization-id, data-position and data-preload from its own script tag, or, when the browser cannot say which tag is running, from the first script whose src contains "widget" and that carries a data-organization-id. If it runs before the page is parsed, it waits for DOMContentLoaded. A second copy on the same page logs "already initialized" and does nothing, so you never get two launchers.
Three things go wrong when a component owns the loader instead:
- Remounts. Every mount can inject another script tag: React's Strict Mode runs effects twice in development, and a widget in a page component remounts on every navigation.
- Server rendering. Code that touches
windowordocumentfails when Next.js, Nuxt, SvelteKit or Astro render a page on the server, where neither object exists. - Orphaned launchers. The launcher lives on
document.body, outside your component tree, so unmounting the component that loaded it leaves the launcher behind; only the widget's API removes it.
The table sums up each framework; the sections below give the code.
| Framework (version) | Where the loader goes | How | Watch out for |
|---|---|---|---|
| Next.js 16.4, App Router | app/layout.tsx | next/script with lazyOnload or afterInteractive | onLoad and similar props need a Client Component |
| React 19.3 on Vite | index.html, or one root component | Script tag, or useEffect with a cleanup | Strict Mode runs the effect twice in development |
| Vue 3.5 on Vite | index.html, or a plugin | Script tag, or app.use() | Guard window if the app is server-rendered |
| Nuxt 4.6 | app/app.vue, or app/plugins/ | useHead with tagPosition: "bodyClose", or a .client plugin | app.head in nuxt.config.ts is static |
| Astro 7.4 | The base layout | <script is:inline> | <ClientRouter /> replaces the body on navigation |
| SvelteKit 3.1 | src/app.html | A plain script tag before </body> | Add %sveltekit.nonce% with a nonce-based CSP |
Versions: latest npm releases on 22 October 2026; APIs from each framework's documentation, read the same day.
How do you add a chat widget to a Next.js App Router site?
In a Next.js 16 App Router site, add the chat widget with the next/script component in the root layout, app/layout.tsx, so it loads on every route and, in the words of the Next.js docs, will "only load once" as visitors move between pages.
PepoChat's install page gives this snippet:
// Add to your root layout (app/layout.tsx)
import Script from "next/script";
<Script
src="https://widget.pepochat.com/widget.js"
strategy="lazyOnload"
data-organization-id="YOUR_ORGANIZATION_ID"
/>
In context, the root layout looks like this; it stays a Server Component, because next/script needs no "use client".
// app/layout.tsx (Next.js 16, App Router)
import Script from "next/script";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
{children}
<Script
src="https://widget.pepochat.com/widget.js"
strategy="lazyOnload"
data-organization-id="YOUR_ORGANIZATION_ID"
/>
</body>
</html>
);
}
next/script forwards attributes it does not use, such as nonce and data-*, to the final script element, which is how data-organization-id reaches the loader. The Script component reference lists four strategies:
beforeInteractiveloads before any Next.js code and is meant for critical scripts such as bot detectors and cookie consent managers, not chat.afterInteractive, the default, loads early but after some hydration.lazyOnloadloads during browser idle time, after the page's resources; the docs name chat support plugins as a typical use.workeris experimental and does not work with the App Router.
Our page speed test of 10 chat widgets measures the chat frame's cost with preloading on and off. On the Pages Router, the same <Script> goes in your custom pages/_app. The onLoad, onReady and onError props work only in Client Components, and a chat widget rarely needs them: code that calls it can check for window.EchoWidget first.
How do you load a chat widget in React without loading it twice?
In a React 19 app, load the chat widget from one component near the root whose effect injects the script only when the widget is absent and whose cleanup removes the launcher, because effects run only in the browser and twice in development.
React's useEffect reference states both rules: effects "only run on the client", and with Strict Mode on, React runs "one extra development-only setup+cleanup cycle" before the first real setup. Since Next.js 13.5.1, Strict Mode is on by default in the App Router. PepoChat's install page gives this component:
// Render <PepoChatWidget /> once, near the root of your app
import { useEffect } from "react";
export function PepoChatWidget() {
useEffect(() => {
if (window.EchoWidget) {
window.EchoWidget.init({ organizationId: "YOUR_ORGANIZATION_ID" });
} else {
const script = document.createElement("script");
script.src = "https://widget.pepochat.com/widget.js";
script.async = true;
script.setAttribute("data-organization-id", "YOUR_ORGANIZATION_ID");
document.body.appendChild(script);
}
return () => {
window.EchoWidget?.destroy?.();
};
}, []);
return null;
}
What it does in each situation:
- First mount:
window.EchoWidgetdoes not exist yet, so the effect appends the loader. - Strict Mode's extra cycle (development only): the cleanup runs before the loader has arrived and finds nothing to remove, so the second setup appends a second tag. The first copy draws the launcher; the second logs "already initialized" and stops. Production builds load one tag.
- Unmount: the cleanup calls
destroy(), which removes the launcher and chat window but keeps the API object. - Remount:
window.EchoWidgetexists, so the effect callsinit()and the launcher returns without a second download.
React's docs warn against hiding the double run with a ref, because an effect must still work after a real remount; a cleanup that undoes the setup is the fix they recommend. In the Next.js App Router, start the component's file with "use client";, since effects and window are client-only; the root layout that renders it can stay a Server Component.

A client-only React app built with Vite has a simpler option. Vite treats index.html at the project root as the app's entry point, so the HTML snippet before </body> runs once per page load, whatever your components do:
<!-- index.html at the project root (Vite) -->
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
<script
src="https://widget.pepochat.com/widget.js"
data-organization-id="YOUR_ORGANIZATION_ID"
></script>
</body>
In a .tsx file, TypeScript rejects window.EchoWidget until you declare it. This declaration mirrors PepoChat's loader source; put it in any .d.ts file your tsconfig.json includes:
// echo-widget.d.ts
export {};
declare global {
interface Window {
EchoWidget?: {
init: (config: {
organizationId?: string;
position?: "bottom-right" | "bottom-left";
preload?: boolean;
}) => void;
show: () => void;
hide: () => void;
destroy: () => void;
};
}
}
How do you add a chat widget to Vue 3 or Nuxt?
In a Vue 3.5 single-page app, paste the chat widget's script tag into index.html or load it from a small plugin; in Nuxt 4, use useHead in app/app.vue or a client-only plugin, so nothing touches window during server rendering.
Vue's docs define a plugin as self-contained code that adds app-level functionality: an object with an install() method, or a function, that runs when you call app.use(). That suits a once-per-app loader:
// src/plugins/pepochat.js
export default {
install() {
// Skip on the server and when the widget already exists.
if (typeof window === "undefined" || window.EchoWidget) return;
const script = document.createElement("script");
script.src = "https://widget.pepochat.com/widget.js";
script.async = true;
script.setAttribute("data-organization-id", "YOUR_ORGANIZATION_ID");
document.body.appendChild(script);
},
};
// src/main.js
import { createApp } from "vue";
import App from "./App.vue";
import pepochat from "./plugins/pepochat";
createApp(App).use(pepochat).mount("#app");
For one section only, mirror the React component with Vue's onMounted (load the script or call init()) and onUnmounted (call destroy()); Vue's docs note neither hook runs during server-side rendering.
Nuxt 4 offers two options. useHead in app/app.vue renders the tag into the server HTML, tagPosition: "bodyClose" puts it at the end of the body, and Unhead, Nuxt's head manager, accepts data-* attributes on script entries:
<!-- app/app.vue -->
<script setup lang="ts">
useHead({
script: [
{
src: "https://widget.pepochat.com/widget.js",
"data-organization-id": "YOUR_ORGANIZATION_ID",
tagPosition: "bodyClose",
},
],
});
</script>
<template>
<NuxtPage />
</template>
The app.head key in nuxt.config.ts also works for a fixed ID, though Nuxt's SEO and meta guide notes it takes no reactive data and recommends useHead() in app.vue. To load the widget only after a condition, such as a consent choice, use a plugin: Nuxt registers top-level files in app/plugins/ automatically, and a .client suffix limits a plugin to the browser:
// app/plugins/pepochat.client.ts
export default defineNuxtPlugin(() => {
if (window.EchoWidget) return;
const script = document.createElement("script");
script.src = "https://widget.pepochat.com/widget.js";
script.async = true;
script.setAttribute("data-organization-id", "YOUR_ORGANIZATION_ID");
document.body.appendChild(script);
});
How do you add a chat widget to an Astro site?
In Astro 7, add the chat widget's script to your base layout with the is:inline directive, which tells Astro to leave the tag as written instead of processing and bundling it as it does by default.
Astro already skips processing for a script with any attribute besides src, but is:inline makes the intent explicit. Inline scripts are not deduplicated: the tag appears as often as it is rendered, so it belongs in the base layout, not in a component used several times per page.
Astro's client router changes the picture. With <ClientRouter />, each client-side navigation completely replaces the <body>, taking the launcher and chat window with it. Inline scripts may or may not run again, and if the loader runs, its duplicate check stops it. The fix is a processed script, which Astro runs once until the next full page load, that re-creates the widget after every swap:
---
// src/layouts/BaseLayout.astro (Astro 7)
import { ClientRouter } from "astro:transitions";
const { title } = Astro.props;
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>{title}</title>
<ClientRouter />
</head>
<body>
<slot />
<script
is:inline
src="https://widget.pepochat.com/widget.js"
data-organization-id="YOUR_ORGANIZATION_ID"
></script>
<script>
// Processed by Astro, so this listener is registered once.
// It is only needed when the layout uses the client router.
document.addEventListener("astro:after-swap", () => {
window.EchoWidget?.init({});
});
</script>
</body>
</html>
According to Astro's view transitions guide, astro:after-swap fires right after the new page replaces the old one and before its scripts run, so the launcher reappears with the new content; an empty init({}) keeps the current settings. An open chat still closes on each navigation, because its frame is re-created. Without the client router, every navigation is a full page load and the layout tag is enough. Processed Astro scripts are TypeScript, so type-checking them needs the declaration from the React section.
How do you add a chat widget to SvelteKit?
In SvelteKit 3, paste the chat widget's script tag into src/app.html before </body>; that file is the page template for every route, and the launcher it draws sits outside the element SvelteKit hydrates and updates.
SvelteKit's project structure docs ask for %sveltekit.body% to sit inside a <div> rather than directly in <body>, so that elements injected by browser extensions are not destroyed by hydration. The same arrangement protects the launcher, which the loader appends to the body next to that div, so client-side navigation never touches it:
<!-- src/app.html (SvelteKit 3) -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body>
<div style="display: contents">%sveltekit.body%</div>
<script
src="https://widget.pepochat.com/widget.js"
data-organization-id="YOUR_ORGANIZATION_ID"
></script>
</body>
</html>
A <svelte:head> block in the root +layout.svelte can also insert the tag, but the template keeps it out of your components. If you use SvelteKit's nonce-based CSP, add nonce="%sveltekit.nonce%" to the tag; SvelteKit fills in that placeholder for scripts you include by hand.

How do you keep the widget working across route changes?
A chat widget loaded once at the root keeps working across client-side route changes on its own, because every framework in this guide except Astro's client router updates the page content and leaves the launcher on document.body alone.
You need the API only when the widget should appear on some routes and not others. PepoChat's loader exposes four methods on window.EchoWidget: show() opens the chat window, hide() closes it, destroy() removes the launcher and window but keeps the API, and init({ organizationId, position, preload }) re-creates the widget, keeping current values for omitted keys, as the widget behaviour and API docs describe.
To limit the widget to one section, render the React component from that section's layout. In the Next.js App Router, a route group is a folder whose name is in parentheses, such as app/(marketing); it gives the routes inside a shared layout without changing their URLs:
// app/(marketing)/layout.tsx: the widget shows on these routes only
import { PepoChatWidget } from "@/components/pepochat-widget";
export default function MarketingLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
{children}
<PepoChatWidget />
</>
);
}
Leaving the group unmounts the component, whose cleanup calls destroy(); coming back calls init(). Moving next/script into a nested layout is not the same: it loads the script once and never undoes what the script drew, so the launcher stays after the visitor leaves.
Each init() re-creates the chat frame, so a component that remounts on every page change closes an open chat and, with preloading on, loads the frame again (about 523 KB on a cold cache, measured 19 October 2026). Mount it at the highest layout that covers the routes, and set data-preload="false" or pass preload: false to init() if the widget is re-created often.
Inside a product, a help menu can open the chat on the screen where the customer is stuck, as the SaaS support setup guide recommends for account and billing questions:
<button type="button" onClick={() => window.EchoWidget?.show()}>
Contact support
</button>
There is no API to open the chat with a question already filled in.
What Content Security Policy does a chat widget need?
PepoChat's chat widget needs one origin, https://widget.pepochat.com, in two Content Security Policy directives: script-src for the loader and frame-src for the chat window; chat traffic runs inside the frame, so connect-src needs nothing.
A Content Security Policy (CSP) is a response header that tells the browser which origins may supply scripts, frames and other resources. The two lines to add:
script-src 'self' https://widget.pepochat.com;
frame-src https://widget.pepochat.com;
The directive most often missed is frame-src. As MDN's frame-src reference explains, without it the browser falls back to child-src, then default-src, so a policy with default-src 'self' and no frame-src blocks the chat window while the launcher still draws. The sample policies in the Next.js CSP guide have exactly that shape, and the browser console names the directive that refused the frame.
Nonce-based policies need one more step. MDN notes that with 'strict-dynamic', a nonce-carrying script passes its trust to the scripts it loads, while allowlists such as 'self' are ignored. Next.js adds the nonce to its own scripts and to any <Script> given a nonce prop, which a Server Component reads from the request headers. Nonces force dynamic rendering of every page, so weigh that cost first:
// app/layout.tsx with a nonce-based CSP set in proxy.ts
import { headers } from "next/headers";
import Script from "next/script";
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const nonce = (await headers()).get("x-nonce") ?? undefined;
return (
<html lang="en">
<body>
{children}
<Script
src="https://widget.pepochat.com/widget.js"
strategy="lazyOnload"
data-organization-id="YOUR_ORGANIZATION_ID"
nonce={nonce}
/>
</body>
</html>
);
}
How do you set allowed domains for localhost and preview deploys?
Add every host where the chat widget must start conversations to PepoChat's allowed-domains list: an entry matches its own hostname and every subdomain, ports are ignored, and the list holds up to 20 entries.
The allowed-domains list is a Widget Customization setting that makes the backend refuse to start a chat from a page whose host is not on it; the visitor sees "This website is not allowed to use this chat widget." An empty list allows every site. The check runs when the chat window opens and a session is created, not when the launcher loads, so the refusal shows inside the window.
| Where the page runs | Example host | Entry to add | Why |
|---|---|---|---|
| Local dev server | localhost:3000 | localhost | Ports are ignored |
| Loopback address | 127.0.0.1:5173 | 127.0.0.1 | A different host from localhost |
| Production and subdomains | www.example.com, docs.example.com | example.com | Subdomains are included |
| Cloudflare Pages previews | 373f31e2.my-site.pages.dev | my-site.pages.dev | Every preview is a subdomain of the project |
| Vercel branch preview | my-app-git-main-acme.vercel.app | The full hostname | Previews are siblings, not subdomains |
| Netlify deploy preview | deploy-preview-42--my-site.netlify.app | The full hostname | One entry per pull request |
Matching rules from PepoChat's backend code and allowed-domains docs; preview address formats from the Vercel, Cloudflare and Netlify documentation, all checked 22 October 2026.
Two entries look helpful and are not. A wildcard such as *.example.com is saved literally and matches nothing; write example.com, which already covers every subdomain. Never add a shared hosting domain such as vercel.app, netlify.app or pages.dev: because subdomains match, that one entry would let every site on that host start chats on your account and spend your AI replies.
On Vercel, the branch address, <project>-git-<branch>-<scope>.vercel.app, always points to the branch's latest deployment, so one entry covers a long-lived staging branch, and the Preview Deployment Suffix (Pro and Enterprise plans) moves previews under a domain you already allow. The browser reports the page's origin, so treat the list as a strong deterrent against copied snippets, not a hard security boundary.
How do you test a chat widget before you deploy?
Test a framework install in six steps, from the dashboard to a preview deploy, because a stripped attribute, a remount, a missing CSP entry and an unlisted host each show up at a different stage.
- Check the answers first in PepoChat's Test the agent drawer, which shows your real widget, is never blocked by allowed domains and spends no AI replies. The 40-question chatbot test script supplies the questions.
- Run your dev server with
localhoston the allowed-domains list. The launcher should appear a second or two after the page finishes loading. Chats from your own site are real, localhost included: they land in the team inbox and every AI answer counts toward your monthly replies (200 on the Free plan). - Read the console. If a React component loads the widget, one "already initialized" warning in development is normal under Strict Mode. "data-organization-id attribute is required" means the attribute never reached the tag.
- Click through five routes with the chat open, then closed. One launcher should stay put, and an open chat should stay open everywhere except under Astro's client router.
- Build for production and preview it locally, where Strict Mode's double run no longer happens, and read the console again.
- Deploy a preview, add its host as described above, run steps 2 to 4 there, and remove the entry after the merge.
Why is the chat widget not showing in my framework app?
When a chat widget fails in a framework app, the cause is usually placement or configuration: an attribute that never reached the tag, an unmounted component, a body swap, a CSP without frame-src, or a host missing from the allowed domains.
| Symptom | Likely cause | Fix |
|---|---|---|
| Console: "data-organization-id attribute is required" | The attribute never reached the script tag | Use the snippet as given; next/script and Nuxt's useHead pass data-* attributes through |
| Two script tags and one "already initialized" warning, in development only | Strict Mode's extra setup and cleanup cycle | Nothing to fix; production loads one tag |
ReferenceError: window is not defined during a build or server render | DOM code running on the server | Move it into useEffect, onMounted or a .client plugin |
| Launcher vanishes after the first link click in Astro | <ClientRouter /> replaced the body | Call EchoWidget.init() on astro:after-swap |
| Launcher vanishes on some React or Next.js routes | The component that loaded it unmounted, and its cleanup ran destroy() | Render it from the root layout |
| Launcher stays on routes that should not show it | next/script in a nested layout, which does not remove what the script drew | Use the component with a cleanup in that section's layout |
| Chat window stays blank, or shows "The chat couldn't load" after 15 seconds | CSP blocks the frame, or the network does | Add frame-src https://widget.pepochat.com and test in a clean browser profile |
| "This website is not allowed to use this chat widget" on localhost or a preview | The host is not on the allowed-domains list | Add localhost, 127.0.0.1 or the preview host |
Property 'EchoWidget' does not exist on type 'Window' | TypeScript has no declaration for the global | Add the declaration from the React section |
Checked against PepoChat's loader source and each framework's documentation on 22 October 2026.
What to do next
- Copy your snippet, Organization ID included, from Widget Setup; the widget install docs list every variant and optional attribute.
- Before launch, add your production domain,
localhostand any preview hosts to the allowed-domains list. - Decide where the widget lives (marketing site, docs, app) with the SaaS guide above, and wire your help menu to
EchoWidget.show(). - No workspace yet? Create a free PepoChat workspace: every feature is on the free plan, with 200 AI replies a month and no credit card.
- Related guides: chat widget not showing and installing a chat widget with Google Tag Manager.
Frequently asked questions
- Where do I put a chat widget script in Next.js?
- Put it in the root layout, app/layout.tsx, with the next/script component, so it loads on every route and only once as visitors move between pages. Next.js forwards data attributes such as data-organization-id to the final script tag. As of October 2026 and Next.js 16, lazyOnload and afterInteractive both suit a chat widget, while beforeInteractive is meant for critical scripts.
- Why does my chat widget load twice in React?
- Usually because React Strict Mode runs one extra setup and cleanup cycle for every effect in development, so an effect that appends a script tag appends two. A loader with a duplicate check ignores the second copy; PepoChat logs a warning that it is already initialized and does nothing. Production builds run the effect once. Fix it with a cleanup, not with a ref that hides the double run.
- How do I add a chat widget to a Nuxt app?
- Use useHead in app/app.vue with the script src, its data attributes and tagPosition set to bodyClose, or a plugin in app/plugins whose file name ends in .client, which Nuxt runs only in the browser. Both load the widget once for the whole app. As of Nuxt 4, app.head in nuxt.config.ts also works for fixed values but cannot take reactive data.
- Why does my chat widget disappear after navigating in Astro?
- Because the Astro ClientRouter replaces the whole body on each client-side navigation, which removes a launcher that a script appended to it. Inline scripts may not run again, and a loader that does may skip itself as a duplicate. Re-create the widget in an astro:after-swap listener registered from a processed script, which Astro runs once. Without ClientRouter, every navigation is a full page load.
- What Content Security Policy does a chat widget need?
- For PepoChat, allow https://widget.pepochat.com in script-src for the loader and in frame-src for the chat window; connect-src needs nothing because chat traffic runs inside the frame. Other widgets list their own origins. A policy with default-src 'self' and no frame-src blocks the chat window, because the browser falls back from frame-src to child-src and then to default-src.
- How do I test a chat widget on localhost?
- Add localhost to the allowed domains; in PepoChat, entries ignore ports and include subdomains, and 127.0.0.1 needs its own entry. Then run the dev server, check for exactly one launcher, click through several routes and send a test message. Chats from localhost are real and count toward monthly AI replies, while the dashboard Test the agent drawer is free.
Try this on your own site in ten minutes
PepoChat includes every feature on the free plan — 200 AI replies and 5 knowledge sources a month, no credit card.
Keep reading
Chatbase vs PepoChat: Which AI Support Agent Fits Your Team in 2026?
An honest side-by-side of Chatbase and PepoChat on pricing, free plan or trial, sources, handoff, booking, actions, channels and voice, and a pick by team type.
Intercom Fin Pricing Explained, and What a Flat-Plan Alternative Costs
What Intercom Fin's $0.99 per outcome adds up to once you count resolutions and seats, with a worked 2,000-conversation example and when a flat plan wins.
