Building the complete chat UI that connects to your Express RAG API
What We're Building
A clean Next.js chat interface that:
→ Loads a PDF via the Express API
→ Shows streaming responses word by word
→ Displays source citations
→ Maintains conversation history
→ Handles loading and error states
This connects to the Express API you built in Module 7.2.
Project Setup
npx create-next-app@latest rag-frontend
Answer the prompts:
✔ Would you like to use TypeScript? → No
✔ Would you like to use ESLint? → No
✔ Would you like to use Tailwind CSS? → Yes
✔ Would you like to use src/ directory? → No
✔ Would you like to use App Router? → Yes
✔ Would you like to customize the import alias? → No
cd rag-frontend
Create .env.local:
NEXT_PUBLIC_API_URL=http://localhost:3001
Project Structure
rag-frontend/
├── .env.local
├── app/
│ ├── layout.js ← root layout
│ ├── page.js ← main chat page
│ ├── globals.css ← global styles
│ └── api/ ← (not needed — we call Express directly)
├── components/
│ ├── ChatWindow.js ← message list display
│ ├── MessageBubble.js ← single message component
│ ├── InputBar.js ← question input and send button
│ ├── PDFLoader.js ← PDF upload section
│ └── StatusBar.js ← shows what's happening
└── hooks/
├── useChat.js ← handles all chat logic + streaming
└── usePDFLoader.js ← handles PDF loading
Step 1 — Hooks (Business Logic)
hooks/usePDFLoader.js
"use client"; // "use client" = this runs in the browser, not on the server // Required for hooks that use useState, useEffect, fetch
import { useState } from "react";
export function usePDFLoader() { // This hook handles everything about loading a PDF
const [isLoading, setIsLoading] = useState(false); // isLoading = true while PDF is being indexed // used to show spinner and disable the load button
const [isLoaded, setIsLoaded] = useState(false); // isLoaded = true after PDF indexed successfully // enables the chat input
const [error, setError] = useState(null); // error = string message if something went wrong // null = no error
const [pdfStats, setPdfStats] = useState(null); // pdfStats = info about loaded PDF // example: { pages: 8, chunks: 32, fileName: "sample.pdf" }
async function loadPDF(pdfPath) { // pdfPath = path to PDF on the Express server // example: "./sample.pdf"
if (!pdfPath.trim()) { setError("Please enter a PDF file path"); return; }
setIsLoading(true); setError(null); // reset error before new attempt
try { const response = await fetch( `${process.env.NEXT_PUBLIC_API_URL}/api/load-pdf`, // NEXT_PUBLIC_API_URL = http://localhost:3001 // read from .env.local file { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ pdfPath }), // send pdfPath to Express API } );
const data = await response.json(); // data = { success: true, stats: {...} } // or { success: false, error: "..." }
if (!response.ok || !data.success) { throw new Error(data.error || "Failed to load PDF"); }
setPdfStats(data.stats); // example: { pages: 8, chunks: 32, fileName: "sample.pdf" }
setIsLoaded(true); // enables the chat input
} catch (err) { setError(err.message); setIsLoaded(false); } finally { setIsLoading(false); // always stop loading spinner — success or fail } }
function reset() { // called when user wants to load a different PDF setIsLoaded(false); setPdfStats(null); setError(null); }
return { isLoading, isLoaded, error, pdfStats, loadPDF, reset, }; }
hooks/useChat.js
"use client";
import { useState, useCallback, useRef } from "react";
const API_URL = process.env.NEXT_PUBLIC_API_URL; // http://localhost:3001
export function useChat() { // This hook manages all chat state and streaming logic
const [messages, setMessages] = useState([]); // messages = array of message objects // example: // [ // { id: "1", role: "user", content: "What is this about?", isStreaming: false }, // { id: "2", role: "assistant", content: "This document...", isStreaming: false }, // ]
const [isStreaming, setIsStreaming] = useState(false); // true while AI is generating a response // used to disable input and show typing indicator
const [error, setError] = useState(null); // error message to show if something goes wrong
const sessionId = useRef(`session_${Date.now()}`); // useRef = persists across renders without causing re-renders // sessionId is created once and stays the same // example: "session_1752672000000" // sent with every message so Express API tracks conversation
const abortControllerRef = useRef(null); // abortControllerRef = lets us cancel a streaming request // if user clicks Stop or navigates away // .current = the actual AbortController object
const addMessage = useCallback((role, content, id) => { // adds a new message to the messages array // role = "user" or "assistant" // content = message text // id = unique identifier
setMessages(prev => [ ...prev, // keep all existing messages
{ id: id || `msg_${Date.now()}_${Math.random()}`, // unique ID — used as React key // Date.now() + random = guaranteed unique
role, // "user" or "assistant"
content, // the actual text
isStreaming: false, // false = complete message // true = currently being streamed (partial)
timestamp: new Date().toISOString(), // when this message was created } ]); }, []); // useCallback = memoizes the function // only recreated if dependencies change (empty array = never)
const updateLastMessage = useCallback((updater) => { // updates the last message in the array // updater = function that returns new message object // used during streaming to append new tokens
setMessages(prev => { const messages = [...prev]; // copy array
const lastIndex = messages.length - 1; // index of last message
messages[lastIndex] = updater(messages[lastIndex]); // call updater with current last message // updater returns updated message object
return messages; }); }, []);
async function sendMessage(question) { if (!question.trim() || isStreaming) return; setError(null);
// Add user message addMessage("user", question);
// Add empty assistant message with loading state setMessages(prev => [...prev, { id: `msg_${Date.now()}`, role: "assistant", content: "", isStreaming: true, timestamp: new Date().toISOString(), }]);
setIsStreaming(true);
try { // Use non-streaming endpoint — cleaner for RAG const response = await fetch( `${API_URL}/api/chat`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ question, sessionId: sessionId.current, }), } );
const data = await response.json();
if (!response.ok || !data.success) { throw new Error(data.error || "Request failed"); }
// Update the assistant message with the final answer updateLastMessage(msg => ({ ...msg, content: data.answer, // data.answer = clean LLM response, no raw chunks isStreaming: false, }));
} catch (err) { if (err.name !== "AbortError") { setError(err.message); updateLastMessage(msg => ({ ...msg, content: "Sorry, something went wrong. Please try again.", isStreaming: false, })); } } finally { setIsStreaming(false); } }
// This is the original streaming version of sendMessage, which is commented out. It uses Server-Sent Events (SSE) to stream tokens from the server as they arrive. The current implementation uses a non-streaming endpoint for simplicity and better RAG handling.
// async function sendMessage(question) { // // question = what the user typed
// if (!question.trim() || isStreaming) return; // // ignore empty input or if already streaming
// setError(null);
// // Add user message immediately // addMessage("user", question); // // user sees their message right away // // doesn't wait for API response
// // Add empty assistant message — will be filled by streaming // const assistantMsgId = `msg_${Date.now()}`; // setMessages(prev => [ // ...prev, // { // id: assistantMsgId, // role: "assistant", // content: "", // // starts empty — tokens appended as they stream in // isStreaming: true, // // true = show typing indicator // timestamp: new Date().toISOString(), // } // ]);
// setIsStreaming(true);
// // Create AbortController to allow cancellation // abortControllerRef.current = new AbortController(); // // AbortController = browser API to cancel fetch requests // // .signal = passed to fetch() so it can be cancelled
// try { // const response = await fetch( // `${API_URL}/api/chat/stream`, // { // method: "POST", // headers: { "Content-Type": "application/json" }, // body: JSON.stringify({ // question, // sessionId: sessionId.current, // // send sessionId so Express tracks conversation history // }), // signal: abortControllerRef.current.signal, // // signal = allows this request to be aborted // } // );
// if (!response.ok) { // const errorData = await response.json(); // throw new Error(errorData.error || "Request failed"); // }
// // Read the streaming response // const reader = response.body.getReader(); // // response.body = ReadableStream of SSE data // // getReader() = get a reader to consume the stream
// const decoder = new TextDecoder(); // // TextDecoder = converts raw bytes to string // // SSE data comes as bytes (Uint8Array)
// let buffer = ""; // // buffer = accumulates incomplete SSE lines // // SSE data may arrive in chunks that split across lines
// while (true) { // const { done, value } = await reader.read(); // if (done) break;
// buffer += decoder.decode(value, { stream: true });
// const lines = buffer.split("\n"); // buffer = lines.pop() || "";
// let currentEvent = ""; // // tracks the current event type // // SSE format: // // event: token ← event type line // // data: {"content":"x"} ← data line // // ← blank line = end of event
// for (const line of lines) { // if (line.startsWith("event: ")) { // currentEvent = line.slice(7).trim(); // // "event: token" → "token" // // "event: done" → "done" // // "event: error" → "error" // // "event: start" → "start" // }
// if (line.startsWith("data: ")) { // const jsonStr = line.slice(6).trim(); // // remove "data: " prefix
// if (!jsonStr || jsonStr === "") continue; // // skip empty data lines
// try { // const data = JSON.parse(jsonStr);
// if (currentEvent === "token" && data.content) { // // append token to last message // updateLastMessage(msg => ({ // ...msg, // content: msg.content + data.content, // })); // }
// if (currentEvent === "done") { // // streaming complete // updateLastMessage(msg => ({ // ...msg, // isStreaming: false, // })); // }
// if (currentEvent === "error" && data.success === false) { // throw new Error(data.error || "Streaming error from server"); // }
// } catch (parseError) { // if (parseError.message.includes("Streaming error")) { // throw parseError; // // re-throw actual errors // } // // ignore JSON parse errors from empty/malformed lines // }
// currentEvent = ""; // // reset event type after processing data // } // } // }
// } catch (err) { // if (err.name === "AbortError") { // // user cancelled — not an error // updateLastMessage(msg => ({ // ...msg, // content: msg.content + " [stopped]", // isStreaming: false, // })); // } else { // setError(err.message); // updateLastMessage(msg => ({ // ...msg, // content: "Sorry, something went wrong. Please try again.", // isStreaming: false, // })); // } // } finally { // setIsStreaming(false); // abortControllerRef.current = null; // // cleanup // } // }
function stopStreaming() { // called when user clicks Stop button if (abortControllerRef.current) { abortControllerRef.current.abort(); // cancels the fetch request // triggers AbortError in the catch block above } }
function clearMessages() { setMessages([]); setError(null); // start fresh conversation // Note: this only clears UI — server still has history }
return { messages, isStreaming, error, sendMessage, stopStreaming, clearMessages, }; }
Step 2 — Components
components/PDFLoader.js
"use client";
import { useState } from "react";
export default function PDFLoader({ onLoaded, isLoading, isLoaded, error, pdfStats }) { // Props: // onLoaded = callback when user clicks Load button // isLoading = true while indexing // isLoaded = true after success // error = error message string // pdfStats = { pages, chunks, fileName }
const [pdfPath, setPdfPath] = useState("./sample.pdf"); // pdfPath = what user types in the input box // default to sample.pdf so they can test immediately
function handleSubmit(e) { e.preventDefault(); // prevent page reload on form submit onLoaded(pdfPath); // call parent's load function with the path }
if (isLoaded) { // Show success state after PDF is loaded return ( <div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-4"> <div className="flex items-center gap-2 text-green-700"> <span className="text-xl">✅</span> <div> <p className="font-medium">Document loaded: {pdfStats?.fileName}</p> <p className="text-sm text-green-600"> {pdfStats?.pages} pages • {pdfStats?.chunks} chunks indexed </p> </div> </div> </div> ); }
return ( <div className="bg-white border border-gray-200 rounded-lg p-4 mb-4"> <h2 className="text-lg font-semibold text-gray-800 mb-3"> 📄 Load a PDF Document </h2>
<form onSubmit={handleSubmit} className="flex gap-2"> <input type="text" value={pdfPath} onChange={(e) => setPdfPath(e.target.value)} // controlled input — updates pdfPath state on every keystroke placeholder="Enter PDF path (e.g. ./sample.pdf)" className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" disabled={isLoading} // disable while loading so user can't change path mid-index />
<button type="submit" disabled={isLoading || !pdfPath.trim()} // disabled if loading OR if input is empty className="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors" > {isLoading ? ( <span className="flex items-center gap-2"> <span className="animate-spin">⟳</span> Indexing... </span> ) : ( "Load PDF" )} </button> </form>
{error && ( <p className="text-red-600 text-sm mt-2">❌ {error}</p> // show error message below the form )}
{isLoading && ( <p className="text-gray-500 text-sm mt-2"> Loading PDF and creating embeddings — this takes a moment... </p> )} </div> ); }
components/MessageBubble.js
"use client";
export default function MessageBubble({ message }) { // message = { id, role, content, isStreaming, timestamp }
const isUser = message.role === "user"; // isUser = true for user messages, false for assistant
return ( <div className={`flex ${isUser ? "justify-end" : "justify-start"} mb-4`}> {/* Align user messages right, assistant messages left */}
<div className={`max-w-[80%] ${isUser ? "order-2" : "order-1"}`}>
{/* Avatar */} <div className={`flex items-end gap-2 ${isUser ? "flex-row-reverse" : "flex-row"}`}> <div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm ${isUser ? "bg-blue-600 text-white" : "bg-gray-200 text-gray-600"}`}> {isUser ? "U" : "🤖"} </div>
{/* Message bubble */} <div className={`rounded-2xl px-4 py-3 ${isUser ? "bg-blue-600 text-white rounded-br-sm" : "bg-white border border-gray-200 text-gray-800 rounded-bl-sm shadow-sm" }`}>
{/* Message content */} <p className="text-sm leading-relaxed whitespace-pre-wrap"> {message.content} {/* whitespace-pre-wrap = preserve line breaks in AI responses */}
{message.isStreaming && ( <span className="inline-block w-2 h-4 bg-current ml-1 animate-pulse" /> // blinking cursor while streaming // animate-pulse = Tailwind's pulse animation )} </p>
{/* Timestamp */} <p className={`text-xs mt-1 ${isUser ? "text-blue-200" : "text-gray-400"}`}> {new Date(message.timestamp).toLocaleTimeString("en-IN", { hour: "2-digit", minute: "2-digit", })} </p> </div> </div> </div> </div> ); }
components/ChatWindow.js
"use client";
import { useEffect, useRef } from "react"; import MessageBubble from "./MessageBubble";
export default function ChatWindow({ messages, isStreaming }) { // messages = array of message objects // isStreaming = true while AI is responding
const bottomRef = useRef(null); // bottomRef = ref to an invisible div at the bottom of the chat // used to scroll to the bottom when new messages arrive
useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); // scroll to bottom whenever messages change // happens when: user sends message, AI responds, token arrives // behavior: "smooth" = animated scrolling }, [messages]); // dependency array = only run when messages changes
if (messages.length === 0) { // Empty state — shown before any messages return ( <div className="flex-1 flex items-center justify-center text-gray-400"> <div className="text-center"> <p className="text-4xl mb-3">💬</p> <p className="text-lg font-medium">Ask a question about your document</p> <p className="text-sm">Load a PDF above to get started</p> </div> </div> ); }
return ( <div className="flex-1 overflow-y-auto p-4"> {/* overflow-y-auto = scroll when content is taller than container */}
{messages.map(message => ( <MessageBubble key={message.id} message={message} /> // key = message.id (unique — required by React for lists) ))}
{/* Typing indicator — shown while streaming before first token */} {isStreaming && messages[messages.length - 1]?.content === "" && ( <div className="flex justify-start mb-4"> <div className="bg-white border border-gray-200 rounded-2xl px-4 py-3 shadow-sm"> <div className="flex gap-1"> <span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: "0ms" }} /> <span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: "150ms" }} /> <span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: "300ms" }} /> </div> {/* Three bouncing dots — classic typing indicator */} </div> </div> )}
<div ref={bottomRef} /> {/* Invisible div at the bottom — scrollIntoView targets this */} </div> ); }
components/InputBar.js
"use client";
import { useState, useRef, useEffect } from "react";
export default function InputBar({ onSend, onStop, isStreaming, isDisabled }) { // onSend = function to call when user submits a question // onStop = function to call when user clicks Stop // isStreaming = true while AI is responding // isDisabled = true if no PDF is loaded yet
const [input, setInput] = useState(""); // input = current text in the textarea
const textareaRef = useRef(null); // ref to the textarea element // used to auto-resize it as user types
useEffect(() => { if (textareaRef.current) { textareaRef.current.style.height = "auto"; // reset height first
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 120) + "px"; // set height to content height (up to 120px max) // creates auto-growing textarea } }, [input]); // run whenever input changes
function handleSubmit(e) { e?.preventDefault(); // e?.preventDefault() = prevent form submit if called from form // ? = optional chaining (works even if e is undefined)
if (!input.trim() || isStreaming || isDisabled) return; // don't submit if empty, already streaming, or no PDF loaded
onSend(input.trim()); // pass trimmed question to parent
setInput(""); // clear input after sending }
function handleKeyDown(e) { if (e.key === "Enter" && !e.shiftKey) { // Enter = submit // Shift+Enter = new line (don't submit) e.preventDefault(); handleSubmit(); } }
return ( <div className="border-t border-gray-200 bg-white p-4"> <form onSubmit={handleSubmit} className="flex gap-2 items-end">
<textarea ref={textareaRef} value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={handleKeyDown} placeholder={ isDisabled ? "Load a PDF first to start chatting..." : "Ask a question about your document... (Enter to send)" } disabled={isDisabled || isStreaming} rows={1} className="flex-1 border border-gray-300 rounded-xl px-4 py-3 text-sm resize-none overflow-hidden focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-50 disabled:text-gray-400" // resize-none = disable manual resize (we auto-resize) // overflow-hidden = hide scrollbar (we expand instead) />
{isStreaming ? ( // Show Stop button while streaming <button type="button" onClick={onStop} className="bg-red-500 text-white px-4 py-3 rounded-xl font-medium text-sm hover:bg-red-600 transition-colors" > ⏹ Stop </button> ) : ( // Show Send button normally <button type="submit" disabled={!input.trim() || isDisabled} className="bg-blue-600 text-white px-4 py-3 rounded-xl font-medium text-sm hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors" > Send ↑ </button> )} </form>
<p className="text-xs text-gray-400 mt-2 text-center"> Press Enter to send • Shift+Enter for new line </p> </div> ); }
Step 3 — Main Page
app/page.js
"use client";
import { useChat } from "@/hooks/useChat"; import { usePDFLoader } from "@/hooks/usePDFLoader"; import PDFLoader from "@/components/PDFLoader"; import ChatWindow from "@/components/ChatWindow"; import InputBar from "@/components/InputBar";
export default function Home() { // Home = the main page component // everything comes together here
const pdfLoader = usePDFLoader(); // pdfLoader.isLoading, pdfLoader.isLoaded, pdfLoader.loadPDF, etc.
const chat = useChat(); // chat.messages, chat.isStreaming, chat.sendMessage, etc.
return ( <div className="flex flex-col h-screen bg-gray-50"> {/* h-screen = full viewport height */} {/* flex flex-col = stack children vertically */}
{/* Header */} <header className="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between shadow-sm"> <div> <h1 className="text-xl font-bold text-gray-900">📚 PDF Chatbot</h1> <p className="text-sm text-gray-500">Ask questions about your documents</p> </div>
{chat.messages.length > 0 && ( // Only show Clear button if there are messages <button onClick={chat.clearMessages} className="text-sm text-gray-500 hover:text-gray-700 border border-gray-300 rounded-lg px-3 py-1.5 hover:bg-gray-50 transition-colors" > Clear chat </button> )} </header>
{/* Main content area */} <main className="flex-1 flex flex-col overflow-hidden max-w-3xl w-full mx-auto px-4 pt-4"> {/* max-w-3xl = max width for readability */} {/* mx-auto = center horizontally */}
{/* PDF Loader — always shown at top */} <PDFLoader onLoaded={pdfLoader.loadPDF} isLoading={pdfLoader.isLoading} isLoaded={pdfLoader.isLoaded} error={pdfLoader.error} pdfStats={pdfLoader.pdfStats} />
{/* Error banner for chat errors */} {chat.error && ( <div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4 text-red-700 text-sm"> ❌ {chat.error} </div> )}
{/* Chat messages */} <ChatWindow messages={chat.messages} isStreaming={chat.isStreaming} /> </main>
{/* Input bar — fixed at bottom */} <div className="max-w-3xl w-full mx-auto px-4"> <InputBar onSend={chat.sendMessage} onStop={chat.stopStreaming} isStreaming={chat.isStreaming} isDisabled={!pdfLoader.isLoaded} // disable input until PDF is loaded /> </div> </div> ); }
app/layout.js
import "./globals.css";
export const metadata = { title: "PDF Chatbot", description: "Ask questions about your PDF documents", };
export default function RootLayout({ children }) { return ( <html lang="en"> <body className="antialiased"> {children} </body> </html> ); }
Step 4 — Run Everything
Terminal 1 — Start Express API:
cd langchain-production-rag
node src/api.js
Terminal 2 — Start Next.js:
cd rag-frontend
npm run dev
Open http://localhost:3000
What You'll See
┌─────────────────────────────────────────┐
│ 📚 PDF Chatbot [Clear chat] │
├─────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────┐ │
│ │ 📄 Load a PDF Document │ │
│ │ [./sample.pdf ] [Load] │ │
│ └─────────────────────────────────┘ │
│ │
│ 💬 │
│ Ask a question about your document │
│ Load a PDF above to get started │
│ │
├─────────────────────────────────────────┤
│ [Ask a question... ] [Send] │
│ Press Enter to send │
└─────────────────────────────────────────┘
After loading PDF:
┌─────────────────────────────────────────┐
│ ✅ Document loaded: sample.pdf │
│ 8 pages • 32 chunks indexed │
├─────────────────────────────────────────┤
│ [U] │
│ What is this about? ✓ │
│ │
│ [🤖] │
│ Based on Source 1 (Page 1), this │
│ document covers AI engineering...▌ │
│ ← words appear as they stream │
└─────────────────────────────────────────┘
3-Line Summary
- The
useChathook handles all streaming logic — it reads the SSE response usingresponse.body.getReader(), decodes each chunk, parses SSEdata:lines as JSON, and appends each token to the last message in state to create the word-by-word effect. - The
usePDFLoaderhook calls the Express/api/load-pdfendpoint and tracks loading state — onceisLoadedis true theInputBaris enabled and the PDF stats are shown at the top. - The component hierarchy is simple —
page.jsconnects the two hooks and passes props down toPDFLoader,ChatWindow, andInputBar— each component has one clear job and none of them fetch data directly.
Module 7.3 — Complete ✅
Coming up — Module 7.4 — Pinecone Cloud Vector Database
We replace the in-memory vector store with Pinecone — a production cloud vector database. Data persists across restarts, scales to millions of documents, and comes with a full web dashboard to visualize your stored vectors.
No comments:
Post a Comment