A step-by-step guide to implementing a floating AI chatbot widget powered by an n8n webhook in a Next.js 15 App Router project with shadcn/ui.
Overview
The widget is built from 6 files following a clear separation of concerns:
src/
├── hooks/
│ └── use-n8n-chat.ts ← all state, localStorage, webhook logic
├── components/shared/chat/
│ ├── chat-message.tsx ← message bubble with Markdown rendering
│ ├── chat-loading-indicator.tsx ← animated "typing…" indicator
│ ├── custom-chat-widget.tsx ← full floating UI (launcher + panel)
│ └── client-chat-wrapper.tsx ← SSR-safe dynamic import wrapper
└── app/(frontend)/layout.tsx ← one-line mount point
Prerequisites
Your project must already have:
Package Why next ≥ 14 (App Router)next/dynamic with ssr: falsereact ≥ 18hooks lucide-reacticons (Bot, MessageCircle, Send…) react-markdown ≥ 9Markdown rendering in bot messages clsx + tailwind-merge (or a cn() helper)class merging shadcn/ui: Button, Textarea, ScrollArea, Card, DropdownMenu, AlertDialog widget UI primitives
Install the one missing package
remark-gfm is needed to parse GitHub Flavored Markdown (tables, task lists, strikethrough).
It is not bundled with react-markdown:
Step 1 — The Chat Hook
File: src/hooks/use-n8n-chat.ts
This is the brain of the widget. It manages:
messages[] state
isLoading / isOpen booleans
sessionId (UUID, persisted across page loads)
localStorage persistence (debounced at 300 ms)
sendMessage() — POSTs to the n8n webhook and parses the response
toggleChat() / clearSession()
request cancellation on unmount, so a closed widget can't set state on a dead component
'use client' ;
import { useState, useEffect, useCallback, useRef } from 'react' ;
export type Message = {
id : string ;
role : 'user' | 'bot' ;
content : string ;
createdAt : Date ;
};
const WEBHOOK_URL =
process.env .NEXT_PUBLIC_N8N_WEBHOOK_URL ?? 'https://your-n8n-instance.com/webhook/YOUR-UUID/chat' ;
interface UseN8 nChatProps {
webhookUrl ?: string ;
initialMessages ?: string [];
}
const DEFAULT_INITIAL_MESSAGES = ['Hello! 👋' , 'How can I help you today?' ];
const LS_SESSION_ID = 'myapp_chat_session_id' ;
const LS_SESSION_CREATED = 'myapp_chat_session_created_at' ;
const messagesKey = (sid : string ) => `myapp_chat_messages_${sid} ` ;
function generateUUID ( ): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID ) return crypto.randomUUID ();
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx' .replace (/[xy]/g , (c ) => {
const r = (Math .random () * 16 ) | 0 ;
return (c === 'x' ? r : (r & 0x3 ) | 0x8 ).toString (16 );
});
}
export function useN8nChat ({
webhookUrl = WEBHOOK_URL,
initialMessages = DEFAULT_INITIAL_MESSAGES,
}: UseN8 nChatProps = {} ) {
const [messages, setMessages] = useState<Message []>([]);
const [isLoading, setIsLoading] = useState (false );
const [isOpen, setIsOpen] = useState (false );
const [sessionId, setSessionId] = useState<string >('' );
const abortRef = useRef<AbortController | null >(null );
useEffect (() => {
let sid = localStorage .getItem (LS_SESSION_ID );
if (!sid) {
sid = generateUUID ();
localStorage .setItem (LS_SESSION_ID , sid);
localStorage .setItem (LS_SESSION_CREATED , new Date ().toISOString ());
}
setSessionId (sid);
const stored = localStorage .getItem (messagesKey (sid));
if (stored) {
try {
const parsed : Message [] = JSON .parse (stored).map ((m : Message ) => ({
...m, createdAt : new Date (m.createdAt ),
}));
setMessages (parsed);
return ;
} catch (e) { console .error ('Failed to parse stored messages' , e); }
}
if (initialMessages.length > 0 ) {
setMessages (initialMessages.map ((msg, idx ) => ({
id : `init-${idx} ` , role : 'bot' , content : msg, createdAt : new Date (),
})));
}
}, []);
useEffect (() => {
if (!sessionId || messages.length === 0 ) return ;
const t = setTimeout (
() => localStorage .setItem (messagesKey (sessionId), JSON .stringify (messages)),
300 ,
);
return () => clearTimeout (t);
}, [messages, sessionId]);
useEffect (() => {
return () => abortRef.current ?.abort ();
}, []);
const sendMessage = useCallback (async (content : string ) => {
if (!content.trim () || !webhookUrl) return ;
setMessages (prev => [...prev, { id : generateUUID (), role : 'user' , content, createdAt : new Date () }]);
setIsLoading (true );
const controller = new AbortController ();
abortRef.current = controller;
try {
const res = await fetch (webhookUrl, {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON .stringify ({ action : 'sendMessage' , sessionId, chatInput : content, node_env : process.env .NODE_ENV }),
signal : controller.signal ,
});
if (!res.ok ) throw new Error (`HTTP ${res.status} ` );
const data : unknown = await res.json ();
let text = 'Sorry, I did not understand that.' ;
if (Array .isArray (data)) {
text = data.map (i => (typeof i === 'object' && i !== null )
? (i as Record <string ,string >).output || (i as Record <string ,string >).text || JSON .stringify (i)
: String (i)).join ('\n' );
} else if (data !== null && typeof data === 'object' ) {
const o = data as Record <string ,string >;
text = o.output || o.text || o.message || JSON .stringify (data);
} else if (typeof data === 'string' ) {
text = data;
}
setMessages (prev => [...prev, { id : generateUUID (), role : 'bot' , content : text, createdAt : new Date () }]);
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError' ) return ;
console .error ('Chat error:' , err);
setMessages (prev => [...prev, {
id : generateUUID (), role : 'bot' ,
content : "Sorry, I'm having trouble connecting. Please try again later." ,
createdAt : new Date (),
}]);
} finally {
setIsLoading (false );
}
}, [webhookUrl, sessionId]);
const toggleChat = useCallback (() => setIsOpen (p => !p), []);
const clearSession = useCallback (() => {
if (sessionId) {
localStorage .removeItem (LS_SESSION_ID );
localStorage .removeItem (messagesKey (sessionId));
localStorage .removeItem (LS_SESSION_CREATED );
}
const newId = generateUUID ();
setSessionId (newId);
localStorage .setItem (LS_SESSION_ID , newId);
localStorage .setItem (LS_SESSION_CREATED , new Date ().toISOString ());
setMessages (initialMessages.map ((msg, idx ) => ({
id : `init-${idx} ` , role : 'bot' , content : msg, createdAt : new Date (),
})));
}, [sessionId, initialMessages]);
return { messages, isLoading, isOpen, sendMessage, toggleChat, setIsOpen, clearSession };
}
Step 2 — The Message Bubble
File: src/components/shared/chat/chat-message.tsx
Renders a single user or bot message. Bot messages use react-markdown + remark-gfm
so the bot can return tables, lists, code blocks, links — all styled with explicit Tailwind
classes (no @tailwindcss/typography needed).
'use client' ;
import { useMemo, type ComponentProps } from 'react' ;
import ReactMarkdown , { type Components } from 'react-markdown' ;
import remarkGfm from 'remark-gfm' ;
import { cn } from '@/lib/utils' ;
import { type Message } from '@/hooks/use-n8n-chat' ;
interface ChatMessageProps { message : Message ; }
const markdownComponents : Components = {
p : ({ children } ) => <p className ="mb-2 last:mb-0 leading-relaxed" > {children}</p > ,
h1 : ({ children } ) => <h1 className ="text-base font-bold mb-2 mt-3 first:mt-0" > {children}</h1 > ,
h2 : ({ children } ) => <h2 className ="text-sm font-bold mb-1.5 mt-2.5 first:mt-0" > {children}</h2 > ,
h3 : ({ children } ) => <h3 className ="text-sm font-semibold mb-1 mt-2 first:mt-0" > {children}</h3 > ,
ul : ({ children } ) => <ul className ="mb-2 pl-4 space-y-0.5 list-disc" > {children}</ul > ,
ol : ({ children } ) => <ol className ="mb-2 pl-4 space-y-0.5 list-decimal" > {children}</ol > ,
li : ({ children } ) => <li className ="leading-relaxed" > {children}</li > ,
strong : ({ children } ) => <strong className ="font-semibold" > {children}</strong > ,
em : ({ children } ) => <em className ="italic" > {children}</em > ,
hr : () => <hr className ="border-border my-2" /> ,
blockquote : ({ children } ) => (
<blockquote className ="border-l-2 border-primary/40 pl-3 italic text-muted-foreground mb-2" >
{children}
</blockquote >
),
code : ({ children, className } ) => {
const isBlock = className?.includes ('language-' );
return isBlock
? <code className ="block bg-black/10 rounded px-2 py-1 text-xs font-mono overflow-x-auto" > {children}</code >
: <code className ="bg-black/10 rounded px-1 py-0.5 text-xs font-mono" > {children}</code > ;
},
pre : ({ children } ) => (
<pre className ="bg-black/10 rounded-lg p-3 text-xs font-mono overflow-x-auto mb-2" > {children}</pre >
),
a : ({ href, children } ) => (
<a href ={href} target ="_blank" rel ="noopener noreferrer"
className ="text-primary underline underline-offset-2 hover:opacity-80" >
{children}
</a >
),
table : ({ children } ) => (
<div className ="overflow-x-auto mb-2 rounded-lg border border-border" >
<table className ="w-full text-xs border-collapse" > {children}</table >
</div >
),
thead : ({ children } ) => <thead className ="bg-primary/10" > {children}</thead > ,
tbody : ({ children } ) => <tbody className ="divide-y divide-border" > {children}</tbody > ,
tr : ({ children } ) => <tr className ="divide-x divide-border" > {children}</tr > ,
th : ({ children, ...props }: ComponentProps <'th' > ) => (
<th className ="px-3 py-1.5 text-left font-semibold text-foreground" {...props }> {children}</th >
),
td : ({ children, ...props }: ComponentProps <'td' > ) => (
<td className ="px-3 py-1.5 text-foreground/80" {...props }> {children}</td >
),
};
export function ChatMessage ({ message }: ChatMessageProps ) {
const isUser = message.role === 'user' ;
const timestamp = useMemo (
() => message.createdAt .toLocaleTimeString ('en-GB' , { hour : '2-digit' , minute : '2-digit' }),
[message.createdAt ],
);
return (
<div className ={cn( 'flex w-full mb-4 ', isUser ? 'justify-end ' : 'justify-start ')}>
<div className ={cn(
'rounded-lg px-4 py-2 text-sm shadow-sm ',
isUser
? 'max-w- [80 %] bg-primary text-primary-foreground rounded-br-none '
: 'w-full max-w-full bg-muted text-foreground rounded-bl-none border border-border ',
)}>
{isUser ? (
<p className ="break-words whitespace-pre-wrap" > {message.content}</p >
) : (
<div className ="break-words min-w-0" >
<ReactMarkdown remarkPlugins ={[remarkGfm]} components ={markdownComponents} >
{message.content}
</ReactMarkdown >
</div >
)}
<p className ={cn( 'text- [10px ] mt-1 ', isUser ? 'text-primary-foreground /60 text-right ' : 'text-muted-foreground ')}>
{timestamp}
</p >
</div >
</div >
);
}
Component renderers over dangerouslySetInnerHTML. react-markdown with custom
component renderers is XSS-safe by default, which removes the need for a separate
sanitize-html pass.
Explicit classes over prose. The prose class requires @tailwindcss/typography.
The component renderers above produce the same visual result with zero extra dependencies.
Step 3 — The Loading Indicator
File: src/components/shared/chat/chat-loading-indicator.tsx
Animated spinner + rotating contextual text while waiting for the bot. The first message
gets a "startup" sequence; subsequent ones show a random loading message.
The message sets below are generic placeholders. Swap the text and icons for whatever fits
your domain (support tickets, order lookups, booking availability, etc.) — the structure is
what matters, not the specific wording.
'use client' ;
import { useState, useEffect } from 'react' ;
import { Loader2 , Search , HelpCircle , FileText , CheckCircle , Clock ,
Package , DollarSign , MessageSquare , Info , Settings } from 'lucide-react' ;
const STARTUP_MESSAGES = [
{ text : 'Connecting to assistant...' , icon : MessageSquare },
{ text : 'Loading data...' , icon : FileText },
{ text : 'Preparing response...' , icon : CheckCircle },
{ text : 'Just a moment...' , icon : Loader2 },
];
const LOADING_MESSAGES = [
{ text : 'Searching for an answer...' , icon : Search },
{ text : 'Checking the details...' , icon : HelpCircle },
{ text : 'Gathering information...' , icon : Info },
{ text : 'Reviewing your request...' , icon : FileText },
{ text : 'Checking availability...' , icon : Clock },
{ text : 'Looking into that...' , icon : Settings },
{ text : 'Checking our records...' , icon : Package },
{ text : 'Processing your question...' , icon : FileText },
{ text : 'Checking pricing...' , icon : DollarSign },
{ text : 'Composing your answer...' , icon : CheckCircle },
];
interface ChatLoadingIndicatorProps { isFirstMessage ?: boolean ; }
export function ChatLoadingIndicator ({ isFirstMessage = false }: ChatLoadingIndicatorProps ) {
const [currentIndex, setCurrentIndex] = useState (0 );
const messages = isFirstMessage ? STARTUP_MESSAGES : LOADING_MESSAGES ;
useEffect (() => {
setCurrentIndex (isFirstMessage ? 0 : Math .floor (Math .random () * messages.length ));
const interval = setInterval (
() => setCurrentIndex (prev => (prev + 1 ) % messages.length ),
3500 ,
);
return () => clearInterval (interval);
}, [isFirstMessage, messages.length ]);
const CurrentIcon = messages[currentIndex]?.icon ?? Loader2 ;
const currentText = messages[currentIndex]?.text ?? 'Loading...' ;
return (
<div className ="flex w-full mb-4 justify-start" >
<div className ="bg-muted text-foreground rounded-lg rounded-bl-none px-4 py-3 shadow-sm flex items-center gap-3" >
<div className ="relative flex items-center justify-center w-5 h-5 shrink-0" >
<Loader2 className ="absolute h-5 w-5 animate-spin text-muted-foreground/50" />
<CurrentIcon className ="absolute h-3 w-3 text-primary animate-pulse" />
</div >
<span key ={currentIndex}
className ="text-xs text-muted-foreground min-w-[160px] animate-in fade-in slide-in-from-bottom-1 duration-500" >
{currentText}
</span >
</div >
</div >
);
}
Step 4 — The Chat Widget
File: src/components/shared/chat/custom-chat-widget.tsx
The full floating UI. Key features:
Fixed bottom-right launcher with unread badge
Smooth open/close scale animation
Message queue with per-message cancel
Starter question chips (disappear after first user message)
Enter to submit, Shift+Enter for newline, Escape to close the panel
Auto-scroll to newest message
"Start new chat" behind a confirmation dialog
aria-labels on every icon-only button, for screen reader users
'use client' ;
import { useState, useEffect, useRef, useCallback } from 'react' ;
import { Bot , MessageCircle , Minus , MoreVertical , RefreshCcw , Send , X, Loader2 } from 'lucide-react' ;
import { cn } from '@/lib/utils' ;
import { useN8nChat } from '@/hooks/use-n8n-chat' ;
import { ChatMessage } from '@/components/shared/chat/chat-message' ;
import { ChatLoadingIndicator } from '@/components/shared/chat/chat-loading-indicator' ;
import { Button } from '@/components/ui/button' ;
import { Textarea } from '@/components/ui/textarea' ;
import { ScrollArea } from '@/components/ui/scroll-area' ;
import { Card , CardContent , CardFooter , CardHeader } from '@/components/ui/card' ;
import { DropdownMenu , DropdownMenuContent , DropdownMenuItem , DropdownMenuTrigger } from '@/components/ui/dropdown-menu' ;
import { AlertDialog , AlertDialogAction , AlertDialogCancel , AlertDialogContent ,
AlertDialogDescription , AlertDialogFooter , AlertDialogHeader ,
AlertDialogTitle , AlertDialogTrigger } from '@/components/ui/alert-dialog' ;
const STARTER_QUESTIONS = [
'What can you help me with?' ,
'How do I get started?' ,
'What are your pricing plans?' ,
'Do you offer support?' ,
'How can I contact you?' ,
];
interface ChatWidgetProps {
apiEndpoint ?: string ;
title ?: string ;
subtitle ?: string ;
}
export function ChatWidget ({
apiEndpoint,
title = 'AI Assistant' ,
subtitle = 'Ask us anything!' ,
}: ChatWidgetProps ) {
const { messages, isLoading, isOpen, sendMessage, toggleChat, setIsOpen, clearSession } =
useN8nChat ({ webhookUrl : apiEndpoint });
const [input, setInput] = useState ('' );
const [messageQueue, setMessageQueue] = useState<string []>([]);
const lastMessageRef = useRef<HTMLDivElement >(null );
const inputRef = useRef<HTMLTextAreaElement >(null );
const [unreadCount, setUnreadCount] = useState (0 );
const prevLen = useRef (messages.length );
const isInitialLoad = useRef (true );
useEffect (() => {
if (messages.length > prevLen.current ) {
const last = messages[messages.length - 1 ];
if (last?.role === 'bot' && !isInitialLoad.current && !isOpen) setUnreadCount (p => p + 1 );
if (isInitialLoad.current ) isInitialLoad.current = false ;
}
prevLen.current = messages.length ;
}, [messages, isOpen]);
useEffect (() => { if (isOpen) setUnreadCount (0 ); }, [isOpen]);
useEffect (() => {
lastMessageRef.current ?.scrollIntoView ({ behavior : 'smooth' , block : 'start' });
}, [messages, isLoading, isOpen]);
useEffect (() => {
if (isOpen) setTimeout (() => inputRef.current ?.focus (), 100 );
}, [isOpen]);
useEffect (() => {
if (!isOpen) return ;
const onKeyDown = (e : KeyboardEvent ) => { if (e.key === 'Escape' ) setIsOpen (false ); };
window .addEventListener ('keydown' , onKeyDown);
return () => window .removeEventListener ('keydown' , onKeyDown);
}, [isOpen, setIsOpen]);
useEffect (() => {
if (!isLoading && messageQueue.length > 0 ) {
const [next, ...rest] = messageQueue;
setMessageQueue (rest);
sendMessage (next);
}
}, [isLoading, messageQueue, sendMessage]);
const handleSubmit = useCallback (async (e : React .FormEvent ) => {
e.preventDefault ();
if (!input.trim ()) return ;
const msg = input;
setInput ('' );
if (isLoading || messageQueue.length > 0 ) setMessageQueue (p => [...p, msg]);
else await sendMessage (msg);
setTimeout (() => inputRef.current ?.focus (), 0 );
}, [input, isLoading, messageQueue.length , sendMessage]);
const handleKeyDown = (e : React .KeyboardEvent ) => {
if (e.key === 'Enter' && !e.shiftKey ) { e.preventDefault (); handleSubmit (e as unknown as React .FormEvent ); }
};
return (
<div className ={cn(
'fixed z-50 flex flex-col items-end print:hidden ',
isOpen ? 'inset-0 sm:inset-auto sm:bottom-6 sm:right-6 ' : 'bottom-4 right-4 sm:bottom-6 sm:right-6 ',
)}>
{/* ── Chat panel ── */}
<Card className ={cn(
'transition-all duration-300 ease-in-out transform origin-bottom-right overflow-hidden ',
'border bg-background shadow-xl flex flex-col p-0 gap-0 ',
isOpen
? 'w-full h-full rounded-none sm:rounded-2xl sm:w- [400px ] sm:h- [600px ] sm:max-h- [calc (100vh-6rem )] opacity-100 scale-100 sm:mb-4 '
: 'w-0 h-0 opacity-0 scale-95 mb-0 rounded-2xl ',
)}>
{/* Header */}
<CardHeader className ="flex flex-row bg-primary px-4 py-3 items-center justify-between text-primary-foreground shrink-0 space-y-0 gap-0" >
<div className ="flex items-center gap-3" >
<div className ="h-[48px] w-[48px] rounded-full bg-white/10 flex items-center justify-center shrink-0" >
<Bot className ="h-6 w-6 text-primary-foreground" />
{/* Swap Bot for <Image > if you want a logo */}
</div >
<div >
<h3 className ="font-semibold text-sm leading-tight" > {title}</h3 >
<p className ="text-xs text-primary-foreground/80" > {subtitle}</p >
</div >
</div >
<div className ="flex items-center gap-1 shrink-0" >
<DropdownMenu >
<DropdownMenuTrigger asChild >
<Button variant ="ghost" size ="icon" aria-label ="Chat options"
className ="h-8 w-8 text-primary-foreground hover:bg-primary-foreground/20" >
<MoreVertical className ="h-5 w-5" />
</Button >
</DropdownMenuTrigger >
<DropdownMenuContent align ="end" >
<AlertDialog >
<AlertDialogTrigger asChild >
<DropdownMenuItem onSelect ={e => e.preventDefault()} className="cursor-pointer">
<RefreshCcw className ="mr-2 h-4 w-4" />
<span > Start new chat</span >
</DropdownMenuItem >
</AlertDialogTrigger >
<AlertDialogContent >
<AlertDialogHeader >
<AlertDialogTitle > Are you sure?</AlertDialogTitle >
<AlertDialogDescription >
This will clear your current conversation and start a new one.
</AlertDialogDescription >
</AlertDialogHeader >
<AlertDialogFooter >
<AlertDialogCancel > Cancel</AlertDialogCancel >
<AlertDialogAction onClick ={clearSession} > Continue</AlertDialogAction >
</AlertDialogFooter >
</AlertDialogContent >
</AlertDialog >
</DropdownMenuContent >
</DropdownMenu >
<Button variant ="ghost" size ="icon" aria-label ="Minimize chat"
className ="h-8 w-8 text-primary-foreground hover:bg-primary-foreground/20"
onClick ={() => setIsOpen(false)}>
<Minus className ="h-5 w-5" />
</Button >
</div >
</CardHeader >
{/* Messages */}
<CardContent className ="p-0 flex-1 min-h-0 bg-muted/30" >
<ScrollArea className ="h-full w-full p-4" >
<div className ="flex flex-col gap-2 min-h-full justify-end" >
{messages.map((msg, i) => (
<div key ={msg.id} ref ={i === messages.length - 1 ? lastMessageRef : null }>
<ChatMessage message ={msg} />
</div >
))}
{isLoading && (
<ChatLoadingIndicator isFirstMessage ={messages.filter(m => m.role === 'user').length === 1} />
)}
{/* Starter chips — hidden as soon as user sends first message */}
{!isLoading && messages.every(m => m.role === 'bot') && (
<div className ="flex flex-wrap gap-2 pt-1 pb-2" ref ={lastMessageRef} >
{STARTER_QUESTIONS.map(q => (
<button key ={q} type ="button" onClick ={() => sendMessage(q)}
className="text-left text-xs px-3 py-1.5 rounded-full border border-primary/40 bg-primary/5 text-primary hover:bg-primary/15 hover:border-primary/70 transition-colors duration-150 cursor-pointer">
{q}
</button >
))}
</div >
)}
</div >
</ScrollArea >
</CardContent >
{/* Footer */}
<CardFooter className ="p-4 border-t bg-background shrink-0 block" >
{/* Queue panel */}
{messageQueue.length > 0 && (
<div className ="mb-2 rounded-lg border border-primary/20 bg-primary/5 overflow-hidden" >
<div className ="flex items-center gap-1.5 px-3 py-1.5 border-b border-primary/10 bg-primary/10" >
<Loader2 className ="h-3 w-3 animate-spin text-primary" />
<span className ="text-xs font-medium text-primary" >
{messageQueue.length} message{messageQueue.length > 1 ? 's' : ''} queued
</span >
</div >
<ul className ="divide-y divide-primary/10" >
{messageQueue.map((msg, i) => (
<li key ={i} className ="flex items-center justify-between px-3 py-1.5 gap-2" >
<span className ="text-xs text-muted-foreground truncate flex-1" >
<span className ="text-primary/40 mr-1" > {i + 1}.</span > {msg}
</span >
<button type ="button" aria-label ={ `Remove queued message: ${msg }`}
onClick ={() => setMessageQueue(p => p.filter((_, idx) => idx !== i))}
className="text-muted-foreground hover:text-destructive shrink-0">
<X className ="h-3 w-3" />
</button >
</li >
))}
</ul >
</div >
)}
<form onSubmit ={handleSubmit} className ="flex gap-2" >
<Textarea ref ={inputRef} id ="chat-input" placeholder ="Type your message..."
value ={input} onChange ={e => setInput(e.target.value)} onKeyDown={handleKeyDown}
className="flex-1 min-h-[40px] max-h-[120px] resize-none py-3" rows={1} />
<Button type ="submit" size ="icon" aria-label ="Send message" disabled ={!input.trim() || isLoading }>
<Send className ="h-4 w-4" />
</Button >
</form >
<p className ="text-center text-[10px] text-muted-foreground mt-2" > Powered by Your Brand</p >
</CardFooter >
</Card >
{/* Launcher */}
<div className ={cn( 'relative ', isOpen && 'hidden sm:block ')}>
{unreadCount > 0 && !isOpen && (
<div className ="absolute -top-2 -right-2 z-50 flex h-6 w-6 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white shadow-lg animate-bounce" >
{unreadCount > 9 ? '9+' : unreadCount}
</div >
)}
<Button id ="chat-launcher-btn" onClick ={toggleChat} size ="icon" aria-label ="Open chat"
className ={cn( 'h-14 w-14 rounded-full shadow-lg transition-transform duration-300 hover:scale-105 ',
isOpen ? 'rotate-90 opacity-0 pointer-events-none absolute bottom-0 right-0 ' : 'rotate-0 opacity-100 ')}>
<MessageCircle className ="h-7 w-7" />
</Button >
</div >
{/* Close button (desktop) */}
<Button onClick ={toggleChat} size ="icon" aria-label ="Close chat"
className ={cn( 'h-14 w-14 rounded-full shadow-lg transition-transform duration-300 absolute bottom-0 right-0 bg-primary text-primary-foreground ',
isOpen ? 'scale-100 rotate-0 hidden sm:flex ' : 'scale-0 rotate-90 opacity-0 ')}>
<X className ="h-7 w-7" />
</Button >
</div >
);
}
Step 5 — SSR-Safe Wrapper
File: src/components/shared/chat/client-chat-wrapper.tsx
The widget uses localStorage and browser-only APIs. ssr: false prevents Next.js from
rendering it on the server.
'use client' ;
import dynamic from 'next/dynamic' ;
const ChatWidget = dynamic (
() => import ('@/components/shared/chat/custom-chat-widget' ).then (m => m.ChatWidget ),
{ ssr : false },
);
export function ClientChatWrapper ( ) {
return <ChatWidget /> ;
}
Direct imports from Server Components fail. Importing custom-chat-widget.tsx
directly in a Server Component throws localStorage is not defined at build or runtime.
Always go through this wrapper.
Step 6 — Mount in Layout
Add one import and one JSX tag inside <body>:
+ import { ClientChatWrapper } from '@/components/shared/chat/client-chat-wrapper'
<body className="antialiased min-h-screen">
{children}
+ <ClientChatWrapper />
</body>
If you only want the widget on certain routes, move <ClientChatWrapper /> to a nested
layout file instead of the root layout.
N8N Webhook Contract
Request (frontend → n8n)
{
"action" : "sendMessage" ,
"sessionId" : "550e8400-e29b-41d4-a716-446655440000" ,
"chatInput" : "What can you help me with?" ,
"node_env" : "production"
}
Response (n8n → frontend) — any of these shapes work
{ "output" : "formatted **Markdown** response" }
{ "message" : "also works" }
[ { "output" : "part one" } , { "output" : "part two" } ]
The bot response may contain GFM Markdown — tables, lists, bold, code blocks, links — and
ChatMessage will render all of it correctly.
Customisation Reference
Set the webhook URL
Add to your .env file:
NEXT_PUBLIC_N8N_WEBHOOK_URL=https://your-instance.com/webhook/UUID/chat
Or override per-instance via the prop: <ChatWidget apiEndpoint="https://..." />
Change the greeting messages
In use-n8n-chat.ts:
const DEFAULT_INITIAL_MESSAGES = ['Hello! 👋' , 'How can I help you today?' ];
Change the starter question chips
In custom-chat-widget.tsx, replace STARTER_QUESTIONS with questions relevant to your
product or service.
Change the loading messages
In chat-loading-indicator.tsx, edit STARTUP_MESSAGES and LOADING_MESSAGES.
Each entry: { text: 'Some message...', icon: LucideIcon }
Replace the Bot icon with a logo image
In custom-chat-widget.tsx, swap:
<Bot className="h-6 w-6 text-primary-foreground" />
import Image from 'next/image' ;
import myLogo from '@/assets/logo.png' ;
<Image src ={myLogo} alt ="Logo" width ={40} height ={40} className ="object-contain" />
Restrict to specific routes
Move the mount point from the root layout to a nested layout, e.g. only storefront pages:
src/app/(frontend)/[locale]/
└── layout.tsx ← put <ClientChatWrapper /> here instead
Change localStorage key prefix
In use-n8n-chat.ts:
const LS_SESSION_ID = 'myapp_chat_session_id' ;
const LS_SESSION_CREATED = 'myapp_chat_session_created_at' ;
const messagesKey = (sid : string ) => `myapp_chat_messages_${sid} ` ;
Troubleshooting
Symptom Likely cause Fix localStorage is not definedWidget rendered on server Ensure ssr: false in client-chat-wrapper.tsx Tables render as plain text | col | remark-gfm missingAdd remarkPlugins={[remarkGfm]} to <ReactMarkdown> Bot reply shows [object Object] Unrecognised response shape Log data in the hook; check n8n output node type Messages lost on refresh localStorage key mismatch Verify LS_SESSION_ID / messagesKey constants Widget sits behind other elements z-index conflict Increase z-50 to z-[100] on the outer wrapper <div> Panel jumps on mobile Viewport units The inset-0 class takes full screen on mobile — expected tsc errors on Components typesreact-markdown version mismatch Ensure react-markdown ≥ 9 Console warning about setState after unmount Request in flight when widget unmounts Confirmed fixed by the AbortController in Step 1
Verification Checklist
Run these after setup:
pnpm --filter your-app exec tsc --noEmit
pnpm lint
Manual checks: