# add live chat to a next.js site

Add Spookat's chat to a Next.js app, in the app router or the pages router, with its look and an optional logged-in user, and verify it makes 0 requests to our API before a click.

2026-11-04 · Kuba

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>`:

```tsx
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](/blog/add-live-chat-to-your-website/) 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:

```tsx
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`:

```tsx
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](/docs/) list them all.

## the pages router

In the pages router, the shared wrapper is `pages/_app.tsx`:

```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:

```tsx
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-src` and `style-src`: `https://cdn.spookat.com`
- `font-src`: `https://cdn.spookat.com`, if you picked a hosted font
- `connect-src`: `wss://api.spookat.com`, for the chat connection after a click
- `img-src`: `data:`, for logos and teammate pictures
- `style-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:

```sh
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:

1. **Network**, "Disable cache", reload, filter `spookat`: one request, the launcher from `cdn.spookat.com`. Two, both from `cdn.spookat.com`, if your code sets a style in `window.Spookat` that isn't the one you published. Nothing to `api.spookat.com`.
2. **Application**: no cookies from Spookat, and no storage until a first message.
3. **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.
4. **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](/blog/answer-website-chat-from-slack/) walks through it. If you use Claude Code, the [Claude Code guide](/blog/claude-code-live-chat/) 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`:

```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 than `afterInteractive`.
- **`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.tsx` or `pages/_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.

---
source: https://spookat.com/blog/live-chat-nextjs/
