Build an AI-Powered PDF Chat App

Build a PDF chat app with Next.js, PostgreSQL + pgvector and an LLM API: upload, chunk, embed, retrieve and chat with any document.

Build the classic “chat with your PDF” app: upload documents, ask questions, get answers with page citations. Deployable to a VPS for ~$8.50/month.

What we’re building

flowchart LR
    User --> Next[Next.js]
    Next --> Upload[Upload + parse]
    Upload --> PG[(PostgreSQL + pgvector)]
    User --> Chat[Chat with RAG]
    Chat --> LLM[LLM API]
    Chat --> PG

The pipeline: each uploaded PDF is parsed into pages → split into chunks → embedded as vectors → stored in pgvector. Each question is embedded, matched against stored vectors, and sent to the LLM with the matching excerpts. Every file in the pipeline is below; nothing is left as a phantom helper.

What you’ll learn

  • Reliable PDF text extraction with a real parser (pdfjs-dist), not regex
  • Chunking that keeps page numbers so answers can cite them
  • A complete RAG loop: embed, retrieve, answer — with honest limits (OCR, auth) called out

Prerequisites

  • Node.js 22+
  • Docker (PostgreSQL + pgvector run in containers)
  • An OpenAI API key (chat + embeddings)

1. Scaffold

npx create-next-app@latest pdfchat --ts --app
cd pdfchat
npm i openai pg zod [email protected]

That’s the full dependency list — note what we are not installing: no Mongoose, no LangChain, no extra PDF engines. pdfjs-dist is Mozilla’s PDF renderer, pinned so the parsing API in this guide matches your installed version.

2. Spin up the database

docker-compose.yml (create this file at the project root):

services:
  db:
    image: pgvector/pgvector:pg17
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: dev
      POSTGRES_DB: pdfchat
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./schema.sql:/docker-entrypoint-initdb.d/00_schema.sql:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      retries: 10

volumes:
  pgdata:

pgvector/pgvector:pg17 is the official Postgres image with the vector extension built in — plain postgres:17 does not ship it. The schema.sql mount creates the tables on first boot.

