Module 7.3 — Next.js Frontend for the RAG Chatbot

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

  1. The useChat hook handles all streaming logic — it reads the SSE response using response.body.getReader(), decodes each chunk, parses SSE data: lines as JSON, and appends each token to the last message in state to create the word-by-word effect.
  2. The usePDFLoader hook calls the Express /api/load-pdf endpoint and tracks loading state — once isLoaded is true the InputBar is enabled and the PDF stats are shown at the top.
  3. The component hierarchy is simple — page.js connects the two hooks and passes props down to PDFLoader, ChatWindow, and InputBar — 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

Module 7.3 — Next.js Frontend for the RAG Chatbot

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 P...