Next.js 加聊天插件:放在哪、路由切换会不会重复加载

第三方聊天插件的安装说明都写着贴在 body 结束标签前面,可 Next.js 里没有这个地方。用 next/script 放进根布局,全站只加载一次,客户端跳转也不会重来。

Next.js 加聊天插件的正确位置是根布局:App Router 用 app/layout.tsx,Pages Router 用 pages/_app.tsx,用 next/script 的 <Script> 组件写一次,strategy 选 afterInteractive 或 lazyOnload。这样整站每个页面都有,客户端路由切换时也不会重复执行。

要点速览

  • 用 next/script 写在根布局(App Router)或 pages/_app.tsx(Pages Router)里;不要在 JSX 里直接写 <script>,也不要放进 pages/_document.tsx。
  • 想让聊天按钮早点出现选 afterInteractive,更在意性能分数选 lazyOnload;Next.js 官方文档把聊天插件列在 lazyOnload 的例子里。
  • 客户端路由切换不会重新加载脚本:页面上始终一个标签、一个插件,访客轨迹照样跟着路由走。
  • window.SealReply 只在客户端组件的事件回调或 useEffect 里调用,加上 ?. 就够了。
  • 某些路由不想显示聊天,用后台的页面规则:插件在每次客户端跳转时都会重新判断。

第三方插件的安装说明一般写“把这行代码贴在 </body> 前面”,但 Next.js 项目里没有一个能让你手改的 HTML 文件,直接往 JSX 里塞 <script> 标签也不会按预期执行。下面的写法在 Next.js 16.4.6 的生产构建上实测过,App Router 和 Pages Router 都验证了,文中的结论都来自实际跑出来的结果。

第 1 步:拿到站点密钥

嵌入代码在客服工作台里复制,Next.js 里真正要用的只有其中的站点密钥(data-site-key)。

  1. 注册一个 SealReply 免费账号,工作台会打开“设置在线客服”。
  2. 在“你的网站”里填“站点名称”和应用部署的“网站地址”(例如 app.example.com),点“继续”。
  3. 在“安装插件”这一步,“网站使用的平台”保持 HTML,点“复制”。

The Your website step of Set up your chat, with a site name and website address filled in

复制到的是这样一行:

<script src="https://cdn.sealreply.com/widget.js" data-site-key="sk_…" async></script>

记下 src 和 data-site-key 的值,下一步把它们改写成 <Script> 组件。站点密钥不是机密,本来就会出现在每个页面的 HTML 里,直接写在代码里或者放进 NEXT_PUBLIC_ 开头的环境变量都可以。

第 2 步:App Router 里脚本放在哪?

放在根布局 app/layout.tsx 里,所有路由都会带上:

// app/layout.tsx
import Script from "next/script";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="zh-CN">
      <body>
        {children}
        <Script
          src="https://cdn.sealreply.com/widget.js"
          data-site-key="sk_…"
          strategy="afterInteractive"
        />
      </body>
    </html>
  );
}

两个容易踩的点:

  • data-* 属性照常传。 <Script> 会把你额外写的属性原样放到生成的 script 标签上,插件靠 document.currentScript.dataset 读 data-site-key,所以一个都不能漏。实测生成出来的标签带着 data-site-key,而且自动是 async 的。Next.js 官方的脚本加载指南(英文)在“Additional Attributes”一节写明了这一点。
  • 根布局是服务端组件也没关系。 <Script> 本身可以放在服务端组件里;只有 onLoad、onReady、onError 这几个回调要求所在组件是客户端组件(文件顶部加 "use client")。

第 2 步(Pages Router):写进 pages/_app.tsx

Pages Router 里没有根布局,对应的位置是包住所有页面的 pages/_app.tsx:

// 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.sealreply.com/widget.js"
        data-site-key="sk_…"
        strategy="afterInteractive"
      />
    </>
  );
}

别写进 _document

pages/_document.tsx 只在服务端渲染,next/script 放在那里是给 beforeInteractive 策略用的,聊天插件用不上,也不该用。

第 3 步:strategy 选哪个?

想让聊天按钮早点出现就用 afterInteractive,更在意性能分数就用 lazyOnload。四种策略的区别见 next/script 的 API 文档(英文),整理成表是这样:

strategy 什么时候加载 适合
beforeInteractive 在 Next 自己的代码之前 机器人识别、Cookie 同意弹窗。只能放在根布局,聊天插件别用
afterInteractive(默认) 页面完成部分 hydration 之后 统计、标签管理器;想让聊天按钮早点出现也用这个
lazyOnload 浏览器空闲时 官方文档举的例子里就有聊天插件和社交挂件
worker(实验性) 在 Web Worker 里 不适合聊天插件:目前不支持 App Router,插件也要操作页面 DOM

