# añade chat en vivo a una web con next.js

Añade el chat de Spookat a una app de Next.js, con app router o pages router, con su aspecto y opcionalmente el usuario con sesión iniciada, y comprueba que no hace ninguna petición a nuestra API antes de un clic.

2026-11-04 · Kuba

El chat en vivo en Next.js es una etiqueta script en el layout raíz. Esta guía muestra dónde va con el app router y con el pages router, cómo pasar el aspecto del widget y el usuario con sesión iniciada sin romper el orden de carga, qué añadir a una Content Security Policy y cómo comprobar el resultado en un build de producción.

## la versión corta

Con el app router, añade la etiqueta en `app/layout.tsx`, dentro de `<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>
  );
}
```

Sustituye `YOUR_SITE_KEY` por la clave de la página de instalación del panel. Esa es toda la instalación cuando el aspecto vive en el panel, que es lo normal. El resto de la guía es para cuando quieres más control. Si empiezas de cero, la [guía principal](/es/blog/add-live-chat-to-your-website/) cubre la parte de la cuenta.

## por qué el layout raíz

El layout raíz envuelve cada ruta y se mantiene montado mientras el usuario navega. El lanzador se engancha a la página una sola vez. La navegación del lado del cliente no recarga la página, así que tampoco recarga el lanzador, y un visitante puede mantener una conversación abierta mientras hace clic por ahí.

Si pones la etiqueta en un componente de página, el chat solo existe en esa página. Si la pones en un componente que se renderiza en varias páginas, tendrás duplicados en las recargas completas (el lanzador ignora su propia segunda copia, pero sigue siendo una petición desperdiciada).

## con next/script

`next/script` es la forma de Next.js de cargar scripts de terceros, y aquí funciona bien:

```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` carga el lanzador justo después de la hidratación. `lazyOnload` espera a que el navegador esté inactivo, lo que retrasa un poco la aparición del botón en móviles lentos. Cualquiera de los dos va bien para un lanzador de 2 kb; `afterInteractive` muestra el botón antes. No uses `beforeInteractive` para el lanzador: competiría con tu propio código para nada.

## definir el aspecto en el código

El aspecto que publicas en el panel viaja dentro del lanzador. Si prefieres tenerlo en el repositorio, define `window.Spookat` antes de que se ejecute el lanzador. La configuración de la página tiene prioridad sobre la del panel.

El orden de carga importa: la configuración tiene que existir cuando el lanzador se ejecuta. Con `next/script`, dale `beforeInteractive` a la configuración (permitido en el layout raíz) y `afterInteractive` al lanzador:

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

Los scripts `beforeInteractive` se ejecutan antes de la hidratación y los `afterInteractive` después, así que el orden está garantizado. Las claves son las mismas que en cualquier otro sitio: `style` (uno de 8), `font`, `accent`, `side`, `greeting`, `label`, `lang`. La [documentación](/es/docs/) las lista todas.

## el pages router

Con el pages router, el envoltorio compartido es `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" />
    </>
  );
}
```

Para una configuración, en el pages router los scripts `beforeInteractive` van en `pages/_document.tsx`. Pon ahí el script de `window.Spookat`, dentro de `<Head>`, con el mismo `id` y la misma `strategy` que arriba, y deja el lanzador en `_app.tsx`.

## el usuario con sesión iniciada

Desde pro, puedes decirle al chat quién ha iniciado sesión. Tu equipo verá entonces `logged in as 42` en la tarjeta de Slack, y los webhooks, la exportación y el `read_convo` de tu agente llevan el id. El id se firma con un secreto que solo conoce tu servidor, así que nadie puede hacerse pasar por otra persona.

Crea el secreto de identidad en la página de instalación del panel, guárdalo en una variable de entorno y firma en el servidor. Con el app router, el layout raíz es un componente de servidor, lo que lo hace sencillo:

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

Nunca calcules la firma en un componente de cliente: el secreto acabaría en el navegador. Una firma que falta o es incorrecta no es un error; el chat simplemente no dice quién es. Leer la sesión en el layout raíz hace que cada página se renderice por petición, así que si tu layout hoy es estático, valóralo antes de añadirlo.

## content security policy

Si tu app envía una cabecera `Content-Security-Policy`, el widget necesita:

