🔥 Building a Simple PDF AI Chat app with Next.js, React PDF and OpenAI 💬

watcharakornwatcharakorn
11 min read

AI is showing up everywhere these days, and one great use is real-time answers from PDFs. From contracts and manuals to compliance reports, PDFs remain a crucial component in business workflows. But searching for answers or details inside long documents can be tedious and time consuming.

With a PDF AI Chat app, you can:

  • Ask questions about a document and get accurate responses fast.

  • Highlight text and get context-aware answers from AI.

  • Copy key details or insights with one click.

For companies, this can mean faster onboarding, quicker report reviews, and less back-and-forth over document details.

In this walkthrough, we'll cover how to build a PDF chat app that let users talk to an AI about the contents of any PDF, right inside a web browser. If you’re a React.js developer wanting to explore document chat workflows, you’ll find this tutorial practical.

Here's a list of tools you’ll need:

  1. Next.js for the app framework

  2. React PDF to show PDFs directly in the browser

  3. OpenAI for the chat backend

  4. Tailwind CSS for easy styling


A quick introduction about us. React PDF lets you display PDF files directly inside your React.js or Next.js projects. With more than 20 built-in features like a default toolbar, easy customization and responsive layouts, your users can view and work with PDFs without leaving your app.

You can add a React PDF Viewer component to all kinds of projects. Use it in document systems, workflow tools, AI apps or as a simple PDF reader. It's flexible and fits right in wherever you need to show or manage PDF files.

Screenshot of React PDF


Part 1 - Set up Your App

Step 1: Initialize a new Next.js project with Tailwind CSS:

npx create-next-app@latest

Step 2: Install required libraries:

npm install @pdf-viewer/react eventsource-parser openai react-markdown

Here's what each does:

  • @pdf-viewer/react: Display PDFs in your app.

  • eventsource-parser: Handle event streams for live AI responses.

  • openai: Connect to the OpenAI API.

  • react-markdown: Render markdown content from AI replies.


Part 2 - Set Up a PDF Viewer Infrastructure

Start by creating a provider and React PDF Viewer component using @pdf-viewer/react. The library lets users open and read PDFs inside your app, with features like built-in toolbars and a responsive design. It works with both simple readers and advanced workflows.

Step 1: Create a PDF Provider Component

// AppProviders.tsx
"use client";
import { RPConfig, RPConfigProps } from "@pdf-viewer/react";
import { type PropsWithChildren } from "react";

function AppProviders({
  children,
  ...props
}: PropsWithChildren<RPConfigProps>) {
  return <RPConfig {...props}>{children}</RPConfig>;
}
export default AppProviders;

Step 2: Create a React PDF Viewer Component

// AppPdfViewer.tsx
"use client";
import { RPProvider, RPDefaultLayout, RPPages } from "@pdf-viewer/react";
import { Ref } from "react";

const AppPdfViewer = ({ ref }: { ref?: Ref<HTMLDivElement> }) => {
  return (
    <div ref={ref}>
      <RPProvider src="https://cdn.codewithmosh.com/image/upload/v1721763853/guides/web-roadmap.pdf">
        <RPDefaultLayout style={{ height: "100vh" }}>
          <RPPages />
        </RPDefaultLayout>
      </RPProvider>
    </div>
  );
};

export default AppPdfViewer;

Step 3: Dynamically Import Viewer Components for Client-Side Rendering

Since PDF.js workers must be run on client-side only, you can use next/dynamic to import your PDF components to prevent SSR errors.

// LazyAppPdfViewer.tsx

"use client";
import dynamic from "next/dynamic";

const LazyAppPdfViewer = dynamic(() => import("./AppPdfViewer"), {
  ssr: false,
});
export default LazyAppPdfViewer;
// LazyAppProviders.tsx
"use client";
import dynamic from "next/dynamic";

const LazyAppProviders = dynamic(() => import("./AppProviders"), {
  ssr: false,
});
export default LazyAppProviders;

Step 4: Configure Layout with Providers

Apply the PDF provider globally via your layout.tsx file.

// layout.tsx
export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className="antialiased">
        <LazyAppProviders licenseKey="your-license-key">
          {children}
        </LazyAppProviders>
      </body>
    </html>
  );
}

Screenshot of React PDF

For more information on how to use React PDF, check out the Basic usage guide.


Part 3: Create a Main Application Layout

To make the chat experience smooth, you’ll want these 4 components:

  • Dropdown for text selection

  • Loading indicator

  • Chat input

  • Chat interface

Create Four Supporting Components

Step 1: Dropdown for Text Selection Component

