coloque chat ao vivo num site next.js

Chat ao vivo no Next.js é uma tag script no layout raiz. Este guia mostra onde ela vai no app router e no pages router, como passar a aparência do widget e o usuário logado sem bagunçar a ordem de carregamento, o que adicionar numa Content Security Policy e como conferir o resultado num build de produção.
a versão curta
No app router, adicione a tag em app/layout.tsx, dentro de <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>
);
}
Troque YOUR_SITE_KEY pela chave da página de instalação do painel. Essa é a instalação inteira quando a aparência fica no painel, que é o padrão. O resto deste guia é para quando você quer mais controle. Se está começando do zero, o guia principal cobre a parte da conta.
por que o layout raiz
O layout raiz envolve todas as rotas e continua montado enquanto o usuário navega. O botão do chat se prende à página uma vez só. A navegação do lado do cliente não recarrega a página, então também não recarrega o botão, e um visitante pode manter a conversa aberta enquanto clica pelo site.
Coloque a tag num componente de página e o chat só existe naquela página. Coloque num componente que renderiza em várias páginas e você ganha duplicatas nos recarregamentos completos (o botão ignora a própria segunda cópia, mas ainda é uma requisição desperdiçada).
com next/script
next/script é o jeito Next.js de carregar scripts de terceiros, e funciona bem aqui:
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 carrega o botão logo depois da hidratação. lazyOnload espera o navegador ficar ocioso, o que atrasa um pouco o aparecimento do botão em celulares lentos. Os dois servem para um botão de 2 kb; afterInteractive mostra o botão mais cedo. Não use beforeInteractive para o botão: ele disputaria espaço com o seu próprio código à toa.
definindo a aparência no código
A aparência que você publica no painel vai dentro do botão. Se prefere manter no repositório, defina window.Spookat antes de o botão rodar. A configuração da página vale mais que a do painel.
A ordem de carregamento importa: a configuração precisa existir quando o botão executar. Com next/script, dê beforeInteractive para a configuração (permitido no layout raiz) e afterInteractive para o botão:
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>
);
}
Scripts beforeInteractive rodam antes da hidratação e os afterInteractive depois, então a ordem é garantida. As chaves são as mesmas de qualquer outro lugar: style (um de 8), font, accent, side, greeting, label, lang. A documentação lista todas.
o pages router
No pages router, o wrapper compartilhado é 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" />
</>
);
}
Para uma configuração, no pages router os scripts beforeInteractive ficam em pages/_document.tsx. Coloque o script do window.Spookat lá, dentro de <Head>, com o mesmo id e a mesma strategy de cima, e deixe o botão no _app.tsx.
o usuário logado
A partir do pro, você pode dizer ao chat quem está logado. Sua equipe passa a ver logged in as 42 no card do Slack, e os webhooks, a exportação e o read_convo do seu agente levam o id. O id é assinado com um segredo que só o seu servidor conhece, então ninguém consegue se passar por outra pessoa.
Crie o segredo de identidade na página de instalação do painel, coloque numa variável de ambiente e assine no servidor. No app router, o layout raiz é um componente de servidor, o que deixa isso simples:
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 calcule a assinatura num componente de cliente: o segredo iria parar no navegador. Uma assinatura ausente ou errada não é um erro; o chat simplesmente não diz quem é. Ler a sessão no layout raiz faz toda página renderizar por requisição, então, se o seu layout é estático hoje, pense nisso antes de adicionar.
content security policy
Se o seu app manda um cabeçalho Content-Security-Policy, o widget precisa de:
script-srcestyle-src:https://cdn.spookat.comfont-src:https://cdn.spookat.com, se você escolheu uma fonte hospedadaconnect-src:wss://api.spookat.com, para a conexão do chat depois de um cliqueimg-src:data:, para os logos e as fotos da equipestyle-src: o botão injeta um pequeno bloco<style>no próprio shadow root, então estilos inline precisam ser permitidos para ele
Sem isso, o console do navegador diz exatamente qual diretiva bloqueou o quê. Adicione a origem que ele indicar e recarregue.
confira num build de produção
O modo de desenvolvimento adiciona scripts próprios e hot reload, então confira um build de verdade:
next build && next start
Abra http://localhost:3000 numa janela anônima com o DevTools aberto. O domínio importa: o chat só conecta a partir do domínio que você registrou e dos subdomínios dele, então no localhost você vê o botão, mas o chat não conecta. Teste o chat em si no seu site publicado.
Em qualquer um dos dois:
- Network, “Disable cache”, recarregue, filtre por
spookat: uma requisição, o botão vindo decdn.spookat.com. Duas, ambas decdn.spookat.com, se o seu código define emwindow.Spookatum estilo diferente do que você publicou. Nada paraapi.spookat.com. - Application: nenhum cookie do Spookat, e nenhum armazenamento até a primeira mensagem.
- Performance ou Lighthouse: o botão não deve aparecer como uma tarefa longa. Compare algumas execuções com e sem ele; a diferença deve ficar dentro do ruído normal entre execuções.
- Clique no botão do chat no site publicado: o painel carrega, a conexão abre, sua mensagem chega no Slack.
destinos de deploy
Nada aqui depende de onde o app roda. Vercel, um servidor Node, um container ou output: "export" para um site estático servem o mesmo HTML, e a tag funciona igual em todos. Num export estático, o usuário logado precisa vir de um servidor que você controla, já que não há renderização no servidor para assinar.
conecte as respostas
Com o chat no site, conecte o Slack no painel e escolha um canal. O guia do Slack mostra o passo a passo. Se você usa o Claude Code, o guia do Claude Code mostra como deixar o agente fazer tudo isso.
apps multilíngues
Se o seu app tem um segmento [locale], diga ao widget em que idioma a página está, para o painel combinar com a página e não com o navegador do visitante. O widget fala 12 idiomas; lang aceita códigos como en, pl, de. Em app/[locale]/layout.tsx:
<Script id="spookat-config" strategy="beforeInteractive">
{`window.Spookat = ${JSON.stringify({ lang: locale })};`}
</Script>
Um idioma que o widget não tem cai para o do navegador do visitante e depois para o inglês. Sua equipe vê o idioma no chat de qualquer jeito.
erros comuns
- A tag dentro de um componente de cliente com
"use client". Ela renderiza, mas é hidratada junto com o componente, e cada remontagem é mais uma chance de duplicata. Deixe no layout. - Criar o script num
useEffect. Mais código para o mesmo resultado, e carrega depois doafterInteractive. type="module"na tag. O botão é um script clássico; deixe o tipo de fora.- Pré-carregar o painel. Um
<link rel="preload">para o script do painel joga o download dele para antes do clique, para cada visitante. A graça é justamente ele esperar. - Testar o chat num deploy de preview. URLs de preview são outros domínios. O botão aparece, o chat não conecta. É o esperado.
o que isso adiciona ao seu bundle
Nada. O botão vem da nossa CDN como um arquivo separado, então não faz parte do seu bundle JavaScript, e os números de “First Load JS” que o next build mostra continuam exatamente iguais. Ele também não mexe no React: o botão vive no próprio shadow root, então seu CSS não consegue quebrá-lo e o CSS dele não vaza para o seu app. A única coisa que seu build precisa saber é a tag.
checklist
- Uma tag ou
<Script>no layout raiz (app/layout.tsxoupages/_app.tsx). - A configuração, se houver, roda antes do botão.
- As assinaturas de usuário são calculadas só no servidor.
- A CSP libera a CDN e a conexão do chat, se você tem uma CSP.
- Conferido num build de produção: uma requisição antes de um clique (duas, ambas de
cdn.spookat.com, se o seu código define um estilo que você não publicou), nenhum cookie.
Custa $4.99 por mês por site no starter, e o teste dura 14 dias sem cartão.