怎么选看你更在意哪头:lazyOnload 对 Lighthouse 分数最友好,代价是访客要多等一会儿才看到聊天按钮;afterInteractive 按钮出现得早,适合落地页、定价页这种随时可能有人要问的站。SealReply 的插件约 38 KB、异步加载,不阻塞页面显示,两种都不会拖住首屏内容。

量一下再定

用 PageSpeed Insights 对同一个页面各测一次,对比 LCP 和 TBT。两个版本部署到同一个网址再测,免得别的因素混进来。

第 4 步:怎么确认装好了?

部署上线,打开线上网站,看角落里有没有聊天按钮。第一次打开首页的同时也完成了域名验证,没验证过的域名上插件是不显示的。

再打开浏览器控制台,跑这两行:

document.querySelectorAll('script[src*="widget.js"]').length; // 1
window.SealReply?.version; // 插件的版本号

第一行应该是 1,第二行应该返回一个版本号。用 <Link> 点几个页面,再跑一次第一行,还应该是 1。最后自己在插件里发一条消息,回到工作台回复,确认两头都通。

路由切换时会不会重复加载?

不会。客户端跳转不会重新加载整个页面,脚本也就不会重新执行,这正是你要的结果。Next.js 官方文档的原话是:放在根布局里的脚本,用户在页面之间跳转时也“只会加载一次”。实测一个 Next.js 16.4.6 的生产构建,从首页点 <Link> 跳到 /pricing 再点浏览器后退:

  • 页面上始终只有一个 widget.js 标签,插件只初始化一次;
  • 聊天按钮一直在,切换页面时不会闪一下重新加载;
  • 插件记下的访客轨迹是 / → /pricing → /,后退也算一次。

A diagram showing one app shell, two interchangeable page cards and a single chat script loaded once for all of them

最后一条值得多说一句:好的聊天插件会自己接管 history.pushState、replaceState,并监听 popstate 和 hashchange,所以客服在工作台里看到的“当前页面”会跟着单页应用的路由走,而不是永远停在访客进站的那一页。按页面设置的欢迎语和自动邀请也一样跟着路由变。这一点在选插件时值得测一下——有的插件只在整页加载时上报一次。

顺带一提,就算你把 <Script> 写在某个具体页面里(而不是根布局),同一个 src 在一次页面会话里也只会执行一次:来回切换两次这个路由,脚本里的计数器仍然是 1。开发模式下 React 严格模式会让组件挂载两遍,实测也只有一个脚本标签。

怎么用自己的按钮打开聊天窗口?

在嵌入代码上加 data-launcher="none",再在客户端组件里调用 window.SealReply.open():

"use client";

export function ContactButton() {
  return (
    <button type="button" onClick={() => window.SealReply?.open()}>
      联系我们
    </button>
  );
}

加了 data-launcher="none" 之后,角落里不再显示默认按钮,也不会弹邀请气泡,聊天窗口只在你的代码调用 open() 时打开。普通 HTML 网站、导航里的“联系我们”链接、以及藏掉默认按钮之后怎么自己做未读红点,见自定义按钮打开在线客服。脚本是异步加载的,点击时它可能还没就绪——?. 就够了,没必要搞复杂。真要在加载完成后马上做点什么(比如数客服回了几条),等一下它的就绪事件:

"use client";
import { useEffect, useState } from "react";

export function ChatButton() {
  const [unread, setUnread] = useState(0);

  useEffect(() => {
    const onMessage = () => setUnread((n) => n + 1);
    const subscribe = () => window.SealReply?.on("message", onMessage);
    if (window.SealReply) subscribe();
    else window.addEventListener("sealreply:ready", subscribe, { once: true });
    return () => {
      window.removeEventListener("sealreply:ready", subscribe);
      window.SealReply?.off("message", onMessage);
    };
  }, []);

  return (
    <button type="button" onClick={() => window.SealReply?.open()}>
      联系我们{unread > 0 ? `(${unread})` : ""}
    </button>
  );
}

TypeScript 项目加一个声明文件,window.SealReply 就有类型了:

// types/sealreply.d.ts
export type SealReplyMessage = {
  id: string;
  text: string | null;
  content_type: string;
  created_at: string;
};

declare global {
  interface Window {
    SealReply?: {
      open(): void;
      close(): void;
      on(event: "ready" | "open" | "close" | "message", handler: (data?: SealReplyMessage) => void): void;
      off(event: "ready" | "open" | "close" | "message", handler: (data?: SealReplyMessage) => void): void;
      identify(identity: { external_id: string; identity_hash: string; name?: string; email?: string }): void;
      reset(): void;
      version: string;
    };
  }
}

某些路由不想显示聊天怎么办?

用后台的页面规则:“站点 → 你的站点 → 页面规则 → 不显示聊天的页面”。不用改代码,单页应用里也好使——插件在每次客户端跳转时都会重新判断:访客跳进被排除的路由,聊天窗口收起、按钮消失;离开这个路由,按钮又会回来。“路径开头是 /app”能排除整个后台,“路径等于 /checkout”只排除一页,每个站点最多 20 条。

