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’stext-embedding-3-smalloutput size. Change the embedding model and you must change this size too.ON DELETE CASCADE: deleting a document removes its chunks automatically.doc_idscoping 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(...).promiseloads 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 | grepstyle 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.
pageis tracked per chunk, so the answer UI can link “page N”. To make chunks smaller (faster retrieval, more tokens saved) lowermaxChars; to skip overlap entirely setoverlapChars = 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 upOPENAI_API_KEYfrom the environment automatically — no key in code.- Want to switch to a cheaper/larger model? Set
EMBEDDING_MODELin.envand recreate thechunkstable with the matchingvector(n)size inschema.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
422with 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:
zodvalidatesdocId(must be a real UUID) andquestion(3–2000 chars) before anything runs — bad input gets400, 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. TheWHERE doc_id = $1filter 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.
sourcesalso returns scores, so the UI can show confidence.- Model selection is config: set
CHAT_MODELin.envto 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/pdfcheck) - Document-level access control (add user accounts; every
doc_idmust 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_KEYin the server env only, never in client bundle
Common problems
- Scanned PDFs return empty: pages parse to almost no text. Solution: OCR with
tesseract.jsor an OCR API — pipe the image through it and use that text instead ofgetTextContent(). - Hallucinated citations: the system prompt already forces “page N” + “Not found”. Keep temperature at 0.2 and inspect
sourcesscores — 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-smalloutputs 1536; changing models or setting adimensionsparam 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.