Install the Chatlio chat widget in Next.js
Chatlio puts a live chat widget on your Next.js site. Visitors chat in the widget, and your team answers from Slack, Microsoft Teams or Discord.
The install has two parts:
- The loader script,
https://js.chatlio.com/widget.js. Load it once per page withnext/script. - The
<chatlio-widget>element. It decides where the widget shows. When the element is in the page, the widget shows. When React removes it, the widget goes away. You don’t need to reload the script when routes change.
On this page:
- Get your embed code
- App Router
- Pages Router
- TypeScript types
- Route changes
- Identify signed-in users
- Hide the widget on some routes
- Sidebar and inline styles
- Troubleshooting
Get your embed code
- Open the Chatlio dashboard.
- Choose the widget that you want to add to your site.
- Select the Widget Install tab, choose your widget style, select NextJS, and copy your embed code.
The examples on this page use YOUR-WIDGET-ID. Replace it with the widgetid from your embed code. If your embed code has more attributes on <chatlio-widget>, such as widgetsrc or widgettype, copy them too, exactly as shown.
The dashboard adds disable-favicon-badge to the Next.js embed code. It turns off the unread-message count that Chatlio otherwise draws on your site’s favicon.
App Router
Put the widget in a small client component and render it from your root layout. The root layout stays mounted during client-side navigation, so the widget and the visitor’s chat stay open as visitors move between pages.
Create app/chatlio-widget.tsx:
'use client';
import Script from 'next/script';
import { usePathname } from 'next/navigation';
import { useEffect, useRef } from 'react';
export function ChatlioWidget() {
const pathname = usePathname();
const lastPathname = useRef(pathname);
// Tell Chatlio about client-side navigation (see "Route changes").
useEffect(() => {
if (lastPathname.current === pathname) return;
lastPathname.current = pathname;
window._chatlio?.page?.();
}, [pathname]);
return (
<>
<Script src="https://js.chatlio.com/widget.js" strategy="lazyOnload" />
<chatlio-widget widgetid="YOUR-WIDGET-ID" disable-favicon-badge />
</>
);
}
Then render it at the end of <body> in app/layout.tsx:
import type { ReactNode } from 'react';
import { ChatlioWidget } from './chatlio-widget';
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
{children}
<ChatlioWidget />
</body>
</html>
);
}
strategy="lazyOnload" loads the widget when the browser is idle, after your page is interactive. That is what the dashboard’s embed code uses. If you want the widget to appear sooner, use strategy="afterInteractive". Both load the script after React hydrates, so the widget never changes the server-rendered HTML that React checks.
If you only want Chatlio on one page, render <ChatlioWidget /> in that page instead of the root layout. The widget then loads when a visitor opens that page and goes away when they leave it.
Pages Router
Option 1: pages/_app.tsx (recommended)
_app.tsx wraps every page and can use hooks, so you can report route changes and hide the widget on some pages.
import type { AppProps } from 'next/app';
import Script from 'next/script';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
export default function App({ Component, pageProps }: AppProps) {
const router = useRouter();
// Tell Chatlio about client-side navigation (see "Route changes").
useEffect(() => {
const onRouteChange = () => window._chatlio?.page?.();
router.events.on('routeChangeComplete', onRouteChange);
return () => router.events.off('routeChangeComplete', onRouteChange);
}, [router.events]);
return (
<>
<Component {...pageProps} />
<Script src="https://js.chatlio.com/widget.js" strategy="lazyOnload" />
<chatlio-widget widgetid="YOUR-WIDGET-ID" disable-favicon-badge />
</>
);
}
Option 2: pages/_document.tsx
If you want the widget on every page with no client code, put both parts in _document.tsx. Next.js renders _document on the server only, so React never removes the element.
import { Html, Head, Main, NextScript } from 'next/document';
export default function Document() {
return (
<Html lang="en">
<Head>
<script src="https://js.chatlio.com/widget.js" async></script>
</Head>
<body>
<Main />
<NextScript />
<chatlio-widget widgetid="YOUR-WIDGET-ID" disable-favicon-badge />
</body>
</Html>
);
}
Use a plain <script async> tag here, not next/script, and keep the async attribute. With this option you can’t hide the widget per route from React. To report route changes, add the routeChangeComplete effect from option 1 to _app.tsx.
Use one option, not both. A second copy of the loader on the page causes an error. See Troubleshooting.
TypeScript types
TypeScript doesn’t know the <chatlio-widget> element or window._chatlio, so the examples above fail type checking until you declare them. Add a declaration file, for example chatlio.d.ts at the root of your project, and make sure your tsconfig.json include covers it (the default Next.js "**/*.ts" does).
For React 19 type definitions (Next.js 15 and later):
// chatlio.d.ts
import type { DetailedHTMLProps, HTMLAttributes } from 'react';
type ChatlioWidgetProps = DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement> & {
widgetid: string;
widgettype?: 'sidebar' | 'inline';
widgetsrc?: string;
'disable-favicon-badge'?: boolean;
};
declare module 'react' {
namespace JSX {
interface IntrinsicElements {
'chatlio-widget': ChatlioWidgetProps;
}
}
}
declare global {
interface ChatlioAPI {
identify(userId: string, traits?: Record<string, unknown>): void;
show(options?: { expanded?: boolean }): ChatlioAPI;
hide(): ChatlioAPI;
open(): void;
showOrHide(): void;
isShown(): boolean;
isOnline(): boolean;
page(): void;
configure(options: Record<string, unknown>): void;
send(message: string): void;
reset(): void;
}
interface Window {
// Before the widget loads, _chatlio is a queue with only some of these methods.
_chatlio?: ChatlioAPI;
}
}
With React 18 type definitions (Next.js 13 and 14), replace the declare module 'react' block with a declaration on the global JSX namespace:
declare global {
namespace JSX {
interface IntrinsicElements {
'chatlio-widget': ChatlioWidgetProps;
}
}
}
ChatlioAPI is global, so other files can use it without an import. It lists the methods used on this page. The widget API reference has the rest. Add them to the interface as you use them.
The loader checks whether attributes such as disable-favicon-badge and data-start-hidden are present, not their values. To turn one off, leave it out. Writing data-start-hidden={false} still starts the widget hidden, because React renders it as data-start-hidden="false".
Route changes
Chatlio shows your team which page a visitor is on and uses the URL for triggers (automatic messages). The widget notices browser back and forward (the popstate event), but Next.js’s <Link> and router.push() navigation doesn’t fire that event. Call _chatlio.page() after each client-side navigation so the page and triggers stay current.
The App Router and _app.tsx examples above already do this. The optional chaining (?.) skips the call if the loader hasn’t loaded yet.
Identify signed-in users
Use _chatlio.identify() to send a signed-in user’s ID, name, email and any other details to Chatlio. Your team sees them with the visitor’s conversation, and in Slack Chatlio uses the name or email to name the visitor’s channel.
window._chatlio doesn’t exist until the loader runs, and the full API isn’t available until the widget sends the chatlio.ready event. The chatlio.ready event may also fire before your component mounts. This helper handles both cases. Put it in lib/chatlio.ts:
// Runs callback now if the widget is ready, otherwise on chatlio.ready.
// Returns a cleanup function for useEffect.
export function whenChatlioReady(callback: (chatlio: ChatlioAPI) => void): () => void {
const chatlio = window._chatlio;
if (chatlio && !Array.isArray(chatlio)) {
callback(chatlio);
return () => {};
}
const onReady = () => callback(window._chatlio as ChatlioAPI);
document.addEventListener('chatlio.ready', onReady, { once: true });
return () => document.removeEventListener('chatlio.ready', onReady);
}
Then call it from a client component that knows the user:
'use client';
import { useEffect } from 'react';
import { whenChatlioReady } from '@/lib/chatlio';
type User = { id: string; name: string; email: string; plan: string };
export function ChatlioIdentify({ user }: { user: User | null }) {
useEffect(() => {
if (!user) return;
return whenChatlioReady((chatlio) => {
chatlio.identify(user.id, {
name: user.name,
email: user.email,
plan: user.plan,
});
});
}, [user]);
return null;
}
Render <ChatlioIdentify user={user} /> anywhere the signed-in user is available, for example in a layout that already loads the session. The ChatlioAPI type comes from the declaration file. The @/lib import assumes the default @/* path alias. Use a relative import if your project doesn’t have it.
When the user signs out, call window._chatlio?.reset() so the next person on that browser starts as a new visitor, without the previous user’s conversation.
Hide the widget on some routes
To keep the widget off some pages, don’t render <chatlio-widget> on them. Keep the <Script> where it is. In the App Router component above:
const HIDDEN_PREFIXES = ['/checkout', '/admin'];
// Inside ChatlioWidget:
const hidden = HIDDEN_PREFIXES.some((prefix) => pathname.startsWith(prefix));
return (
<>
<Script src="https://js.chatlio.com/widget.js" strategy="lazyOnload" />
{!hidden && <chatlio-widget widgetid="YOUR-WIDGET-ID" disable-favicon-badge />}
</>
);
In _app.tsx, use router.pathname in the same way.
When a visitor reaches a hidden route, the widget unmounts. When they come back, it mounts again and shows the visitor’s earlier messages.
The dashboard’s page filters also hide the widget, but the widget checks them against the URL when it loads. After client-side navigation, a filter may not apply until the next full page load. For Next.js routes, conditional rendering is more reliable.
To keep the widget mounted but out of sight for a moment, for example while a modal is open, call _chatlio.hide() and later _chatlio.show().
Sidebar and inline styles
If you chose the Sidebar or Embed inline style in the dashboard, your embed code includes widgettype="sidebar" or widgettype="inline". Keep that attribute on the element.
The inline style also needs a container on the same page, where Chatlio renders the chat. In JSX:
<div id="chatlioWidgetPlaceholder" style={{ margin: 'auto', height: 400, width: 400 }} />
Change the height and width to fit your layout.
Troubleshooting
Hydration warnings
- Load the loader with
next/script(lazyOnloadorafterInteractive) in the App Router. A plain<script>inapp/layout.tsxcan run before React hydrates. The nodes the widget adds to<body>can then cause hydration warnings. - Render the element the same way on the server and in the browser. Don’t wrap
<chatlio-widget>in a check such astypeof window !== 'undefined'. The server and the browser then render different HTML, and React reports a mismatch. To show the widget only on some routes, decide withusePathname()orrouter.pathname, which give the same answer on both.
Content Security Policy
If your site sends a Content-Security-Policy header, add Chatlio’s hosts to it. In next.config.mjs:
const isDev = process.env.NODE_ENV === 'development';
const contentSecurityPolicy = [
"default-src 'self' https://*.chatlio.com",
`script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ''} https://*.chatlio.com`,
"connect-src 'self' https://*.chatlio.com wss://*.chatlio.com",
"style-src 'self' 'unsafe-inline' https://*.chatlio.com",
'img-src * data:',
].join('; ');
const nextConfig = {
async headers() {
return [
{
source: '/(.*)',
headers: [{ key: 'Content-Security-Policy', value: contentSecurityPolicy }],
},
];
},
};
export default nextConfig;
Chatlio needs the https://*.chatlio.com entries, wss://*.chatlio.com for live replies, 'unsafe-inline' in style-src, and data: in img-src. 'unsafe-inline' and 'unsafe-eval' in script-src are for Next.js’s own scripts, not Chatlio. If you use nonces for your scripts, keep your nonce setup and only add the Chatlio hosts. The CSP guide explains each rule.
The widget appears twice, or the console shows a CustomElementRegistry error
The loader is on the page twice, or there are two <chatlio-widget> elements. Check that:
- Only one place loads
https://js.chatlio.com/widget.js. A<Script>in a layout and a<script>tag in_document.tsxcount as two. The second copy fails with “the name “chatlio-widget” has already been used with this registry”. - Only one
<chatlio-widget>is rendered. If it’s in your root layout or_app.tsx, remove it from page components. - You don’t also load the widget bundle (a
w.chatlio.comURL) with its own<script>tag. The loader loads it for you.
In the browser console, document.querySelectorAll('chatlio-widget').length should be 1.
The widget doesn’t show
- Check that the
widgetidmatches the widget in your dashboard, and that the widget is active. - If your embed code has
data-start-hidden, the widget stays hidden until you call_chatlio.show(). - Check the dashboard’s page filters and your own route conditions.
- Check the browser console for blocked requests to
chatlio.com. They usually mean a Content Security Policy is missing a Chatlio host. - Call
_chatlio.debug(true)in the console to turn on debug logging.
Calls to window._chatlio fail or do nothing
From the moment the loader runs until the widget finishes loading, window._chatlio only queues a few methods: configure(), identify(), show(), hide(), open(), showOrHide(), isShown(), isOnline() and page(). Any other method, such as send() or reset(), is undefined until then, and isShown() and isOnline() don’t return a real answer yet. Run your code through whenChatlioReady(), or inside a chatlio.ready listener.
For everything else the widget can do, see the widget API reference. For React apps without Next.js, see Install Chatlio on React.