Pop up when users highlight text in the PDF. Options include “Ask” (send a question about the selected text to AI) and “Copy” (save the text to clipboard)

// SelectDropDown.tsx

import { useCallback } from "react";

interface SelectDropDownProps {
  position: {
    x: number;
    y: number;
  };
  show: boolean;
  onAsk: () => void;
  onCopy: () => void;
}

export const SelectDropDown = ({
  position,
  show,
  onAsk,
  onCopy,
}: SelectDropDownProps) => {
  const handleAsk = useCallback(() => {
    onAsk();
  }, [onAsk]);
  const handleCopy = useCallback(() => {
    onCopy();
  }, [onCopy]);

  if (!show) return null;

  return (
    <div
      className="absolute"
      style={{ top: `${position.y}px`, left: `${position.x}px` }}
    >
      <ul className="bg-white border border-gray-200 rounded-md p-2">
        <li className="cursor-pointer hover:bg-gray-100" onClick={handleAsk}>
          Ask
        </li>
        <li className="cursor-pointer hover:bg-gray-100" onClick={handleCopy}>
          Copy
        </li>
      </ul>
    </div>
  );
};

Step 2: Loading Indicator Component

This appears when the AI is working on a response

// LoadingIcon.tsx

export const LoadingIcon = () => {
  return (
    <svg
      width="1em"
      height="1em"
      viewBox="0 0 100 100"
      xmlns="http://www.w3.org/2000/svg"
    >
      <circle
        cx="50"
        cy="50"
        r="40"
        stroke="#555555"
        strokeWidth="10"
        fill="none"
        strokeLinecap="round"
        strokeDasharray="209.44"
        strokeDashoffset="0"
      >
        <animateTransform
          attributeName="transform"
          type="rotate"
          from="0 50 50"
          to="360 50 50"
          dur="1s"
          repeatCount="indefinite"
        />
      </circle>
    </svg>
  );
};

Step 3: Chat Input Component

Let's users send questions, either based on selected text or general queries about the document.

// Input.tsx

"use client";
import { useCallback, Ref, useRef } from "react";

interface InputProps {
  onSubmit?: (value: string) => void;
  onClearContext?: () => void;
  ref?: Ref<HTMLDivElement>;
  context?: string;
}

export const Input = ({
  ref,
  onSubmit,
  context,
  onClearContext,
}: InputProps) => {
  const inputRef = useRef<HTMLDivElement>(null);

  const handleSubmit = useCallback(() => {
    const textValue = inputRef.current?.innerText;
    if (onSubmit && textValue) {
      onSubmit(textValue);
      inputRef.current!.innerText = "";
    }
  }, [onSubmit]);

  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent<HTMLInputElement>) => {
      // if press Enter only it will send question to server
      // allow new line by shift + enter
      if (e.key === "Enter" && !e.shiftKey) {
        e.preventDefault();
        handleSubmit();
      }
    },
    [handleSubmit]
  );

  return (
    <div ref={ref} className="p-1">
      {context && (
        <div className="text-sm border-t border-gray-300 px-2">
          <div className="flex justify-between">
            Ask about:{" "}
            <span onClick={onClearContext} className="cursor-pointer">
              X
            </span>
          </div>
          <div className="text-gray-500">{context}</div>
        </div>
      )}
      <div className="flex border-t border-gray-300">
        <p
          ref={inputRef}
          className="w-3/4"
          contentEditable
          suppressContentEditableWarning
          onKeyDown={handleKeyDown}
        ></p>
        <button
          onClick={handleSubmit}
          className="w-1/4 border-l border-gray-300"
        >
          Submit
        </button>
      </div>
    </div>
  );
};

Step 4: Chat Interface Component

This component manages the entire conversation:

  • Shows Q&A messages

  • Streams answers in real time

  • Updates dynamically with markdown formatting

  • Adjusts height responsively based on the input area


// Chat.tsx

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Input } from "./Input";
import Markdown from "react-markdown";
import { LoadingIcon } from "./LoadingIcon";
import { EventSourceParserStream } from "eventsource-parser/stream";

// message item for question and answer
const Item = ({
  type,
  content,
}: {
  type: "question" | "answer";
  content: string;
}) => {
  return (
    <div
      className={`flex ${
        type === "question" ? "justify-end" : "justify-start"
      } w-full`}
    >
      <div
        className={`px-2 max-w-2/3 my-2 py-1 rounded ${
          type === "question" ? "bg-neutral-200" : "bg-neutral-400"
        }`}
      >
        {/* render markdown content that will be received from ai */}
        <Markdown>{content}</Markdown>
      </div>
    </div>
  );
};

