# live chat op een next.js-site

Voeg de chat van Spookat toe aan een Next.js-app, met de app router of de pages router, met de look en eventueel de ingelogde gebruiker, en controleer dat er voor een klik geen verzoek naar onze API gaat.

2026-11-04 · Kuba

Live chat in Next.js is één script-tag in de root-layout. Deze gids laat zien waar die komt in de app router en de pages router, hoe je de look van de widget en de ingelogde gebruiker doorgeeft zonder de laadvolgorde te breken, wat er in een Content Security Policy moet en hoe je het resultaat controleert in een productiebuild.

## de korte versie

In de app router voeg je de tag toe aan `app/layout.tsx`, binnen `<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>
  );
}
```

Vervang `YOUR_SITE_KEY` door de key van de installatiepagina in het dashboard. Dat is de hele installatie als de look in het dashboard staat, en dat is de standaard. De rest van deze gids is voor als je meer controle wilt. Begin je vanaf nul, dan behandelt de [basisgids](/nl/blog/add-live-chat-to-your-website/) de accountkant.

## waarom de root-layout

De root-layout omsluit elke route en blijft gemount terwijl de gebruiker navigeert. De launcher hecht zich één keer aan de pagina. Navigatie aan de clientkant herlaadt de pagina niet, dus ook de launcher niet, en een bezoeker kan een gesprek openhouden terwijl die rondklikt.

Zet je de tag in een paginacomponent, dan bestaat de chat alleen op die pagina. Zet je hem in een component dat op meerdere pagina's rendert, dan krijg je bij harde reloads dubbele exemplaren (de launcher negeert zijn eigen tweede kopie, maar het blijft een verspild request).

## met next/script

`next/script` is de Next.js-manier om scripts van derden te laden, en het werkt hier prima:

```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` laadt de launcher direct na de hydration. `lazyOnload` wacht tot de browser idle is, waardoor de knop op trage telefoons iets later verschijnt. Beide zijn prima voor een launcher van 2 kb; `afterInteractive` toont de knop eerder. Gebruik geen `beforeInteractive` voor de launcher: die zou voor niets met je eigen code concurreren.

## de look in code instellen

De look die je in het dashboard publiceert, zit in de launcher. Hou je hem liever in de repo, zet dan `window.Spookat` voordat de launcher draait. De config van de pagina wint van die van het dashboard.

De laadvolgorde telt: de config moet bestaan als de launcher wordt uitgevoerd. Met `next/script` geef je de config `beforeInteractive` (toegestaan in de root-layout) en de 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 draaien voor de hydration, `afterInteractive`-scripts erna, dus de volgorde is gegarandeerd. De keys zijn dezelfde als overal: `style` (een van 8), `font`, `accent`, `side`, `greeting`, `label`, `lang`. De [docs](/nl/docs/) noemen ze allemaal.

## de pages router

In de pages router is de gedeelde wrapper `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" />
    </>
  );
}
```

Voor een config horen `beforeInteractive`-scripts in de pages router in `pages/_document.tsx`. Zet het `window.Spookat`-script daar, binnen `<Head>`, met dezelfde `id` en `strategy` als hierboven, en laat de launcher in `_app.tsx`.

## de ingelogde gebruiker

Vanaf pro kun je de chat vertellen wie er ingelogd is. Je team ziet dan `logged in as 42` op de Slack-kaart, en webhooks, de export en `read_convo` van je agent dragen de id mee. De id wordt ondertekend met een secret dat alleen jouw server kent, zodat niemand zich als iemand anders kan voordoen.

Maak het identity-secret op de installatiepagina van het dashboard, zet het in een omgevingsvariabele en onderteken op de server. In de app router is de root-layout een servercomponent, en dat maakt het eenvoudig:

```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>
  );
}
```

Bereken de handtekening nooit in een clientcomponent: dan zou het secret naar de browser gaan. Een ontbrekende of verkeerde handtekening is geen fout; de chat zegt dan gewoon niet wie het is. Lees je de sessie in de root-layout, dan rendert elke pagina per request. Is je layout nu statisch, weeg dat dan af voordat je het toevoegt.

## content security policy

Stuurt je app een `Content-Security-Policy`-header, dan heeft de widget nodig:

- `script-src` en `style-src`: `https://cdn.spookat.com`
- `font-src`: `https://cdn.spookat.com`, als je een gehost font koos
- `connect-src`: `wss://api.spookat.com`, voor de chatverbinding na een klik
- `img-src`: `data:`, voor logo's en foto's van je teamgenoten
- `style-src`: de launcher voegt een klein `<style>`-blok toe aan zijn eigen shadow root, dus inline styles moeten voor hem zijn toegestaan

