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

复制到的是这样一行:
<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→/,后退也算一次。

最后一条值得多说一句:好的聊天插件会自己接管 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。