interface ChatProps {
  context?: string;
  onClearContext?: () => void;
}

export const Chat = ({ context, onClearContext }: ChatProps) => {
  const [inputRef, setInputRef] = useState<HTMLDivElement | null>(null);
  const [messages, setMessages] = useState<
    { id: string; type: "question" | "answer"; content: string }[]
  >([]);
  const [loading, setLoading] = useState(false);
  const messageRef = useRef<string>("");
  const currentMessageIndexRef = useRef<number>(0);
  const [conversationHeight, setConversationHeight] = useState<string>("100vh");

  useEffect(() => {
    if (!inputRef) return;
    const observer = new ResizeObserver((entries) => {
      setConversationHeight(`calc(100vh - ${entries[0].contentRect.height}px)`);
    });
    observer.observe(inputRef);
    return () => {
      observer.disconnect();
    };
  }, [inputRef]);

  const handleSubmit = useCallback(
    async (value: string) => {
      setLoading(true);
      messageRef.current = "";
      setMessages((prev) => {
        // set message index to be appended when answer is received
        currentMessageIndexRef.current = prev.length + 1;
        return [
          ...prev,
          { id: Date.now().toString(), type: "question", content: value },
        ];
      });
      if (onClearContext) {
        onClearContext();
      }
      // get message from openai
      const response = await fetch("/api/ai", {
        method: "POST",
        body: JSON.stringify({ question: value, context }),
        headers: {
          "Content-Type": "application/json",
        },
      });
      // read event stream
      const reader = response.body
        ?.pipeThrough(new TextDecoderStream())
        .pipeThrough(new EventSourceParserStream())
        .getReader();
      try {
        while (true) {
          if (!reader) {
            throw new Error("Reader is not defined");
          }
          const { done, value } = await reader.read();
          if (done || value.data === "[DONE]") {
            reader.cancel();
            break;
          }
          const newMessage = value.data;
          // append new message to the current message
          messageRef.current = messageRef.current + newMessage;
          setMessages((prev) => {
            if (prev[currentMessageIndexRef.current]) {
              return prev.map((message, index) => {
                if (index === currentMessageIndexRef.current) {
                  return {
                    ...message,
                    content: messageRef.current,
                  };
                }
                return message;
              });
            }
            return [
              ...prev,
              {
                id: Date.now().toString(),
                content: messageRef.current,
                type: "answer",
              },
            ];
          });
        }
      } catch (error) {
        console.error(error);
      }
      setLoading(false);
    },
    [context, onClearContext]
  );

  return (
    <div className="h-full relative">
      {loading && (
        <div className="w-full h-full bg-neutral-400/30 absolute inset-0 flex items-center justify-center">
          <div className="spin text-5xl">
            <LoadingIcon />
          </div>
        </div>
      )}
      <div
        className="overflow-y-auto"
        style={{
          height: conversationHeight,
        }}
      >
        <div className="p-1">
          {messages.map((ele) => (
            <Item key={ele.id} type={ele.type} content={ele.content} />
          ))}
        </div>
      </div>
      {/* avoid rerender when input height change due to new line */}
      <div className="absolute bottom-0 w-full">
        <Input
          context={context}
          onSubmit={handleSubmit}
          onClearContext={onClearContext}
          ref={setInputRef}
        />
      </div>
    </div>
  );
};

Screenshot of Chat Interface

Step 5: Combine PDF Viewer and Chat Interface

At this stage, you’ll combine everything into a unified experience:

  • The PDF viewer will appear on the left.

  • The chat interface on the right.

  • When users highlight text, they’ll see a dropdown menu near their selection with contextual actions like Ask or Copy.

Hooks will be used to handle events like text selection and menu placement. Make sure the chat input and dropdown feel responsive by tracking which text is active and when to show menus.

// PdfChat.tsx

"use client";
import LazyAppPdfViewer from "./LazyAppPdfViewer";
import { Chat } from "./Chat";
import { useCallback, useEffect, useState } from "react";
import { SelectDropDown } from "./SelectDropDown";