Zonder deze regels vertelt de browserconsole precies welke directive wat blokkeerde. Voeg de genoemde bron toe en herlaad.

## controleer het in een productiebuild

Dev-modus voegt eigen scripts en hot reload toe, dus controleer een echte build:

```sh
next build && next start
```

Open `http://localhost:3000` in een privévenster met DevTools open. Het domein telt: de chat maakt alleen verbinding vanaf het domein dat je registreerde en de subdomeinen ervan, dus op localhost zie je de launcher, maar maakt de chat geen verbinding. Test de chat zelf op je gedeployde site.

Op allebei:

1. **Network**, "Disable cache", herladen, filteren op `spookat`: één request, de launcher van `cdn.spookat.com`. Twee, allebei van `cdn.spookat.com`, als je code in `window.Spookat` een andere stijl zet dan die je gepubliceerd hebt. Niets naar `api.spookat.com`.
2. **Application**: geen cookies van Spookat en geen opslag tot een eerste bericht.
3. **Performance of Lighthouse**: de launcher hoort niet als long task te verschijnen. Vergelijk een paar runs met en zonder; het verschil hoort binnen de normale ruis tussen runs te vallen.
4. **Klik op de launcher** op de gedeployde site: het paneel laadt, de verbinding opent, je bericht komt aan in Slack.

## deploydoelen

Niets hiervan hangt af van waar de app draait. Vercel, een Node-server, een container of `output: "export"` voor een statische site serveren allemaal dezelfde HTML, en de tag werkt overal hetzelfde. Bij een statische export moet de ingelogde gebruiker van een server komen die jij beheert, omdat er geen serverrender is om hem te ondertekenen.

## koppel de antwoorden

Staat de chat op de site, koppel dan Slack in het dashboard en kies een kanaal. De [Slack-gids](/nl/blog/answer-website-chat-from-slack/) loopt het met je door. Gebruik je Claude Code, dan laat de [Claude Code-gids](/nl/blog/claude-code-live-chat/) zien hoe de agent al het bovenstaande kan doen.

## meertalige apps

Heeft je app een `[locale]`-segment, vertel de widget dan in welke taal de pagina is, zodat het paneel bij de pagina past en niet bij de browser van de bezoeker. De widget spreekt 12 talen; `lang` neemt codes zoals `en`, `pl`, `de`. In `app/[locale]/layout.tsx`:

```tsx
<Script id="spookat-config" strategy="beforeInteractive">
  {`window.Spookat = ${JSON.stringify({ lang: locale })};`}
</Script>
```

Een taal die de widget niet heeft, valt terug op de browser van de bezoeker, daarna op Engels. Je team ziet de taal hoe dan ook bij de chat.

## veelgemaakte fouten

- **De tag in een clientcomponent met `"use client"`.** Hij rendert, maar wordt samen met het component gehydrateerd, en elke remount is weer een kans op een duplicaat. Hou hem in de layout.
- **Het script aanmaken in `useEffect`.** Meer code voor hetzelfde resultaat, en het laadt later dan `afterInteractive`.
- **`type="module"` op de tag.** De launcher is een klassiek script; laat het type weg.
- **Het paneel vooraf laden.** Een `<link rel="preload">` voor het paneelscript zet de download voor elke bezoeker voor de klik. Het hele punt is dat het wacht.
- **De chat testen op een preview-deployment.** Preview-URL's zijn andere domeinen. De launcher verschijnt, de chat maakt geen verbinding. Dat is zo bedoeld.

## wat het aan je bundle toevoegt

Niets. De launcher komt als eigen bestand van ons CDN, dus hij hoort niet bij je JavaScript-bundle, en de "First Load JS"-cijfers die `next build` toont blijven precies gelijk. Hij komt ook niet aan React: de knop leeft in zijn eigen shadow root, dus je CSS kan hem niet breken en zijn CSS kan niet in je app lekken. Het enige wat je build hoeft te weten, is die ene tag.

## checklist

- Eén tag of `<Script>` in de root-layout (`app/layout.tsx` of `pages/_app.tsx`).
- De config, als die er is, draait voor de launcher.
- Gebruikershandtekeningen worden alleen op de server berekend.
- De CSP staat het CDN en de chatverbinding toe, als je een CSP hebt.
- Gecontroleerd in een productiebuild: één request voor een klik (twee, allebei van `cdn.spookat.com`, als je code een stijl zet die je niet gepubliceerd hebt), geen cookies.

Het kost € 4,99 per maand per site op starter, en de proefperiode is 14 dagen zonder kaart.

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