# live-chat auf einer next.js-seite

Bau den Chat von Spookat in eine Next.js-App ein, im App Router oder Pages Router, mit Look und optional eingeloggtem Nutzer, und prüf, dass vor einem Klick keine Anfrage an unsere API geht.

2026-11-04 · Kuba

Live-Chat in Next.js ist ein Script-Tag im Root-Layout. Dieser Guide zeigt, wo er im App Router und im Pages Router hingehört, wie du den Look des Widgets und den eingeloggten Nutzer übergibst, ohne die Ladereihenfolge zu zerschießen, was in eine Content Security Policy gehört und wie du das Ergebnis in einem Produktions-Build prüfst.

## die kurzfassung

Im App Router kommt der Tag in `app/layout.tsx`, innerhalb von `<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>
  );
}
```

Ersetz `YOUR_SITE_KEY` durch den Key von der Install-Seite im Dashboard. Das ist die ganze Installation, wenn der Look im Dashboard liegt, und das ist der Standard. Der Rest dieses Guides ist für den Fall, dass du mehr Kontrolle willst. Wenn du bei null anfängst, erklärt der [Grundlagen-Guide](/de/blog/add-live-chat-to-your-website/) die Kontoseite.

## warum das root-layout

Das Root-Layout umschließt jede Route und bleibt gemountet, während der Nutzer navigiert. Der Launcher hängt sich einmal an die Seite. Client-seitige Navigation lädt die Seite nicht neu, also auch den Launcher nicht, und ein Besucher kann ein Gespräch offen halten, während er herumklickt.

Setzt du den Tag in eine Seitenkomponente, gibt es den Chat nur auf dieser Seite. Setzt du ihn in eine Komponente, die auf mehreren Seiten rendert, bekommst du bei harten Reloads Duplikate (der Launcher ignoriert seine eigene zweite Kopie, aber es ist trotzdem ein verschwendeter Request).

## mit next/script

`next/script` ist der Next.js-Weg, Scripts von Dritten zu laden, und funktioniert hier gut:

```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` lädt den Launcher direkt nach der Hydration. `lazyOnload` wartet, bis der Browser idle ist, was das Erscheinen des Buttons auf langsamen Handys etwas nach hinten schiebt. Beides ist für einen 2-kb-Launcher in Ordnung; `afterInteractive` zeigt den Button früher. Nimm für den Launcher nicht `beforeInteractive`: Er würde für nichts mit deinem eigenen Code konkurrieren.

## den look im code setzen

Der Look, den du im Dashboard veröffentlichst, steckt im Launcher. Wenn du ihn lieber im Repo hältst, setz `window.Spookat`, bevor der Launcher läuft. Die Konfiguration der Seite schlägt die des Dashboards.

Die Ladereihenfolge zählt: Die Konfiguration muss existieren, wenn der Launcher ausgeführt wird. Mit `next/script` gibst du der Konfiguration `beforeInteractive` (im Root-Layout erlaubt) und dem 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 laufen vor der Hydration, `afterInteractive`-Scripts danach, die Reihenfolge ist also garantiert. Die Keys sind dieselben wie überall: `style` (einer von 8), `font`, `accent`, `side`, `greeting`, `label`, `lang`. Die [Docs](/de/docs/) listen sie alle.

## der pages router

Im Pages Router ist der gemeinsame 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" />
    </>
  );
}
```

Für eine Konfiguration gehören `beforeInteractive`-Scripts im Pages Router in `pages/_document.tsx`. Setz das `window.Spookat`-Script dort hinein, innerhalb von `<Head>`, mit derselben `id` und `strategy` wie oben, und lass den Launcher in `_app.tsx`.

## der eingeloggte nutzer

Ab pro kannst du dem Chat sagen, wer eingeloggt ist. Dein Team sieht dann `logged in as 42` auf der Slack-Karte, und Webhooks, der Export und `read_convo` deines Agenten tragen die ID. Die ID wird mit einem Secret signiert, das nur dein Server kennt, damit niemand sich als jemand anderes ausgeben kann.

Erstell das Identity-Secret auf der Install-Seite im Dashboard, pack es in eine Umgebungsvariable und signier auf dem Server. Im App Router ist das Root-Layout eine Server-Komponente, was das einfach macht:

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

Berechne die Signatur nie in einer Client-Komponente: Das Secret würde im Browser landen. Eine fehlende oder falsche Signatur ist kein Fehler; der Chat sagt dann einfach nicht, wer es ist. Wenn du die Session im Root-Layout liest, rendert jede Seite pro Request. Ist dein Layout heute statisch, wäg das ab, bevor du es hinzufügst.

## content security policy

Wenn deine App einen `Content-Security-Policy`-Header schickt, braucht das Widget:

- `script-src` und `style-src`: `https://cdn.spookat.com`
- `font-src`: `https://cdn.spookat.com`, wenn du einen gehosteten Font gewählt hast
- `connect-src`: `wss://api.spookat.com`, für die Chat-Verbindung nach einem Klick
- `img-src`: `data:`, für Logos und Profilbilder deines Teams
- `style-src`: Der Launcher fügt einen kleinen `<style>`-Block in seine eigene Shadow Root ein, Inline-Styles müssen dafür also erlaubt sein