export const PdfChat = () => {
  const [pdfViewer, setPdfViewer] = useState<HTMLDivElement | null>();
  const [selectedText, setSelectedText] = useState<string>();
  const [menuPosition, setMenuPosition] = useState<{ x: number; y: number }>({
    x: 0,
    y: 0,
  });
  const [showDropdown, setShowDropdown] = useState<boolean>(false);
  const [context, setContext] = useState<string>();

  const handleMouseUp = useCallback(() => {
    const selection = window.getSelection(); // Get current selection
    const selectedString = selection?.toString(); // Convert to string
    const selectedRange = selection?.getRangeAt(0); // Get the range object

    // Set selected text so we can use it later
    setSelectedText(selectedString);

    // If there's valid selection, show dropdown
    if (selectedString && selectedString.trim().length > 0 && selectedRange) {
      const rangeBounds = selectedRange.getBoundingClientRect();
      // Position the dropdown near the selection
      setMenuPosition({
        x: rangeBounds.left + window.scrollX,
        y: rangeBounds.bottom + window.scrollY,
      });
      setShowDropdown(true);
    } else {
      setShowDropdown(false);
    }
  }, []);

  const handleAsk = useCallback(() => {
    setContext(selectedText);
    setShowDropdown(false);
  }, [selectedText]);

  const handleCopy = useCallback(() => {
    if (selectedText) {
      window.navigator.clipboard.writeText(selectedText);
    }
    setShowDropdown(false);
  }, [selectedText]);

  useEffect(() => {
    pdfViewer?.addEventListener("mouseup", handleMouseUp);

    return () => {
      pdfViewer?.removeEventListener("mouseup", handleMouseUp);
    };
  }, [handleMouseUp, pdfViewer]);

  return (
    <>
      <div className="col-span-2 relative">
        <LazyAppPdfViewer ref={setPdfViewer} />
        <SelectDropDown
          position={menuPosition}
          show={showDropdown}
          onAsk={handleAsk}
          onCopy={handleCopy}
        />
      </div>
      <div className="col-span-1">
        <Chat context={context} onClearContext={() => setContext(undefined)} />
      </div>
    </>
  );
};

Screenshot of React PDF and Chat Interface


Part 4 - Create an API to Connect with OpenAI

Now that the UI is complete, we’ll build an API route that connects your app to OpenAI. This backend route will:

  • Accept questions and optional context (like highlighted text).

  • Send those to the OpenAI API with an appropriate prompt

  • Stream the AI’s response back to the frontend in real-time

This gives users a fast, interactive experience where answers appear as they’re generated.

import OpenAI from "openai";

const openai = new OpenAI({
  apiKey: process.env.OPENAI_KEY,
});

export async function POST(request: Request) {
  const { question, context } = await request.json();
  if (!question) {
    return new Response("No question", { status: 400 });
  }
  try {
    const prompt = context
      ? `
    From this content:
    ${context}
    Question: ${question}
    `
      : question;

    const encoder = new TextEncoder();
    const customReadable = new ReadableStream({
      start(controller) {
        openai.responses
          .stream({
            model: "gpt-4.1-nano",
            input: prompt,
          })
          // send delta to client
          .on("response.output_text.delta", (event) => {
            controller.enqueue(encoder.encode(`data: ${event.delta}\n\n`));
          })
          // send done to client
          .on("response.output_text.done", () => {
            controller.enqueue(encoder.encode("data: [DONE]\n\n"));
            controller.close();
          });

        // Clean up on connection close
        request.signal.addEventListener("abort", () => {
          // clearInterval(intervalId);
          controller.close();
        });
      },
    });

    return new Response(customReadable, {
      headers: {
        "Content-Type": "text/event-stream",
        "Cache-Control": "no-cache",
        Connection: "keep-alive",
      },
    });
  } catch (error) {
    console.error(error);
    return new Response("Error", { status: 500 });
  }
}

Demo

Here’s what the final app looks like in action:

Screenshot of final app

For the complete example with full codes, check out Stackblitz.


Conclusion

In this tutorial, we've built a simple PDF viewer application with AI chat by using Next.js, OpenAI, and React PDF. By combining modern web technologies, we created a practical tool that enhances document interaction and understanding:

  • Quickly get summaries, explanations, or answers about any PDF.

  • Avoid endless scrolling or skimming to find info.

  • Evaluate answers or insights as needed for reports or messages.

This makes it particularly valuable for professionals who frequently work with PDF documents and need quick insights or clarifications about their content.

With the right React tools and AI, you can unlock new ways to interact with information. If you build something cool or have ideas for new features, share your results with the community!


If you liked this article, take a look at React PDF. It's a React PDF viewer made for React and Next.js projects. No matter if you're new to building apps or handling a larger setup, React PDF is ready to help.

React PDF is built with developers in mind and offers:

Your support helps me keep building new tools and writing for React devs. Thanks for giving React PDF a look! 🙏

0
Subscribe to my newsletter

Read articles from watcharakorn directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

watcharakorn
watcharakorn