add live chat to a next.js site

Live chat in Next.js is one script tag in the root layout. This guide shows where it goes in the app router and the pages router, how to pass the widget’s look and the logged-in user without breaking the load order, what to add to a Content Security Policy, and how to check the result in a production build.
the short version
In the app router, add the tag to app/layout.tsx, inside <body>:
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<script src="https://cdn.spookat.com/s/YOUR_SITE_KEY.js" async></script>
</body>
</html>
);
}
Replace YOUR_SITE_KEY with the key from the dashboard’s install page. That’s the whole install when the look lives in the dashboard, which is the default. The rest of this guide is for when you want more control. If you’re starting from zero, the pillar guide covers the account side.
why the root layout
The root layout wraps every route and stays mounted while the user navigates. The launcher attaches itself to the page once. Client-side navigation doesn’t reload the page, so it doesn’t reload the launcher either, and a visitor can keep a conversation open while they click around.
Put the tag in a page component and the chat only exists on that page. Put it in a component that renders on several pages and you get duplicates on hard reloads (the launcher ignores its own second copy, but it’s still a wasted request).
with next/script
next/script is the Next.js way to load third-party scripts, and it works fine here:
import Script from "next/script";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<Script src="https://cdn.spookat.com/s/YOUR_SITE_KEY.js" strategy="afterInteractive" />
</body>
</html>
);
}
afterInteractive loads the launcher right after hydration. lazyOnload waits until the browser is idle, which pushes the button’s appearance a little later on slow phones. Either is fine for a 2 kb launcher; afterInteractive shows the button sooner. Don’t use beforeInteractive for the launcher: it would compete with your own code for nothing.
setting the look in code
The look you publish in the dashboard travels inside the launcher. If you’d rather keep it in the repo, set window.Spookat before the launcher runs. The page’s config wins over the dashboard’s.
The load order matters: the config has to exist when the launcher executes. With next/script, give the config beforeInteractive (allowed in the root layout) and the launcher afterInteractive:
import Script from "next/script";
const spookat = { style: "clean", font: "system", accent: "#2F6FEB", side: "right" };
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<Script id="spookat-config" strategy="beforeInteractive">
{`window.Spookat = ${JSON.stringify(spookat)};`}
</Script>
<Script src="https://cdn.spookat.com/s/YOUR_SITE_KEY.js" strategy="afterInteractive" />
</body>
</html>
);
}
beforeInteractive scripts run before hydration, afterInteractive ones after it, so the order is guaranteed. The keys are the same as anywhere else: style (one of 8), font, accent, side, greeting, label, lang. The docs list them all.
the pages router
In the pages router, the shared wrapper is pages/_app.tsx:
import type { AppProps } from "next/app";
import Script from "next/script";
export default function App({ Component, pageProps }: AppProps) {
return (
<>
<Component {...pageProps} />
<Script src="https://cdn.spookat.com/s/YOUR_SITE_KEY.js" strategy="afterInteractive" />
</>
);
}
For a config, beforeInteractive scripts belong in pages/_document.tsx in the pages router. Put the window.Spookat script there, inside <Head>, with the same id and strategy as above, and keep the launcher in _app.tsx.
the logged-in user
On pro and above, you can tell the chat who is signed in. Your team then sees logged in as 42 on the Slack card, and webhooks, the export and your agent’s read_convo carry the id. The id is signed with a secret that only your server knows, so nobody can pretend to be someone else.
Make the identity secret on the dashboard’s install page, put it in an environment variable, and sign on the server. In the app router, the root layout is a server component, which makes this simple:
import { createHmac } from "node:crypto";
import Script from "next/script";
import { getSession } from "@/lib/auth"; // yours
const sign = (id: string) => createHmac("sha256", process.env.SPOOKAT_IDENTITY_SECRET!).update(id).digest("hex");
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const session = await getSession();
const user = session ? { id: String(session.userId), sig: sign(String(session.userId)) } : undefined;
return (
<html lang="en">
<body>
{children}
{user && (
<Script id="spookat-config" strategy="beforeInteractive">
{`window.Spookat = ${JSON.stringify({ user })};`}
</Script>
)}
<Script src="https://cdn.spookat.com/s/YOUR_SITE_KEY.js" strategy="afterInteractive" />
</body>
</html>
);
}
Never compute the signature in a client component: the secret would ship to the browser. A missing or wrong signature isn’t an error; the chat simply doesn’t say who it is. Reading the session in the root layout makes every page render per request, so if your layout is static today, weigh that before you add it.
content security policy
If your app sends a Content-Security-Policy header, the widget needs:
script-srcandstyle-src:https://cdn.spookat.comfont-src:https://cdn.spookat.com, if you picked a hosted fontconnect-src:wss://api.spookat.com, for the chat connection after a clickimg-src:data:, for logos and teammate picturesstyle-src: the launcher injects one small<style>block into its own shadow root, so inline styles need to be allowed for it
Without these, the browser console says exactly which directive blocked what. Add the source it names and reload.
check it in a production build
Dev mode adds its own scripts and hot reload, so check a real build:
next build && next start
Open http://localhost:3000 in a private window with DevTools open. The domain matters: the chat connects only from the domain you registered and its subdomains, so on localhost you’ll see the launcher, but the chat won’t connect. Test the chat itself on your deployed site.
On either one:
- Network, “Disable cache”, reload, filter
spookat: one request, the launcher fromcdn.spookat.com. Two, both fromcdn.spookat.com, if your code sets a style inwindow.Spookatthat isn’t the one you published. Nothing toapi.spookat.com. - Application: no cookies from Spookat, and no storage until a first message.
- Performance or Lighthouse: the launcher shouldn’t show up as a long task. Compare a few runs with and without it; the difference should sit inside the normal noise between runs.
- Click the launcher on the deployed site: the panel loads, the connection opens, your message reaches Slack.
deploy targets
Nothing here depends on where the app runs. Vercel, a Node server, a container or output: "export" for a static site all serve the same HTML, and the tag works the same way in each. For a static export, the logged-in user has to come from a server you control, since there is no server render to sign it in.
connect the replies
Once the chat is on the site, connect Slack in the dashboard and pick a channel. The Slack guide walks through it. If you use Claude Code, the Claude Code guide shows how to let the agent do all of the above.
multilingual apps
If your app has a [locale] segment, tell the widget which language the page is in, so the panel matches the page instead of the visitor’s browser. The widget speaks 12 languages; lang takes codes like en, pl, de. In app/[locale]/layout.tsx:
<Script id="spookat-config" strategy="beforeInteractive">
{`window.Spookat = ${JSON.stringify({ lang: locale })};`}
</Script>
A language the widget doesn’t have falls back to the visitor’s browser, then English. Your team sees the language on the chat either way.
common mistakes
- The tag inside a client component with
"use client". It renders, but it’s hydrated with the component, and every remount is another chance for a duplicate. Keep it in the layout. - Creating the script in
useEffect. More code for the same result, and it loads later thanafterInteractive. type="module"on the tag. The launcher is a classic script; leave the type off.- Preloading the panel. A
<link rel="preload">for the panel script moves its download before the click for every visitor. The whole point is that it waits. - Testing the chat on a preview deployment. Preview URLs are other domains. The launcher shows, the chat doesn’t connect. That’s expected.
what it adds to your bundle
Nothing. The launcher comes from our CDN as its own file, so it isn’t part of your JavaScript bundle, and the “First Load JS” numbers that next build prints stay exactly the same. It doesn’t touch React either: the button lives in its own shadow root, so your CSS can’t break it and its CSS can’t leak into your app. The only thing your build needs to know is the one tag.
checklist
- One tag or
<Script>in the root layout (app/layout.tsxorpages/_app.tsx). - Config, if any, runs before the launcher.
- User signatures computed on the server only.
- CSP allows the CDN and the chat connection, if you have a CSP.
- Checked in a production build: one request before a click (two, both from
cdn.spookat.com, if your code sets a style you didn’t publish), no cookies.
It costs $4.99 a month per site on starter, and the trial is 14 days with no card.