也可以在代码里把一部分页面隔开:<Script> 不放根布局,改放到路由分组的布局里,例如 app/(marketing)/layout.tsx。这样直接打开后台页面的访客,根本不会加载插件。

组件卸载了,插件不会跟着卸载

脚本一旦执行过,把 <Script> 从组件树里拿掉也不会移除插件,Next.js 不会卸载已经执行的脚本。访客先进营销页、再跳进 /app,插件会一直在。所以某些路由必须干净的话,路由分组布局要和页面规则一起用。

已登录用户怎么在聊天里显示名字?

用户登录后调一次 SealReply.identify(),带上服务端算好的签名:

window.SealReply?.identify({
  external_id: user.id,
  identity_hash: hashFromServer, // 服务端用密钥算的 HMAC-SHA256
  name: user.name,
  email: user.email,
});

先在站点设置里打开“访客身份验证”,复制密钥(只显示一次)。签名只能在服务端算:放在路由处理器(app/api/.../route.ts)或服务端组件里,密钥存进不带 NEXT_PUBLIC_ 前缀的环境变量,这样它永远不会被打包进浏览器代码。调用之后插件会以这位用户的身份重新加载,客服看到的是“已验证”的访客,换设备也认得。用户退出登录时调 SealReply.reset()。完整做法和四种语言的签名代码见插件开发文档。

装好了却不显示,怎么排查?

大部分问题出在三处:在服务端碰了 window、在本地测试、设了内容安全策略(CSP)。

问题 原因和解决
ReferenceError: window is not defined 在服务端组件或渲染期间碰了 window。只在事件回调或 useEffect 里用,组件加 "use client"。
本地看不到聊天按钮 插件只在站点的“允许域名”及其子域名上显示,而 localhost 加不进允许域名。在部署好的线上地址测,或者用 staging.example.com 这样的子域名部署一个测试环境。主域名要先验证过:代码部署到线上后打开一次首页即可。
出现两个按钮 同一个脚本声明了两次(例如根布局和某个页面各写了一个,而且策略不同)。只保留一处。
设了 CSP 之后插件不加载 放行 script-src https://cdn.sealreply.com、connect-src https://api.sealreply.com wss://rt.sealreply.com、img-src https://api.sealreply.com。用的是带 nonce 的 CSP,还要把 nonce 传给 <Script>,见 Next.js 内容安全策略指南(英文)。
想在点击之后再加载 把 <Script> 放在一个状态为真才渲染的组件里,用自己的按钮触发,next/script 会在那时才插入脚本。

常见问题

Next.js 的聊天插件脚本应该放在哪里?

App Router 放在根布局 app/layout.tsx,Pages Router 放在 pages/_app.tsx,都用 next/script 的 <Script> 组件写。写在这里一次,所有路由都会加载,客户端跳转时也不会重复执行。

聊天插件用 afterInteractive 还是 lazyOnload?

两个都能用,看你更在意什么。afterInteractive 在页面可交互后很快就显示聊天按钮;lazyOnload 等浏览器空闲再加载,对性能分数更友好。Next.js 官方文档把聊天插件列为 lazyOnload 的典型例子。

调用 window.SealReply 为什么报 window is not defined?

因为这段代码在服务端执行了,服务端没有 window。把调用挪到加了 "use client" 的组件里,并且只在事件回调或 useEffect 里执行,这两处只会在浏览器里运行。

能在 localhost 上测试聊天插件吗?

不能。插件只在站点的允许域名上加载,localhost 和 IP 地址都加不进这个列表。可以在已验证域名的子域名上部署一个测试环境,例如 staging.example.com,或者直接在线上地址测。

项目里的全局 CSS 或 Tailwind 会把聊天插件样式弄乱吗?

不会。插件渲染在 Shadow DOM 里,页面的 CSS 影响不到它,它的样式也不会漏到你的页面上。颜色、位置这些在工作台的“外观”里调。

站点密钥写进 Next.js 代码里安全吗?

安全。站点密钥本来就会出现在每个页面的 HTML 里,而且插件只在你允许的域名上显示,别人复制到自己网站上也用不了。真正要保密的是访客身份验证的密钥,它只能放在服务端的环境变量里。

接下来

插件装好之后,站内发生的事还可以接进你自己的系统:用 Webhook 签名怎么验证里的方法确认请求确实来自我们,再照网站咨询推送到飞书、钉钉、Slack 和 Telegram把新咨询转到你平时用的群里(Webhook 从入门版起提供)。

同一个 SealReply.on("open", …) 监听也是统计的入口:GA4 怎么统计在线客服带来的转化里写了在单页应用中该把它挂在哪,才不会每切一次路由就多发一次事件。

还没挑定工具的,先看网站怎么添加在线客服;已经装好、想把咨询接住并跟进的,看5 分钟上手 SealReply。