next.jsのサイトにライブチャットを入れる

Next.jsでのライブチャットは、ルートレイアウトのscriptタグ1つです。このガイドでは、App RouterとPages Routerでのタグの置き場所、読み込み順を崩さずにウィジェットのデザインやログイン中のユーザーを渡す方法、Content Security Policyに追加すべきもの、そして本番ビルドでの確認方法を紹介します。
手短に言うと
App Routerでは、app/layout.tsxの<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>
);
}
YOUR_SITE_KEYは、ダッシュボードのインストールページにあるキーに置き換えてください。デザインをダッシュボードで管理する場合(これが既定です)、インストールはこれだけです。このガイドの残りは、もっと細かく制御したい場合の話です。ゼロから始めるなら、アカウント側の手順は基本ガイドにあります。
なぜルートレイアウトなのか
ルートレイアウトはすべてのルートを包み、ユーザーがページを移動してもマウントされたままです。ランチャーはページに一度だけ取り付きます。クライアント側のナビゲーションではページが再読み込みされないので、ランチャーも再読み込みされず、訪問者はあちこちクリックしながら会話を開いたままにできます。
タグをページコンポーネントに置くと、チャットはそのページにしか存在しません。複数のページで描画されるコンポーネントに置くと、ハードリロードで重複します(ランチャーは自分の2つ目のコピーを無視しますが、無駄なリクエストであることに変わりはありません)。
next/scriptを使う
next/scriptは、Next.jsでサードパーティのスクリプトを読み込む方法で、ここでも問題なく使えます。
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はハイドレーション直後にランチャーを読み込みます。lazyOnloadはブラウザがアイドルになるまで待つので、遅いスマートフォンではボタンの表示が少し遅れます。2 kbのランチャーならどちらでも構いませんが、afterInteractiveのほうがボタンが早く出ます。ランチャーにbeforeInteractiveは使わないでください。無意味にあなたのコードと競合します。
デザインをコードで設定する
ダッシュボードで公開したデザインはランチャーの中に含まれます。リポジトリで管理したい場合は、ランチャーが実行される前にwindow.Spookatを設定してください。ページ側の設定がダッシュボードより優先されます。
読み込み順が大事です。ランチャーが実行される時点で設定が存在していなければなりません。next/scriptなら、設定にはbeforeInteractive(ルートレイアウトで使用可)を、ランチャーにはafterInteractiveを指定します。
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のスクリプトはハイドレーション前、afterInteractiveのものは後に実行されるので、順番は保証されます。キーはほかの場所と同じです。style(8種類のどれか)、font、accent、side、greeting、label、lang。すべてドキュメントに載っています。
pages router
Pages Routerでは、共通のラッパーは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" />
</>
);
}
設定を使う場合、Pages RouterではbeforeInteractiveのスクリプトはpages/_document.tsxに置きます。window.Spookatのスクリプトをそこの<Head>内に、上と同じidとstrategyで置き、ランチャーは_app.tsxに残してください。
ログイン中のユーザー
pro以上では、誰がログインしているかをチャットに伝えられます。するとチームには、Slackのカードにlogged in as 42と表示され、Webhook、エクスポート、エージェントのread_convoにもそのidが含まれます。idはあなたのサーバーだけが知るシークレットで署名されるので、誰かがほかの人になりすますことはできません。
ダッシュボードのインストールページでIDシークレットを作成し、環境変数に入れ、サーバー側で署名します。App Routerではルートレイアウトがサーバーコンポーネントなので、簡単です。
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>
);
}
署名は決してクライアントコンポーネントで計算しないでください。シークレットがブラウザに送られてしまいます。署名がない、または間違っていてもエラーにはならず、チャットが誰なのかを表示しないだけです。ルートレイアウトでセッションを読むと、すべてのページがリクエストごとに描画されるようになります。今レイアウトが静的なら、追加する前によく検討してください。
content security policy
アプリがContent-Security-Policyヘッダーを送っているなら、ウィジェットには次が必要です。
script-srcとstyle-src:https://cdn.spookat.comfont-src:ホスト済みフォントを選んだ場合はhttps://cdn.spookat.comconnect-src:クリック後のチャット接続用にwss://api.spookat.comimg-src:ロゴとチームメンバーの写真用にdata:style-src:ランチャーは自分のshadow rootに小さな<style>ブロックを追加するので、そのためにインラインスタイルを許可する必要があります
これらがないと、どのディレクティブが何をブロックしたかがブラウザのコンソールに正確に表示されます。表示されたソースを追加して再読み込みしてください。
本番ビルドで確認する
開発モードは独自のスクリプトやホットリロードを追加するので、本物のビルドで確認します。
next build && next start
DevToolsを開いたプライベートウィンドウでhttp://localhost:3000を開きます。ドメインが大事です。チャットは登録したドメインとそのサブドメインからしか接続しないので、localhostではランチャーは見えても、チャットは接続しません。チャット自体はデプロイしたサイトで試してください。
どちらでも次を確認します。
- Networkで「Disable cache」、再読み込み、
spookatで絞り込み:リクエストは1件、cdn.spookat.comからのランチャー。公開したものと違うスタイルをコードでwindow.Spookatに指定している場合は2件で、どちらもcdn.spookat.comからです。api.spookat.comには何も送られません。 - Application:SpookatのCookieはなく、最初のメッセージまでストレージも使われません。
- PerformanceまたはLighthouse:ランチャーがロングタスクとして現れてはいけません。ありとなしで数回ずつ比べ、差が実行ごとの通常のぶれに収まっていることを確認してください。
- デプロイしたサイトでランチャーをクリック:パネルが読み込まれ、接続が開き、メッセージがSlackに届きます。
デプロイ先
ここで説明したことは、アプリがどこで動いているかに左右されません。Vercel、Nodeサーバー、コンテナ、静的サイト用のoutput: "export"、どれも同じHTMLを配信し、タグはどこでも同じように動きます。静的エクスポートの場合、署名するサーバーレンダリングがないので、ログイン中のユーザー情報はあなたが管理するサーバーから渡す必要があります。
返信先をつなぐ
チャットをサイトに入れたら、ダッシュボードでSlackを接続してチャンネルを選びます。手順はSlackのガイドで説明しています。Claude Codeを使っているなら、Claude Codeのガイドで、ここまでの作業をエージェントに任せる方法を紹介しています。
多言語アプリ
アプリに[locale]セグメントがあるなら、ページの言語をウィジェットに伝えましょう。パネルが訪問者のブラウザではなくページに合わせて表示されます。ウィジェットは12言語に対応していて、langにはen、pl、deのようなコードを指定します。app/[locale]/layout.tsxでは次のようにします。
<Script id="spookat-config" strategy="beforeInteractive">
{`window.Spookat = ${JSON.stringify({ lang: locale })};`}
</Script>
ウィジェットにない言語は、訪問者のブラウザの言語、次に英語にフォールバックします。どちらの場合も、チームにはチャットの言語が表示されます。
よくある間違い
"use client"付きのクライアントコンポーネントにタグを置く。 描画はされますが、コンポーネントと一緒にハイドレーションされ、再マウントのたびに重複の機会が生まれます。レイアウトに置いてください。useEffectでスクリプトを作る。 同じ結果のためにコードが増え、afterInteractiveより読み込みが遅くなります。- タグに
type="module"を付ける。 ランチャーは従来型のスクリプトなので、typeは付けないでください。 - パネルを先読みする。 パネルのスクリプトに
<link rel="preload">を付けると、訪問者全員についてダウンロードがクリックの前に移ります。待つことにこそ意味があります。 - プレビュー環境でチャットをテストする。 プレビューのURLは別のドメインです。ランチャーは出ても、チャットは接続しません。それで正常です。
バンドルに加わるもの
何もありません。ランチャーは私たちのCDNから独立したファイルとして届くので、JavaScriptのバンドルには含まれず、next buildが表示する「First Load JS」の数字もまったく変わりません。Reactにも触れません。ボタンは自分のshadow rootの中にあるので、あなたのCSSで壊れることも、ランチャーのCSSがアプリに漏れることもありません。ビルドが知っておくべきなのは、タグ1つだけです。
チェックリスト
- ルートレイアウト(
app/layout.tsxまたはpages/_app.tsx)にタグか<Script>が1つ。 - 設定がある場合は、ランチャーより先に実行される。
- ユーザーの署名はサーバー側でのみ計算する。
- CSPがある場合は、CDNとチャット接続を許可している。
- 本番ビルドで確認済み:クリック前のリクエストは1件(公開していないスタイルをコードで指定している場合は2件、どちらも
cdn.spookat.comから)、Cookieなし。
料金はstarterで1サイトあたり月$4.99、トライアルはカード不要の14日間です。