a question answered, a sale keptthe chat needs no cookie bannerunlimited teammatesreply from slack or our phone app

/blog / guides

add live chat to a next.js site

· by kuba · 6 min read

A plug labelled next.js about to go into a pink socket, the Spookat cat riding the cable.

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

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 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 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.

liked it? get on the list.

invites go out in small batches. when yours lands: 14 days free, no card.

we only email you about your invite and the launch. you can stop them anytime. privacy