schema.sql:

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE documents (
  id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name       TEXT NOT NULL,
  page_count INT  NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE chunks (
  id         BIGSERIAL PRIMARY KEY,
  doc_id     UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
  page       INT  NOT NULL,
  chunk      TEXT NOT NULL,
  embedding  vector(1536) NOT NULL
);

-- Retrieval only ever scans chunks of one document.
CREATE INDEX ON chunks (doc_id);
  • vector(1536) matches OpenAI’s text-embedding-3-small output size. Change the embedding model and you must change this size too.
  • ON DELETE CASCADE: deleting a document removes its chunks automatically.
  • doc_id scoping is the retrieval isolation: queries always filter by document, so chunk A of doc X can never be returned for a question about doc Y.

Start it:

docker compose up -d

3. Environment variables

.env (project root; not committed):

DATABASE_URL=postgres://postgres:dev@localhost:5432/pdfchat
OPENAI_API_KEY=sk-...
MAX_FILE_BYTES=10485760
Variable Required Description
DATABASE_URL yes Points at the db container (see port map above).
OPENAI_API_KEY yes Get it at platform.openai.com → API keys. This is what the app uses to embed and to chat.
MAX_FILE_BYTES no Upload size limit. 10485760 = 10 MB. Raise it if you accept bigger PDFs.

Use your own key in .env — never commit it. sk-... above is a placeholder that you replace.

4. Database access

lib/db.ts:

import { Pool } from 'pg';

const globalForPg = globalThis as unknown as { pool?: Pool };

export const pool =
  globalForPg.pool ??
  new Pool({
    connectionString: process.env.DATABASE_URL,
    max: 10,
  });

if (process.env.NODE_ENV !== 'production') globalForPg.pool = pool;

The globalForPg guard reuses one connection pool across hot reloads in dev — without it, every file edit opens a new pool and you run out of connections.

5. Parse the PDF into pages

lib/pdf.ts:

import { getDocument } from 'pdfjs-dist/legacy/build/pdf.mjs';

export interface Page {
  page: number;
  text: string;
}

// Reads every page of the PDF and returns its text. Layout-aware, not regex.
export async function parsePdfIntoPages(buffer: Buffer): Promise<Page[]> {
  const doc = await getDocument({ data: new Uint8Array(buffer) }).promise;
  const pages: Page[] = [];

  for (let i = 1; i <= doc.numPages; i++) {
    const page = await doc.getPage(i);
    const content = await page.getTextContent();
    const text = content.items
      .map((item) => ('str' in item ? (item as { str: string }).str : ''))
      .join(' ')
      .replace(/\s+/g, ' ')
      .trim();
    pages.push({ page: i, text });
  }
  return pages;
}

What this does:

  • getDocument(...).promise loads the whole file into memory and parses its internal structure.
  • Each page’s text items (words, glyphs) are joined in reading order — this handles most multi-column PDFs, which a naive pdftotext | grep style approach mangles.
  • The function returns per-page text with the page number, which is exactly what the citation step needs.
  • Scanned/image-only PDFs return near-empty text (text.length < 50). That is the OCR case called out in Common problems.

6. Chunk with page tracking

lib/chunker.ts:

import type { Page } from './pdf';

// Splits pages into chunks of at most maxChars characters, keeping a
// small overlap so a thought split across two chunks is still found.
export function chunkPages(
  pages: Page[],
  maxChars = 3200, // ~800 tokens
  overlapChars = 400
): { text: string; page: number }[] {
  const chunks: { text: string; page: number }[] = [];
  let buffer = '';
  let bufferPage = 1;

  for (const p of pages) {
    for (const paragraph of p.text.split(/\n\n+/)) {
      const clean = paragraph.replace(/\s+/g, ' ').trim();
      if (!clean) continue; // skip blank lines

      if (buffer.length + clean.length > maxChars && buffer) {
        chunks.push({ text: buffer.trim(), page: bufferPage });
        buffer = buffer.slice(-overlapChars); // carry the tail into the next chunk
        bufferPage = p.page;
      }
      buffer += (buffer ? ' ' : '') + clean;
    }
  }
  if (buffer.trim()) chunks.push({ text: buffer.trim(), page: bufferPage });
  return chunks;
}

Why chunk this way:

  • Chunks are paragraph-aligned and never exceed ~800 tokens — small enough that retrieval is precise, large enough that the LLM has context.
  • The 400-character overlap means a sentence split across two chunks is still matched in full by either side.
  • page is tracked per chunk, so the answer UI can link “page N”. To make chunks smaller (faster retrieval, more tokens saved) lower maxChars; to skip overlap entirely set overlapChars = 0.

7. Embedding

lib/embeddings.ts:

import OpenAI from 'openai';

export const openai = new OpenAI(); // reads OPENAI_API_KEY from .env

export async function embed(text: string): Promise<number[]> {
  const res = await openai.embeddings.create({
    model: process.env.EMBEDDING_MODEL ?? 'text-embedding-3-small',
    input: text,
  });
  return res.data[0].embedding;
}
  • new OpenAI() picks up OPENAI_API_KEY from the environment automatically — no key in code.
  • Want to switch to a cheaper/larger model? Set EMBEDDING_MODEL in .env and recreate the chunks table with the matching vector(n) size in schema.sql.

8. Upload + ingest route

app/api/upload/route.ts:

import { NextRequest, NextResponse } from 'next/server';
import { v4 as uuid } from 'uuid';
import { pool } from '@/lib/db';
import { parsePdfIntoPages } from '@/lib/pdf';
import { chunkPages } from '@/lib/chunker';
import { embed } from '@/lib/embeddings';

export async function POST(req: NextRequest) {
  try {
    const form = await req.formData();
    const file = form.get('file');
    if (!(file instanceof File) || file.type !== 'application/pdf') {
      return NextResponse.json({ error: 'a PDF file is required' }, { status: 400 });
    }
    const limit = Number(process.env.MAX_FILE_BYTES ?? 10 * 1024 * 1024);
    if (file.size > limit) {
      return NextResponse.json({ error: `file exceeds ${limit} byte limit` }, { status: 413 });
    }

    const buffer = Buffer.from(await file.arrayBuffer());
    const pages = await parsePdfIntoPages(buffer);
    if (!pages.length || pages.every((p) => p.text.length < 50)) {
      return NextResponse.json(
        { error: 'no extractable text found — this is likely a scanned PDF (see OCR note)' },
        { status: 422 }
      );
    }

    const chunks = chunkPages(pages);
    const docId = uuid();

    const client = await pool.connect();
    try {
      await client.query('BEGIN');
      await client.query('INSERT INTO documents (id, name, page_count) VALUES ($1, $2, $3)', [
        docId,
        file.name,
        pages.length,
      ]);
      for (const c of chunks) {
        const embedding = await embed(c.text);
        await client.query(
          'INSERT INTO chunks (doc_id, page, chunk, embedding) VALUES ($1, $2, $3, $4)',
          [docId, c.page, c.text, JSON.stringify(embedding)]
        );
      }
      await client.query('COMMIT');
    } catch (err) {
      await client.query('ROLLBACK');
      throw err;
    } finally {
      client.release();
    }

    return NextResponse.json({ ok: true, docId, pages: pages.length, chunks: chunks.length });
  } catch (err) {
    console.error('upload failed', err);
    return NextResponse.json({ error: 'internal error' }, { status: 500 });
  }
}

What each part does:

  • The file.type !== 'application/pdf' and size checks reject wrong uploads before any work — this is the checklist’s “file size + type limits” implemented.
  • The empty-text check catches scanned PDFs early and returns 422 with a readable message instead of storing garbage embeddings.
  • The transaction (BEGIN/COMMIT) means a mid-ingest failure can’t leave a document with half its chunks — either the whole document lands or none of it.
  • uuid() creates the document id; without it you’d have to guess ids later.

Add uuid to the install list from step 1: npm i uuid.

9. Retrieval + chat route

app/api/chat/route.ts:

import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { pool } from '@/lib/db';
import { embed, openai } from '@/lib/embeddings';

const bodySchema = z.object({
  docId: z.string().uuid(),
  question: z.string().min(3).max(2000),
});

export async function POST(req: NextRequest) {
  try {
    const parsed = bodySchema.safeParse(await req.json());
    if (!parsed.success) {
      return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
    }
    const { docId, question } = parsed.data;

    const embedding = await embed(question);

    // Vector search: closest chunks of THIS document only.
    const { rows } = await pool.query(
      `SELECT chunk, page, 1 - (embedding <=> $2::vector) AS score
         FROM chunks
        WHERE doc_id = $1
        ORDER BY embedding <=> $2::vector
        LIMIT 6`,
      [docId, JSON.stringify(embedding)]
    );

    const excerpts = rows
      .map((r) => `(page ${r.page}) ${r.chunk}`)
      .join('\n\n');

    const res = await openai.chat.completions.create({
      model: process.env.CHAT_MODEL ?? 'gpt-4o-mini',
      temperature: 0.2,
      messages: [
        {
          role: 'system',
          content:
            'Answer from the document excerpts only. Cite "page N" for every claim. If the answer is not in the excerpts, reply exactly: "Not found in this PDF".',
        },
        { role: 'user', content: `Excerpts:\n${excerpts}\n\nQuestion: ${question}` },
      ],
    });

    return NextResponse.json({
      answer: res.choices[0].message.content,
      sources: rows.map((r) => ({ page: r.page, score: Number(r.score) })),
    });
  } catch (err) {
    console.error('chat failed', err);
    return NextResponse.json({ error: 'internal error' }, { status: 500 });
  }
}

What this does, step by step:

  • zod validates docId (must be a real UUID) and question (3–2000 chars) before anything runs — bad input gets 400, not a cascading error.
  • The question is embedded into the same 1536-dimension space as the chunks, then <=> (cosine distance operator in pgvector) finds the 6 closest chunks. The WHERE doc_id = $1 filter is what keeps retrieval isolated to the requested document.
  • Chunks are interpolated into the prompt with their page numbers, and the system prompt requires “page N” citations and an honest “Not found” escape — combines to cut hallucinated citations.
  • sources also returns scores, so the UI can show confidence.
  • Model selection is config: set CHAT_MODEL in .env to switch models without touching code.

10. The UI

Minimal but real — /app/page.tsx:

'use client';
import { useState } from 'react';

export default function Home() {
  const [docId, setDocId] = useState('');
  const [question, setQuestion] = useState('');
  const [answer, setAnswer] = useState('');

  async function upload(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const fd = new FormData(e.currentTarget);
    const res = await fetch('/api/upload', { method: 'POST', body: fd });
    setDocId((await res.json()).docId);
  }

  async function ask() {
    const res = await fetch('/api/chat', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ docId, question }),
    });
    setAnswer((await res.json()).answer);
  }

  return (
    <main style={{ maxWidth: 640, margin: 'auto', padding: 24 }}>
      <h1>Chat with your PDF</h1>
      <form onSubmit={upload}>
        <input name="file" type="file" accept="application/pdf" required />
        <button>Upload</button>
      </form>
      <p>Document id: {docId}</p>
      <textarea value={question} onChange={(e) => setQuestion(e.target.value)} rows={3} />
      <button onClick={ask}>Ask</button>
      <pre style={{ whiteSpace: 'pre-wrap' }}>{answer}</pre>
    </main>
  );
}