- `script-src` y `style-src`: `https://cdn.spookat.com`
- `font-src`: `https://cdn.spookat.com`, si elegiste una fuente alojada
- `connect-src`: `wss://api.spookat.com`, para la conexión del chat después de un clic
- `img-src`: `data:`, para los logos y las fotos de tu equipo
- `style-src`: el lanzador inyecta un pequeño bloque `<style>` en su propio shadow root, así que hay que permitir estilos inline para él

Sin esto, la consola del navegador dice exactamente qué directiva bloqueó qué. Añade la fuente que indica y recarga.

## compruébalo en un build de producción

El modo desarrollo añade sus propios scripts y la recarga en caliente, así que comprueba un build real:

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

Abre `http://localhost:3000` en una ventana privada con DevTools abierto. El dominio importa: el chat solo se conecta desde el dominio que registraste y sus subdominios, así que en localhost verás el lanzador, pero el chat no se conectará. Prueba el chat en sí en tu web desplegada.

En cualquiera de los dos:

1. **Network**, "Disable cache", recarga, filtra por `spookat`: una petición, el lanzador desde `cdn.spookat.com`. Dos, ambas desde `cdn.spookat.com`, si tu código pone en `window.Spookat` un estilo distinto del que publicaste. Nada hacia `api.spookat.com`.
2. **Application**: ninguna cookie de Spookat y ningún almacenamiento hasta un primer mensaje.
3. **Performance o Lighthouse**: el lanzador no debería aparecer como una tarea larga. Compara varias ejecuciones con y sin él; la diferencia debería quedar dentro del ruido normal entre ejecuciones.
4. **Haz clic en el lanzador** en la web desplegada: el panel se carga, la conexión se abre y tu mensaje llega a Slack.

## destinos de despliegue

Nada de esto depende de dónde se ejecute la app. Vercel, un servidor Node, un contenedor u `output: "export"` para una web estática sirven el mismo HTML, y la etiqueta funciona igual en todos. En una exportación estática, el usuario con sesión iniciada tiene que venir de un servidor que controles, porque no hay renderizado en servidor que lo firme.

## conecta las respuestas

Cuando el chat esté en la web, conecta Slack en el panel y elige un canal. La [guía de Slack](/es/blog/answer-website-chat-from-slack/) te acompaña. Si usas Claude Code, la [guía de Claude Code](/es/blog/claude-code-live-chat/) muestra cómo dejar que el agente haga todo lo anterior.

## apps multilingües

Si tu app tiene un segmento `[locale]`, dile al widget en qué idioma está la página, para que el panel coincida con la página y no con el navegador del visitante. El widget habla 12 idiomas; `lang` acepta códigos como `en`, `pl`, `de`. En `app/[locale]/layout.tsx`:

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

Un idioma que el widget no tiene recurre al del navegador del visitante y luego al inglés. Tu equipo ve el idioma en el chat en cualquier caso.

## errores frecuentes

- **La etiqueta dentro de un componente de cliente con `"use client"`.** Se renderiza, pero se hidrata con el componente, y cada remontaje es otra ocasión para un duplicado. Déjala en el layout.
- **Crear el script en `useEffect`.** Más código para el mismo resultado, y carga más tarde que `afterInteractive`.
- **`type="module"` en la etiqueta.** El lanzador es un script clásico; no pongas el tipo.
- **Precargar el panel.** Un `<link rel="preload">` para el script del panel adelanta su descarga antes del clic para cada visitante. La gracia es que espere.
- **Probar el chat en un despliegue de preview.** Las URLs de preview son otros dominios. El lanzador aparece, el chat no se conecta. Es lo esperado.

## lo que añade a tu bundle

Nada. El lanzador viene de nuestra CDN como archivo propio, así que no forma parte de tu bundle de JavaScript, y las cifras de "First Load JS" que imprime `next build` se quedan exactamente igual. Tampoco toca React: el botón vive en su propio shadow root, así que tu CSS no puede romperlo y su CSS no puede filtrarse a tu app. Lo único que tu build necesita saber es la etiqueta.

## checklist

- Una etiqueta o un `<Script>` en el layout raíz (`app/layout.tsx` o `pages/_app.tsx`).
- La configuración, si la hay, se ejecuta antes del lanzador.
- Las firmas de usuario se calculan solo en el servidor.
- La CSP permite la CDN y la conexión del chat, si tienes CSP.
- Comprobado en un build de producción: una petición antes de un clic (dos, ambas desde `cdn.spookat.com`, si tu código pone un estilo que no publicaste), sin cookies.

Cuesta $4.99 al mes por sitio con starter, y la prueba dura 14 días sin tarjeta.

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