Ohne diese Einträge sagt dir die Browser-Konsole genau, welche Direktive was blockiert hat. Füg die genannte Quelle hinzu und lade neu.

## prüf es in einem produktions-build

Der Dev-Modus bringt eigene Scripts und Hot Reload mit, prüf also einen echten Build:

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

Öffne `http://localhost:3000` in einem privaten Fenster mit offenen DevTools. Die Domain zählt: Der Chat verbindet sich nur von der registrierten Domain und ihren Subdomains, auf localhost siehst du also den Launcher, aber der Chat verbindet sich nicht. Den Chat selbst testest du auf deiner deployten Seite.

Auf beiden:

1. **Network**, „Disable cache“, neu laden, nach `spookat` filtern: ein Request, der Launcher von `cdn.spookat.com`. Zwei, beide von `cdn.spookat.com`, wenn dein Code in `window.Spookat` einen anderen Style setzt als den, den du veröffentlicht hast. Nichts an `api.spookat.com`.
2. **Application**: keine Cookies von Spookat und kein Speicher bis zur ersten Nachricht.
3. **Performance oder Lighthouse**: Der Launcher sollte nicht als Long Task auftauchen. Vergleich ein paar Durchläufe mit und ohne ihn; der Unterschied sollte im normalen Rauschen zwischen Durchläufen liegen.
4. **Klick auf den Launcher** auf der deployten Seite: Das Panel lädt, die Verbindung öffnet sich, deine Nachricht erreicht Slack.

## deploy-ziele

Nichts davon hängt davon ab, wo die App läuft. Vercel, ein Node-Server, ein Container oder `output: "export"` für eine statische Seite liefern alle dasselbe HTML aus, und der Tag funktioniert überall gleich. Bei einem statischen Export muss der eingeloggte Nutzer von einem Server kommen, den du kontrollierst, weil es kein Server-Rendering gibt, das ihn signieren könnte.

## verbinde die antworten

Sobald der Chat auf der Seite ist, verbinde Slack im Dashboard und wähl einen Channel. Der [Slack-Guide](/de/blog/answer-website-chat-from-slack/) führt durch. Wenn du Claude Code nutzt, zeigt der [Claude-Code-Guide](/de/blog/claude-code-live-chat/), wie der Agent all das oben erledigt.

## mehrsprachige apps

Hat deine App ein `[locale]`-Segment, sag dem Widget, in welcher Sprache die Seite ist, damit das Panel zur Seite passt und nicht zum Browser des Besuchers. Das Widget spricht 12 Sprachen; `lang` nimmt Codes wie `en`, `pl`, `de`. In `app/[locale]/layout.tsx`:

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

Eine Sprache, die das Widget nicht hat, fällt auf den Browser des Besuchers zurück, dann auf Englisch. Dein Team sieht die Sprache so oder so am Chat.

## häufige fehler

- **Der Tag in einer Client-Komponente mit `"use client"`.** Er rendert, wird aber mit der Komponente hydriert, und jedes Remount ist eine weitere Chance auf ein Duplikat. Lass ihn im Layout.
- **Das Script in `useEffect` erzeugen.** Mehr Code für dasselbe Ergebnis, und es lädt später als `afterInteractive`.
- **`type="module"` am Tag.** Der Launcher ist ein klassisches Script; lass den Typ weg.
- **Das Panel vorladen.** Ein `<link rel="preload">` für das Panel-Script schiebt seinen Download für jeden Besucher vor den Klick. Der ganze Sinn ist, dass es wartet.
- **Den Chat auf einem Preview-Deployment testen.** Preview-URLs sind andere Domains. Der Launcher erscheint, der Chat verbindet sich nicht. Das ist so gewollt.

## was es zu deinem bundle hinzufügt

Nichts. Der Launcher kommt als eigene Datei von unserem CDN, er ist also nicht Teil deines JavaScript-Bundles, und die „First Load JS“-Zahlen, die `next build` ausgibt, bleiben exakt gleich. React fasst er auch nicht an: Der Button lebt in seiner eigenen Shadow Root, dein CSS kann ihn also nicht kaputt machen und sein CSS nicht in deine App auslaufen. Das Einzige, was dein Build wissen muss, ist der eine Tag.

## checkliste

- Ein Tag oder `<Script>` im Root-Layout (`app/layout.tsx` oder `pages/_app.tsx`).
- Eine Konfiguration, falls vorhanden, läuft vor dem Launcher.
- Nutzer-Signaturen werden nur auf dem Server berechnet.
- Die CSP erlaubt das CDN und die Chat-Verbindung, falls du eine CSP hast.
- Geprüft in einem Produktions-Build: ein Request vor einem Klick (zwei, beide von `cdn.spookat.com`, wenn dein Code einen Style setzt, den du nicht veröffentlicht hast), keine Cookies.

Es kostet 4,99 € pro Monat und Site mit starter, und die Testphase dauert 14 Tage ohne Karte.

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