The page stores the returned docId and sends it with every question — that id is the only thing tying a question to a document. A real product would store it per accounts (auth is out of scope here; see the checklist note).

11. Run locally

export $(grep -v '^#' .env | xargs)   # load DATABASE_URL, OPENAI_API_KEY, MAX_FILE_BYTES
npm run dev

Then open http://localhost:3000, upload a PDF, and ask a question about its contents. Watch the terminal: you’ll see the SQL queries and can copy the vector-search SQL to tweak LIMIT 6 if answers lack context.

12. Dockerize and deploy

Dockerfile (uses the standalone output — enable it in next.config.ts with output: 'standalone'):

FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci

FROM node:22-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build

FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]

On the VPS, extend the docker-compose.yml with an app service pointing at the build, plus Caddy:

  app:
    build: .
    environment:
      DATABASE_URL: postgres://postgres:dev@db:5432/pdfchat
      OPENAI_API_KEY: ${OPENAI_API_KEY}
    depends_on:
      db: { condition: service_healthy }
    restart: unless-stopped

  caddy:
    image: caddy:2
    ports: ["80:80", "443:443"]
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro

Caddyfile:

YOUR-DOMAIN.com {
	reverse_proxy app:3000
}

Open the db port only for the host (localhost) — never expose 5432 publicly in production.

