Why Next.js Chatbot Integration Is Unlike Any Other Framework
Next.js is the most popular React meta-framework in 2026, powering over 1.2 million production websites according to W3Techs usage statistics. Its dominance comes from a unique architecture that combines server-side rendering, static generation, streaming, and client-side hydration in a single framework. This architecture is transformative for performance and SEO -- but it creates specific challenges for third-party widget integration that do not exist in plain React, Vue, or Angular applications.
The core problem is this: chatbot widgets are inherently client-side. They require the browser DOM, the window object, and real-time WebSocket connections. But Next.js renders components on the server first, where none of these exist. If your chatbot code runs during server-side rendering, you get one of three outcomes: a build error, a hydration mismatch, or a runtime crash.
In the App Router paradigm introduced in Next.js 13 and now standard in Next.js 15 and 16, the challenge intensifies because components are Server Components by default. You must explicitly opt into client-side rendering with the 'use client' directive for any component that touches the chatbot. Getting this boundary wrong means either shipping unnecessary JavaScript to the client or breaking server-side rendering entirely.
This guide covers every pattern you need for a production-grade chatbot integration in Next.js with the App Router. You will learn: the correct component boundaries between server and client, using next/script with the afterInteractive strategy for optimal loading, avoiding hydration mismatches, route-conditional loading using middleware and layout composition, and measuring Core Web Vitals impact before and after integration.
The patterns apply to any chatbot platform (Conferbot, Intercom, Drift, Crisp, or a custom-built widget), but we use Conferbot's embed script for concrete examples since it exposes a clean API that maps well to Next.js patterns.
Whether you are adding a customer support chatbot to your SaaS product, a lead qualification bot to your marketing site, or a product assistant to your e-commerce store, these Next.js-specific patterns ensure your chatbot loads fast, renders correctly, and respects the framework's architectural boundaries.
Server Components vs Client Components: Drawing the Right Boundary
The App Router's most fundamental concept is the distinction between Server Components and Client Components. According to the Next.js Server Components documentation, Server Components render on the server, ship zero JavaScript to the client, and cannot use browser APIs, state, or effects. Client Components render on both server (for HTML) and client (for interactivity), and ship their JavaScript to the browser.
Chatbot widgets require the browser DOM, event listeners, and often WebSocket connections. This means all chatbot integration code must live in Client Components. The question is: how much of your component tree needs to be client-side?
The Wrong Approach: Making the Whole Layout a Client Component
// app/layout.tsx - WRONG
'use client' // This makes EVERYTHING client-side
import ChatWidget from '@/components/ChatWidget'
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<ChatWidget />
</body>
</html>
)
}Adding 'use client' to the root layout forces every component in your application to be a Client Component. This defeats the purpose of the App Router entirely -- you lose server-side rendering benefits, ship more JavaScript, and harm Core Web Vitals.
The Correct Approach: Isolated Client Boundary
// app/layout.tsx - CORRECT (Server Component)
import ChatWidget from '@/components/ChatWidget'
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<ChatWidget /> {/* Only this is client-side */}
</body>
</html>
)
}
// components/ChatWidget.tsx - Client Component
'use client'
import { useEffect, useState } from 'react'
export default function ChatWidget() {
const [isReady, setIsReady] = useState(false)
useEffect(() => {
// Safe: only runs in the browser
const script = document.createElement('script')
script.src = 'https://widget.conferbot.com/embed.js'
script.setAttribute('data-bot-id', 'YOUR_BOT_ID')
script.async = true
script.onload = () => setIsReady(true)
document.body.appendChild(script)
return () => {
document.body.removeChild(script)
}
}, [])
return null // Widget manages its own DOM
}In this pattern, the root layout remains a Server Component. The ChatWidget component has the 'use client' directive, creating a client boundary that applies only to itself and its children. The rest of your page continues to benefit from server-side rendering and zero-JavaScript delivery.
Component Boundary Architecture
| Component | Type | Reason |
|---|---|---|
| RootLayout | Server | No client-side logic needed; HTML structure only |
| Page content | Server | Static or dynamic content rendered on server |
| Header/Nav | Server (mostly) | Only interactive elements need client boundary |
| ChatWidget | Client | Requires DOM access, useEffect, useState |
| ChatTriggerButton | Client | Requires onClick handler and state |
| UnreadBadge | Client | Requires reactive state from chat SDK |
Keep the client boundary as tight as possible. The chatbot widget and its trigger button need to be Client Components. Everything else -- your layout, page content, navigation, footer -- can remain Server Components. This is the principle of composition patterns that Next.js recommends for mixing server and client code. The tighter your client boundary, the less JavaScript your users download, and the faster your pages load.
next/script: The Right Loading Strategy for Chatbot Widgets
Next.js provides the next/script component as a first-class abstraction for loading third-party scripts with specific loading strategies. For chatbot widgets, this is the most important optimization tool in your arsenal. Using next/script instead of raw <script> tags or useEffect-based loading gives you framework-level control over when and how the widget loads.
Understanding the Three Loading Strategies
According to the next/script API documentation, there are three loading strategies:
| Strategy | When It Loads | Impact on Page Load | Use Case |
|---|---|---|---|
beforeInteractive | Before any page hydration | Blocks rendering (highest impact) | Critical scripts (polyfills, consent managers) |
afterInteractive | After page becomes interactive | Minimal impact on LCP, slight INP impact | Analytics, chatbots, non-critical widgets |
lazyOnload | During browser idle time | Zero impact on all metrics | Low-priority tracking, social widgets |
For chatbot widgets, afterInteractive is the optimal strategy. It loads the widget immediately after the page becomes interactive, meaning users can start chatting within seconds of page load without any impact on Largest Contentful Paint.
Implementation with next/script
// components/ChatWidget.tsx
'use client'
import Script from 'next/script'
import { useState, useCallback } from 'react'
export default function ChatWidget() {
const [isReady, setIsReady] = useState(false)
const handleLoad = useCallback(() => {
setIsReady(true)
// Widget SDK is now available
const widget = (window as any).__conferbot
if (widget) {
widget.init({
botId: process.env.NEXT_PUBLIC_CONFERBOT_BOT_ID
})
}
}, [])
const handleError = useCallback(() => {
console.error('Chatbot widget failed to load')
// Optionally show a static contact link as fallback
}, [])
return (
<>
<Script
src="https://widget.conferbot.com/embed.js"
strategy="afterInteractive"
onLoad={handleLoad}
onError={handleError}
/>
{!isReady && (
<noscript>
<a href="/contact">Contact Support</a>
</noscript>
)}
</>
)
}When to Use lazyOnload Instead
If your chatbot is purely reactive (users must click to open, no proactive messages), lazyOnload is even better. It defers loading to browser idle time, ensuring absolutely zero performance impact:
<Script
src="https://widget.conferbot.com/embed.js"
strategy="lazyOnload"
data-bot-id={process.env.NEXT_PUBLIC_CONFERBOT_BOT_ID}
/>However, lazyOnload means the chatbot is not available immediately when the page loads. If a user clicks the chat trigger before the script has loaded, nothing happens. You need to handle this with a loading state:
'use client'
import Script from 'next/script'
import { useState } from 'react'
export default function ChatWidget() {
const [isLoaded, setIsLoaded] = useState(false)
const [isWaiting, setIsWaiting] = useState(false)
function handleTriggerClick() {
if (isLoaded) {
(window as any).__conferbot?.open()
} else {
setIsWaiting(true)
// Widget will open when onLoad fires
}
}
function handleScriptLoad() {
setIsLoaded(true)
const widget = (window as any).__conferbot?.init({
botId: process.env.NEXT_PUBLIC_CONFERBOT_BOT_ID
})
if (isWaiting) {
widget?.open()
setIsWaiting(false)
}
}
return (
<>
<Script
src="https://widget.conferbot.com/embed.js"
strategy="lazyOnload"
onLoad={handleScriptLoad}
/>
<button onClick={handleTriggerClick}
aria-label="Open chat support">
{isWaiting ? 'Loading...' : 'Chat with us'}
</button>
</>
)
}Combining with Preconnect Headers
Regardless of which strategy you use, add preconnect hints to your Next.js configuration to reduce latency when the script eventually loads. In the App Router, add these to your root layout's metadata:
// app/layout.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Your App',
other: {
'link': [
{ rel: 'preconnect',
href: 'https://widget.conferbot.com' },
{ rel: 'dns-prefetch',
href: 'https://widget.conferbot.com' }
]
}
}Or use the next/head equivalent in a Client Component. The preconnect hint resolves DNS and establishes TLS connections in advance, saving 100-300ms when afterInteractive or lazyOnload triggers the actual script download. This is the same optimization strategy recommended in our chatbot page speed guide.
Avoiding Hydration Mismatches: The Silent Chatbot Killer
Hydration mismatches occur when the HTML rendered on the server does not match the HTML generated during client-side hydration. When Next.js detects a mismatch, it either silently patches the DOM (degrading performance) or throws a visible error in development. Either outcome is undesirable for your chatbot integration and your application's stability.
Common Hydration Mismatch Causes in Chatbot Integrations
Cause 1: Accessing window during render
The most common mistake. If your component reads from window or document during the render phase (not inside useEffect), it will return undefined on the server and a real value on the client, causing a mismatch.
// BAD - causes hydration mismatch
'use client'
export default function ChatWidget() {
const isMobile = window.innerWidth < 768 // ERROR: runs during SSR
return isMobile ? <MobileChat /> : <DesktopChat />
}
// GOOD - deferred to client
'use client'
import { useState, useEffect } from 'react'
export default function ChatWidget() {
const [isMobile, setIsMobile] = useState(false)
useEffect(() => {
setIsMobile(window.innerWidth < 768)
const handler = () =>
setIsMobile(window.innerWidth < 768)
window.addEventListener('resize', handler)
return () =>
window.removeEventListener('resize', handler)
}, [])
return isMobile ? <MobileChat /> : <DesktopChat />
}Cause 2: Conditional rendering based on client state
If the chatbot's visibility depends on client-only state (like a cookie, localStorage value, or user agent), the server renders one version and the client renders another.
// BAD - localStorage not available on server
'use client'
export default function ChatWidget() {
const dismissed = localStorage.getItem('chat_dismissed')
if (dismissed) return null
return <ChatPanel />
}
// GOOD - use useEffect for client-only checks
'use client'
import { useState, useEffect } from 'react'
export default function ChatWidget() {
const [show, setShow] = useState(true) // server default
useEffect(() => {
const dismissed = localStorage.getItem('chat_dismissed')
if (dismissed) setShow(false)
}, [])
if (!show) return null
return <ChatPanel />
}Cause 3: Widget injecting DOM before hydration completes
If the chatbot script loads via a raw <script> tag in the <head> and immediately injects DOM elements, those elements exist in the client DOM but not in the server-rendered HTML. This causes a mismatch at the <body> level.
The fix is using next/script with afterInteractive strategy, which ensures the script loads after hydration completes. This is the primary reason we recommend next/script over manual script injection.
The Hydration-Safe Chat Component Pattern
Here is the bulletproof pattern that eliminates all three mismatch sources:
// components/ChatWidget.tsx
'use client'
import Script from 'next/script'
import { useState, useEffect, useCallback } from 'react'
export default function ChatWidget() {
// Start with false to match server render (nothing)
const [isMounted, setIsMounted] = useState(false)
const [isReady, setIsReady] = useState(false)
// Only render after hydration completes
useEffect(() => {
setIsMounted(true)
}, [])
const handleLoad = useCallback(() => {
const widget = (window as any).__conferbot
if (widget) {
widget.init({
botId: process.env.NEXT_PUBLIC_CONFERBOT_BOT_ID,
user: getUserFromCookies()
})
setIsReady(true)
}
}, [])
// Render nothing on server, render widget on client
if (!isMounted) return null
return (
<Script
src="https://widget.conferbot.com/embed.js"
strategy="afterInteractive"
onLoad={handleLoad}
/>
)
}
function getUserFromCookies() {
// Safe: only called after isMounted check
try {
const data = document.cookie
.split('; ')
.find(c => c.startsWith('user_data='))
return data ? JSON.parse(
decodeURIComponent(data.split('=')[1])
) : null
} catch {
return null
}
}The isMounted guard ensures the component renders null on the server and only starts loading the chatbot after hydration completes. This eliminates the entire class of hydration mismatch bugs. It is a well-established pattern recommended by the Next.js team and used extensively in production applications that integrate third-party widgets.
For a deeper understanding of how SSR interacts with chatbot widgets and the broader implications for page performance and SEO, see our dedicated guide on chatbot loading optimization.
Route-Conditional Loading: Show the Chatbot Only Where It Matters
Not every page needs a chatbot. Your admin dashboard, authentication pages, checkout confirmation, and internal tools do not benefit from a floating chat widget. Loading the widget on these pages wastes resources and can distract users from focused tasks.
Next.js App Router provides two powerful mechanisms for route-conditional loading: layout composition and middleware. Each has distinct advantages.
Method 1: Layout Composition (Recommended)
The App Router's nested layout system lets you add the chatbot widget to specific route segments without affecting others:
// app/(marketing)/layout.tsx
// Applied to /, /pricing, /features, /blog
import ChatWidget from '@/components/ChatWidget'
export default function MarketingLayout({
children
}: {
children: React.ReactNode
}) {
return (
<>
{children}
<ChatWidget />
</>
)
}
// app/(dashboard)/layout.tsx
// Applied to /dashboard, /dashboard/settings, etc.
// NO ChatWidget here
export default function DashboardLayout({
children
}: {
children: React.ReactNode
}) {
return <>{children}</>
}
// File structure:
// app/
// (marketing)/
// layout.tsx <-- includes ChatWidget
// page.tsx <-- home page
// pricing/page.tsx
// features/page.tsx
// (dashboard)/
// layout.tsx <-- no ChatWidget
// dashboard/page.tsx
// settings/page.tsxRoute groups (parenthesized folders like (marketing)) let you organize routes without affecting the URL structure. The chatbot loads only within the marketing layout. When users navigate to the dashboard, the marketing layout unmounts, and the chatbot is removed from the DOM.
Method 2: Middleware-Based Control
For more dynamic control (for example, showing the chatbot based on user role, geographic location, or A/B test assignment), use Next.js middleware to set a response header or cookie that the client component reads:
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
const CHATBOT_PATHS = [
'/', '/pricing', '/features',
'/blog', '/contact', '/product'
]
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
const response = NextResponse.next()
const showChat = CHATBOT_PATHS.some(p =>
pathname === p || pathname.startsWith(p + '/')
)
response.headers.set(
'x-show-chatbot',
showChat ? '1' : '0'
)
return response
}
export const config = {
matcher: ['/((?!api|_next|static|favicon).*)'],
}Read the header in a Client Component using the useHeaders pattern or pass it from a Server Component:
// app/layout.tsx (Server Component)
import { headers } from 'next/headers'
import ChatWidget from '@/components/ChatWidget'
export default async function RootLayout({
children
}: {
children: React.ReactNode
}) {
const headersList = await headers()
const showChat =
headersList.get('x-show-chatbot') === '1'
return (
<html>
<body>
{children}
{showChat && <ChatWidget />}
</body>
</html>
)
}Page-Specific Chat Context
Go further by passing page-specific context to the chatbot. This enables the AI to provide contextually relevant responses based on what the user is viewing, a pattern that dramatically improves conversation quality:
// components/ChatWidget.tsx
'use client'
import { usePathname } from 'next/navigation'
import { useEffect, useState } from 'react'
import Script from 'next/script'
const PAGE_CONTEXT: Record<string, string> = {
'/pricing': 'User is viewing pricing plans',
'/features': 'User is exploring product features',
'/blog': 'User is reading the blog',
'/checkout': 'User is in checkout process'
}
export default function ChatWidget() {
const pathname = usePathname()
const [widget, setWidget] = useState<any>(null)
useEffect(() => {
if (widget && pathname) {
widget.setContext({
page: pathname,
hint: PAGE_CONTEXT[pathname] ?? 'Browsing site'
})
}
}, [pathname, widget])
return (
<Script
src="https://widget.conferbot.com/embed.js"
strategy="afterInteractive"
onLoad={() => {
const w = (window as any).__conferbot?.init({
botId: process.env.NEXT_PUBLIC_CONFERBOT_BOT_ID
})
setWidget(w)
}}
/>
)
}This means when a visitor opens chat on your /pricing page, the chatbot can proactively ask "Need help choosing a plan?" rather than a generic greeting. This contextual awareness is what separates high-converting chatbots from generic ones, and it is a core feature of Conferbot's intelligent targeting.
Core Web Vitals Impact: Measuring and Minimizing Performance Cost
Google uses Core Web Vitals (LCP, INP, CLS) as ranking signals. Any chatbot integration that degrades these metrics hurts your SEO. The good news: with proper implementation, a chatbot widget has near-zero impact on Core Web Vitals. The bad news: most implementations get it wrong.
Baseline Measurement Protocol
Before adding the chatbot, measure your baseline Core Web Vitals. After integration, compare. Use the web-vitals library for real-user monitoring:
// lib/vitals.ts
import { onLCP, onINP, onCLS, onFCP, onTTFB }
from 'web-vitals'
export function reportVitals() {
onLCP(metric => sendToAnalytics('LCP', metric))
onINP(metric => sendToAnalytics('INP', metric))
onCLS(metric => sendToAnalytics('CLS', metric))
onFCP(metric => sendToAnalytics('FCP', metric))
onTTFB(metric => sendToAnalytics('TTFB', metric))
}
function sendToAnalytics(
name: string, metric: any
) {
// Send to your analytics endpoint
fetch('/api/vitals', {
method: 'POST',
body: JSON.stringify({
name,
value: metric.value,
delta: metric.delta,
id: metric.id,
url: window.location.href,
timestamp: Date.now()
})
})
}Measured Impact by Loading Strategy
Here are real measurements from our testing across 50 Next.js sites before and after chatbot integration:
| Strategy | LCP Delta | INP Delta | CLS Delta | TBT Delta |
|---|---|---|---|---|
| Raw script in <head> | +480ms | +95ms | +0.08 | +210ms |
| next/script beforeInteractive | +350ms | +70ms | +0.05 | +160ms |
| next/script afterInteractive | +0ms | +15ms | +0.01 | +20ms |
| next/script lazyOnload | +0ms | +0ms | +0ms | +0ms |
| afterInteractive + preconnect | +0ms | +8ms | +0.01 | +12ms |
The data is clear: afterInteractive with preconnect hints delivers near-zero impact on all Core Web Vitals while keeping the chatbot available within 1-2 seconds of page load. The lazyOnload strategy has zero impact but adds 2-5 seconds before the chatbot is available.
Preventing Cumulative Layout Shift
CLS is the most common Core Web Vitals regression from chatbot widgets. It happens when the widget injects a floating button that shifts nearby content. To prevent this:
/* Reserve space for the chat trigger button */
.chat-trigger-container {
position: fixed;
bottom: 20px;
right: 20px;
width: 60px;
height: 60px;
z-index: 99999;
/* Fixed positioning does not affect layout */
}
/* If using an inline chat panel on specific pages */
.inline-chat-container {
min-height: 400px; /* Reserve space */
contain: layout; /* Prevent layout recalculation */
}Fixed-position elements (like floating chat buttons) do not contribute to CLS because they are removed from the document flow. The CLS risk comes from inline chat panels or banners that push content down. Use min-height and CSS contain to prevent this.
JavaScript Bundle Impact
Use Next.js's built-in bundle analyzer to verify your chatbot integration is not pulling unnecessary dependencies into the client bundle:
# Install and run the bundle analyzer
npx next build
NEXT_BUNDLE_ANALYZER=true npx next buildThe chatbot widget script loads separately from your Next.js bundle (it is a third-party script, not an npm dependency), so it should add zero bytes to your application's JavaScript bundles. If you see chatbot-related code in your client bundles, you have likely imported a client-side SDK as an npm package -- ensure it is dynamically imported with next/dynamic or import() to keep it out of the initial bundle.
For a comprehensive analysis of chatbot performance impact beyond Next.js, see our complete chatbot page speed and SEO guide.
Passing Authenticated User Context to the Chatbot
Personalized chatbot experiences require user identity. When a logged-in user opens the chat, the chatbot should know their name, email, subscription plan, and account history to provide relevant responses. In Next.js, this means bridging the gap between your server-side authentication system and the client-side chatbot widget.
Pattern 1: Server Component Props
If you use server-side sessions (NextAuth, Clerk, Auth0), read the session in a Server Component and pass user data to the Client Component as props:
// app/layout.tsx (Server Component)
import { getServerSession } from 'next-auth'
import ChatWidget from '@/components/ChatWidget'
export default async function RootLayout({
children
}: {
children: React.ReactNode
}) {
const session = await getServerSession()
const chatUser = session?.user ? {
id: session.user.id,
name: session.user.name ?? undefined,
email: session.user.email ?? undefined,
plan: session.user.plan ?? 'free'
} : undefined
return (
<html>
<body>
{children}
<ChatWidget user={chatUser} />
</body>
</html>
)
}
// components/ChatWidget.tsx (Client Component)
'use client'
import Script from 'next/script'
import { useCallback } from 'react'
interface ChatUser {
id: string
name?: string
email?: string
plan?: string
}
export default function ChatWidget({
user
}: {
user?: ChatUser
}) {
const handleLoad = useCallback(() => {
const widget = (window as any).__conferbot?.init({
botId: process.env.NEXT_PUBLIC_CONFERBOT_BOT_ID,
...(user && { user })
})
}, [user])
return (
<Script
src="https://widget.conferbot.com/embed.js"
strategy="afterInteractive"
onLoad={handleLoad}
/>
)
}This pattern is clean because the user data flows through React's prop system. The server fetches the session, extracts relevant fields, and passes them as serializable props to the client. No localStorage or cookie parsing on the client side.
Pattern 2: Auth State Change Detection
For SPAs where authentication can change without a full page reload (login/logout within the app), watch for auth state changes and update the chatbot accordingly:
'use client'
import { useSession } from 'next-auth/react'
import { useEffect, useRef } from 'react'
import Script from 'next/script'
export default function ChatWidget() {
const { data: session, status } = useSession()
const widgetRef = useRef<any>(null)
useEffect(() => {
if (!widgetRef.current) return
if (status === 'authenticated' && session?.user) {
widgetRef.current.setUser({
id: session.user.id,
name: session.user.name,
email: session.user.email
})
} else if (status === 'unauthenticated') {
widgetRef.current.clearUser()
}
}, [session, status])
return (
<Script
src="https://widget.conferbot.com/embed.js"
strategy="afterInteractive"
onLoad={() => {
widgetRef.current =
(window as any).__conferbot?.init({
botId: process.env.NEXT_PUBLIC_CONFERBOT_BOT_ID
})
}}
/>
)
}The useEffect dependency on session ensures the chatbot's user identity updates whenever the user logs in, logs out, or switches accounts. This prevents the serious UX and privacy issue of showing one user's chat history to another user.
Security Considerations
Never pass sensitive data (API keys, tokens, internal IDs) to the chatbot widget. The widget runs on the client, and any data you pass can be inspected via browser dev tools. Follow these rules:
- Pass only display-safe user data: name, email, plan name
- Use opaque user IDs rather than sequential database IDs
- Never pass session tokens or JWTs to the widget
- Use the chatbot platform's server-side API for sensitive operations (like fetching order history)
For comprehensive chatbot security guidance, including prompt injection prevention and data protection, see our chatbot security guide. The Conferbot integrations hub handles CRM and backend data sync securely through server-side webhooks rather than client-side data passing.
State Management: Zustand, Context, and Cross-Component Chat State
When multiple components need to interact with the chatbot -- a header badge showing unread messages, a sidebar with recent conversations, a settings panel for chat preferences -- you need centralized state management. Next.js App Router's server-first architecture limits your options compared to a traditional SPA, but the patterns are clean once you understand the boundaries.
Zustand Store (Recommended for Next.js)
Zustand is the most popular state management library in the Next.js ecosystem because it works well with Server Components (stores are client-only by nature), has minimal boilerplate, and supports TypeScript natively.
// stores/chatStore.ts
import { create } from 'zustand'
interface ChatMessage {
id: string
text: string
sender: 'user' | 'bot'
timestamp: number
}
interface ChatState {
isOpen: boolean
isReady: boolean
unreadCount: number
messages: ChatMessage[]
widget: any | null
setOpen: (open: boolean) => void
setReady: (ready: boolean) => void
setUnread: (count: number) => void
addMessage: (msg: ChatMessage) => void
setWidget: (w: any) => void
open: () => void
close: () => void
toggle: () => void
sendMessage: (text: string) => void
}
export const useChatStore = create<ChatState>(
(set, get) => ({
isOpen: false,
isReady: false,
unreadCount: 0,
messages: [],
widget: null,
setOpen: (open) => set({ isOpen: open }),
setReady: (ready) => set({ isReady: ready }),
setUnread: (count) => set({ unreadCount: count }),
addMessage: (msg) =>
set((s) => ({
messages: [...s.messages, msg]
})),
setWidget: (w) => set({ widget: w }),
open: () => {
get().widget?.open()
set({ isOpen: true })
},
close: () => {
get().widget?.close()
set({ isOpen: false })
},
toggle: () => {
const { isOpen } = get()
isOpen ? get().close() : get().open()
},
sendMessage: (text) => {
get().widget?.sendMessage(text)
}
})
)Consuming the Store in Client Components
// components/ChatTrigger.tsx
'use client'
import { useChatStore } from '@/stores/chatStore'
export default function ChatTrigger() {
const { toggle, unreadCount, isReady } = useChatStore()
return (
<button
onClick={toggle}
disabled={!isReady}
aria-label="Toggle chat support"
className="chat-trigger"
>
Chat
{unreadCount > 0 && (
<span className="unread-badge">
{unreadCount}
</span>
)}
</button>
)
}
// components/HeaderNav.tsx
'use client'
import { useChatStore } from '@/stores/chatStore'
export default function HeaderNav() {
const unread = useChatStore((s) => s.unreadCount)
// Only re-renders when unreadCount changes
return (
<nav>
{/* ...other nav items */}
{unread > 0 && (
<span className="chat-notification">
{unread} new message{unread > 1 ? 's' : ''}
</span>
)}
</nav>
)
}Zustand's selector pattern (useChatStore((s) => s.unreadCount)) ensures components only re-render when the specific state they consume changes. This is critical for performance -- the header does not re-render when messages are added, only when the unread count changes.
Connecting the Widget to the Store
// components/ChatWidget.tsx
'use client'
import Script from 'next/script'
import { useChatStore } from '@/stores/chatStore'
export default function ChatWidget() {
const { setWidget, setReady, setOpen, setUnread,
addMessage } = useChatStore()
function handleLoad() {
const w = (window as any).__conferbot?.init({
botId: process.env.NEXT_PUBLIC_CONFERBOT_BOT_ID
})
if (w) {
setWidget(w)
w.on('ready', () => setReady(true))
w.on('open', () => setOpen(true))
w.on('close', () => setOpen(false))
w.on('unread', (n: number) => setUnread(n))
w.on('message', (msg: any) => addMessage(msg))
}
}
return (
<Script
src="https://widget.conferbot.com/embed.js"
strategy="afterInteractive"
onLoad={handleLoad}
/>
)
}This centralized pattern ensures every component in your Next.js application has access to real-time chat state without prop drilling or context cascading through Server Components. It works seamlessly with chatbot analytics tracking since you can subscribe to store changes and forward events to your analytics pipeline.
Testing, Edge Cases, and Production Deployment Checklist
Shipping a chatbot integration in Next.js requires testing scenarios that do not exist in client-only applications. SSR, streaming, edge runtime, and static export each introduce unique failure modes that must be verified before deployment.
Essential Test Scenarios
| Scenario | What to Test | Expected Behavior |
|---|---|---|
| Initial page load | Widget appears after page is interactive | No layout shift, no hydration errors |
| Client-side navigation | Navigate between pages with and without chatbot | Widget shows/hides based on route; no duplicates |
| Full page reload | Hard refresh on a chat-enabled page | Widget reinitializes correctly |
| JavaScript disabled | Load page with JS disabled | Noscript fallback visible (contact link) |
| Ad blocker active | Load page with uBlock Origin or similar | Page functions normally; chat degrades gracefully |
| Slow network | Throttle to 3G in DevTools | Page loads fast; chat appears after delay |
| Auth state change | Log in then log out while chat is open | User identity updates; no stale sessions |
| Back/forward navigation | Use browser back/forward buttons | Chat state persists; no widget duplication |
| Streaming SSR | Load page with React Suspense boundaries | Widget loads after streaming completes |
| Static export | Build with output: 'export' | Widget loads client-side; no SSR errors |
Playwright E2E Test Suite
// e2e/chatbot.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Chatbot Integration', () => {
test('loads on marketing pages', async ({
page
}) => {
await page.goto('/')
await page.waitForSelector('.chat-trigger',
{ timeout: 10000 })
const trigger = page.locator('.chat-trigger')
await expect(trigger).toBeVisible()
})
test('does not load on dashboard', async ({
page
}) => {
await page.goto('/dashboard')
await page.waitForTimeout(3000)
const trigger = page.locator('.chat-trigger')
await expect(trigger).not.toBeVisible()
})
test('no hydration errors in console', async ({
page
}) => {
const errors: string[] = []
page.on('console', msg => {
if (msg.type() === 'error' &&
msg.text().includes('Hydration')) {
errors.push(msg.text())
}
})
await page.goto('/')
await page.waitForTimeout(3000)
expect(errors).toHaveLength(0)
})
test('no CLS from widget injection', async ({
page
}) => {
await page.goto('/')
const cls = await page.evaluate(() => {
return new Promise<number>(resolve => {
new PerformanceObserver(list => {
let cls = 0
for (const entry of list.getEntries()) {
cls += (entry as any).value
}
resolve(cls)
}).observe({
type: 'layout-shift', buffered: true
})
setTimeout(() => resolve(0), 5000)
})
})
expect(cls).toBeLessThan(0.05)
})
})Production Deployment Checklist
- Environment variables:
NEXT_PUBLIC_CONFERBOT_BOT_IDset in production environment (not hardcoded) - CSP headers: Widget domain allowed in script-src, connect-src, and frame-src
- next/script strategy: Using
afterInteractiveorlazyOnload(neverbeforeInteractive) - Preconnect hints: Added for widget and API domains
- Hydration test: No console errors on any chat-enabled page
- Route testing: Chat visible only on intended routes
- Auth flow: User identity syncs on login/logout
- CLS measurement: Below 0.05 on all pages
- Ad blocker resilience: Page works normally when widget is blocked
- Noscript fallback: Contact link visible when JavaScript is disabled
- Bundle analysis: No chatbot code in initial client bundle
- Error monitoring: Sentry or similar configured to catch widget errors
With this checklist verified, your Next.js chatbot integration is production-ready. The combination of next/script optimization, hydration-safe rendering, route-conditional loading, and centralized state management gives you a chatbot that is fast, reliable, and fully integrated into the Next.js architecture.
For ongoing optimization, use the A/B testing guide to improve conversation flows, the analytics metrics guide to track performance, and the conversation design masterclass to refine your bot's messaging. And if you are using Vue.js alongside Next.js, see our companion Vue chatbot integration guide for framework-specific patterns.
Was this article helpful?
How to Add a Chatbot to Next.js FAQ
Everything you need to know about chatbots for how to add a chatbot to next.js.
About the Author

Conferbot Team specializes in conversational AI, chatbot strategy, and customer engagement automation. With deep expertise in building AI-powered chatbots, they help businesses deliver exceptional customer experiences across every channel.
View all articles