Cost (assumptions: 500 docs, 3k questions/mo)

Service Monthly cost
VPS 2 GB $4.49
LLM (gpt-4o-mini, ~1.5M tokens) $3
Embeddings (text-embedding-3-small) $1
Total ≈ $8.50/month

Token math: ~500 docs × 3200 chars ≈ 1.1M embed tokens once, plus ~3k questions × ~450 tokens in/out each month. Recompute with your own volume — both prices are per-token, not fixed.

Production checklist

  • Size + type limits (done: MAX_FILE_BYTES, application/pdf check)
  • Document-level access control (add user accounts; every doc_id must belong to the requester before upload/chat)
  • Rate limit chat per user (in-memory or DB counter per account)
  • OCR for scanned PDFs (add when page text < 50 chars — see below)
  • Batch ingest queue for large files (upload should return a job id and process in background)
  • OPENAI_API_KEY in the server env only, never in client bundle

Common problems

  • Scanned PDFs return empty: pages parse to almost no text. Solution: OCR with tesseract.js or an OCR API — pipe the image through it and use that text instead of getTextContent().
  • Hallucinated citations: the system prompt already forces “page N” + “Not found”. Keep temperature at 0.2 and inspect sources scores — answers citing far-away chunks should be treated as low-confidence.
  • Vector dimension mismatch: vector(1536) in SQL must match your embedding model. text-embedding-3-small outputs 1536; changing models or setting a dimensions param means migrating the column.
  • Big PDFs time out: the request embeds every chunk synchronously. Move ingestion to a queue (Redis + jobs) and let upload return immediately.

Improvements

  • Multi-document chat (“compare this contract vs that one”) — retrieve across documents, ask the same question
  • Table extraction to CSV for the structured rows in your PDFs
  • Local embedding model (e.g. bge-small via Ollama) to drop the embeddings line of the cost table to $0
  • Streaming chat responses (ReadableStream) so answers render token by token

Conclusion

The PDF chat app is the canonical RAG project for a reason: one upload pipeline, one vector table, one LLM call per question. Every helper this guide claims is implemented above — parse, chunk, embed, retrieve, answer — runs on ~$8.50/month self-hosted, and every extension (OCR, comparisons, streaming) plugs into the same pipeline.

Related guides