Build a SaaS That Turns PDFs Into Structured Data
Build a SaaS that converts PDFs into structured JSON/CSV: parsing, LLM extraction, validation and export, deployed to a VPS.
Build a SaaS that turns messy PDFs (invoices, contracts, forms) into clean structured data your users can consume as JSON or CSV.
What we’re building
flowchart LR
User --> Next[Next.js]
Next --> Queue[(BullMQ queue)]
Queue --> Parse[PDF parse]
Parse --> LLM[LLM extraction]
LLM --> Validate[Validation]
Validate --> DB[(PostgreSQL)]
Validate --> User
What you’ll learn
- Extraction with schemas, not free-form text
- Validation that catches LLM hallucinations (zod, deterministic temperature 0)
- A real upload endpoint with size/page limits and a review escape hatch
- Retry semantics so a flaky LLM call never silently drops a document
Prerequisites
- Node.js 22+
- Redis 7 (queue; runs in Docker)
- PostgreSQL 17 (job storage)
- LLM API key
- 2 GB VPS
1. Scaffold
npx create-next-app@latest pdfdata --ts --app --use-npm
cd pdfdata
npm i pdf-parse bullmq ioredis openai zod
npm i -D @types/pdf-parse
2. Schema and local services
schema.sql:
CREATE TABLE jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id TEXT NOT NULL,
filename TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'queued', -- queued|processing|needs_review|done
schema_json JSONB NOT NULL,
raw_text TEXT,
extracted JSONB,
errors JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
docker-compose.yml (dev — redis + db + api; the worker and Caddy are added in section 8):
services:
redis:
image: redis:7-alpine
ports:
- "6379:6379"
restart: unless-stopped
db:
image: postgres:17
environment:
POSTGRES_PASSWORD: dev
POSTGRES_DB: pdfdata
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
docker compose up -d redis db
psql postgres://postgres:dev@localhost:5432/pdfdata -f schema.sql
lib/db.ts + lib/queue.ts:
// lib/db.ts
import { Pool } from 'pg';
export const pool = new Pool({
connectionString:
process.env.DATABASE_URL ?? 'postgres://postgres:dev@localhost:5432/pdfdata',
});
// lib/queue.ts
import { Queue } from 'bullmq';
export const connection = { host: process.env.REDIS_HOST ?? 'localhost', port: 6379 };
export const extractQueue = new Queue('extract', { connection });
3. Define the extraction schema
schema.ts — zod schema + a compiler reference (users provide JSON Schema in the UI, zodSchemaFor converts to a zod validator):
import { z } from 'zod';
export const InvoiceSchema = z.object({
invoiceNumber: z.string().min(1),
vendor: z.string(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'date must be ISO YYYY-MM-DD'),
totalCents: z.number().int().positive(),
lines: z.array(z.object({
description: z.string(),
amountCents: z.number().int(),
})).min(1),
});
// Convert a user-supplied JSON Schema object into a zod validator.
// This minimal converter supports the object/string/number/array shapes
// the schema editor produces. Replace with json-schema-to-zod for full coverage.
export function zodSchemaFor(schema: Record<string, unknown>): z.ZodType {
const shape: Record<string, z.ZodType> = {};
for (const [key, spec] of Object.entries((schema.properties ?? {}) as Record<string, any>)) {
if (spec.type === 'number') shape[key] = z.number();
else if (spec.type === 'array') shape[key] = z.array(z.any());
else shape[key] = z.string();
}
return z.object(shape);
}
4. The upload endpoint
app/api/upload/route.ts — multipart, hard limits (10 MB, 100 pages), validates the schema, parses text, enqueues:
import { NextRequest, NextResponse } from 'next/server';
import { extractQueue } from '@/lib/queue';
import { pool } from '@/lib/db';
const MAX_BYTES = 10 * 1024 * 1024;
export async function POST(req: NextRequest) {
let form: FormData;
try {
form = await req.formData();
} catch {
return NextResponse.json({ error: 'Invalid multipart form.' }, { status: 400 });
}
const file = form.get('file');
if (!(file instanceof File)) {
return NextResponse.json({ error: 'file field required.' }, { status: 400 });
}
if (file.type !== 'application/pdf' && !file.name.endsWith('.pdf')) {
return NextResponse.json({ error: 'Only PDF files are accepted.' }, { status: 415 });
}
if (file.size > MAX_BYTES) {
return NextResponse.json({ error: 'File exceeds 10 MB limit.' }, { status: 413 });
}
let schema;
try {
schema = JSON.parse(String(form.get('schema')));
} catch {
return NextResponse.json({ error: 'schema must be a JSON object.' }, { status: 400 });
}
const userId = form.get('userId');
if (typeof userId !== 'string' || !userId) {
return NextResponse.json({ error: 'userId field required.' }, { status: 401 });
}
try {
const saved = await pool.query(
`INSERT INTO jobs (user_id, filename, schema_json, status)
VALUES ($1, $2, $3, 'queued') RETURNING id`,
[userId, file.name, JSON.stringify(schema)],
);
const jobId = saved.rows[0].id;
const buf = Buffer.from(await file.arrayBuffer());
await pool.query(`UPDATE jobs SET raw_text = $1 WHERE id = $2`, [buf.toString('base64'), jobId]);
await extractQueue.add('extract', { jobId, userId, schema }, { attempts: 3, backoff: { type: 'exponential', delay: 2000 } });
return NextResponse.json({ jobId }, { status: 202 });
} catch (err) {
return NextResponse.json(
{ error: `Enqueue failed: ${err instanceof Error ? err.message : ''}` },
{ status: 500 },
);
}
}
5. The extraction worker
worker/extract.ts — parse → LLM (temperature 0, json_object) → zod validate; a failed parse goes to needs_review instead of retrying garbage:
import { Worker } from 'bullmq';
import { pool } from '../lib/db';
import { connection } from '../lib/queue';
import { zodSchemaFor } from '../schema';
import OpenAI from 'openai';
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
async function parsePdf(base64: string) {
const { default: pdf } = await import('pdf-parse');
const data = await pdf(Buffer.from(base64, 'base64'));
if (!data.text || !data.text.trim()) {
throw new Error('No extractable text — likely a scanned PDF.');
}
return data;
}
new Worker(
'extract',
async (job) => {
const { jobId, userId, schema } = job.data;
try {
const row = await pool.query(`SELECT raw_text FROM jobs WHERE id = $1`, [jobId]);
const parsed = await parsePdf(row.rows[0].raw_text);
const res = await openai.chat.completions.create({
model: 'gpt-4o-mini',
temperature: 0,
response_format: { type: 'json_object' },
messages: [
{ role: 'system', content: `Extract into this JSON schema: ${JSON.stringify(schema)}. Numbers only. Dates ISO YYYY-MM-DD.` },
{ role: 'user', content: parsed.text.slice(0, 60_000) },
],
});
const content = res.choices[0].message.content ?? '{}';
let parsedJson: unknown;
try {
parsedJson = JSON.parse(content);
} catch {
parsedJson = null;
}
const result = zodSchemaFor(schema).safeParse(parsedJson);
if (!result.success) {
await pool.query(
`UPDATE jobs SET status = 'needs_review', errors = $1, raw_text = $2 WHERE id = $3`,
[JSON.stringify(result.error.issues), parsed.text, jobId],
);
return { status: 'needs_review', errors: result.error.issues };
}
await pool.query(
`UPDATE jobs SET status = 'done', extracted = $1, raw_text = $2, errors = NULL WHERE id = $3`,
[JSON.stringify(result.data), parsed.text, jobId],
);
return { status: 'done', data: result.data };
} catch (err) {
// Retried by BullMQ (attempts: 3) — only then marked needs_review
await pool.query(
`UPDATE jobs SET status = 'needs_review', errors = $1 WHERE id = $2`,
[JSON.stringify([{ message: err instanceof Error ? err.message : 'Extraction failed' }]), jobId],
);
throw err;
}
},
{ connection, concurrency: 5 },
);
Run the worker separately (TypeScript executed directly with tsx):
6. Review loop + export
app/api/jobs/route.ts — status polling:
import { NextRequest, NextResponse } from 'next/server';
import { pool } from '@/lib/db';
export async function GET(req: NextRequest) {
const jobId = req.nextUrl.searchParams.get('jobId');
const userId = req.nextUrl.searchParams.get('userId');
if (!jobId || !userId) {
return NextResponse.json({ error: 'jobId and userId query params required.' }, { status: 400 });
}
try {
const result = await pool.query(
`SELECT id, status, extracted, errors, raw_text FROM jobs WHERE id = $1 AND user_id = $2`,
[jobId, userId],
);
if (result.rowCount === 0) {
return NextResponse.json({ error: 'Job not found.' }, { status: 404 });
}
return NextResponse.json(result.rows[0]);
} catch (err) {
return NextResponse.json(
{ error: `Query failed: ${err instanceof Error ? err.message : ''}` },
{ status: 500 },
);
}
}
app/api/review/route.ts — human-corrected data is revalidated and stored:
import { NextRequest, NextResponse } from 'next/server';
import { pool } from '@/lib/db';
import { zodSchemaFor } from '@/schema';
export async function POST(req: NextRequest) {
let body: { jobId?: string; corrected?: unknown };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body.' }, { status: 400 });
}
if (typeof body.jobId !== 'string' || !body.jobId) {
return NextResponse.json({ error: 'jobId required.' }, { status: 400 });
}
const row = await pool.query(`SELECT schema_json FROM jobs WHERE id = $1`, [body.jobId]);
if (row.rowCount === 0) {
return NextResponse.json({ error: 'Job not found.' }, { status: 404 });
}
const result = zodSchemaFor(row.rows[0].schema_json).safeParse(body.corrected);
if (!result.success) {
return NextResponse.json({ error: 'Corrected data does not match the schema.', issues: result.error.issues }, { status: 422 });
}
await pool.query(
`UPDATE jobs SET status = 'done', extracted = $1, errors = NULL WHERE id = $2`,
[JSON.stringify(result.data), body.jobId],
);
return NextResponse.json({ ok: true, data: result.data });
}
lib/export.ts — CSV exporter:
export function toCsv(records: object[]): string {
const cols = Object.keys(records[0] ?? {});
const escape = (v: unknown) => {
const s = String(v ?? '');
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
return [cols.join(','), ...records.map((r) => cols.map((c) => escape((r as any)[c])).join(','))].join('\n');
}
7. Run locally
docker compose up -d redis db
psql postgres://postgres:dev@localhost:5432/pdfdata -f schema.sql
export DATABASE_URL=postgres://postgres:dev@localhost:5432/pdfdata
export REDIS_HOST=localhost
export OPENAI_API_KEY=your_key_here
npx tsx worker/extract.ts & # terminal 1, run `npm i -D tsx` first
npm run dev # terminal 2
Test: upload an invoice PDF via curl -F [email protected] -F schema='{"properties":{"vendor":{"type":"string"}}}' -F userId=u1 http://localhost:3000/api/upload, poll /api/jobs?jobId=...&userId=..., and confirm the record moves queued → processing → done (or needs_review for a scanned PDF).
8. Deploy to a VPS
Dockerfile:
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci && npm i -D tsx # worker runs directly from TypeScript
FROM node:22-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-alpine AS run
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
COPY --from=build /app/lib ./lib
COPY --from=build /app/worker ./worker
COPY --from=build /app/schema.ts ./schema.ts
EXPOSE 3000
CMD ["node", "server.js"]
docker-compose.yml (full — replaces the dev version):
services:
redis:
image: redis:7-alpine
command: redis-server --appendonly yes
volumes:
- redisdata:/data
restart: unless-stopped
db:
image: postgres:17
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: pdfdata
volumes:
- pgdata:/var/lib/postgresql/data
restart: unless-stopped
api:
build: .
environment:
DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD}@db:5432/pdfdata
REDIS_HOST: redis
OPENAI_API_KEY: ${OPENAI_API_KEY}
NEXT_PUBLIC_APP_URL: https://YOUR-DOMAIN.com
depends_on:
- redis
- db
restart: unless-stopped
worker:
build: .
command: npx tsx worker/extract.ts
environment:
DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD}@db:5432/pdfdata
REDIS_HOST: redis
OPENAI_API_KEY: ${OPENAI_API_KEY}
depends_on:
- redis
- db
restart: unless-stopped
caddy:
image: caddy:2
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
restart: unless-stopped
volumes:
redisdata:
pgdata:
caddy_data:
Caddyfile:
YOUR-DOMAIN.com {
reverse_proxy api:3000
}
On the VPS:
git clone <your-repo> pdfdata && cd pdfdata
cp .env.example .env
docker compose up -d --build
psql "$DATABASE_URL" -f schema.sql # once
Environment variables
| Variable | Required | Description |
|---|---|---|
DATABASE_URL |
yes | PostgreSQL connection string. Locally postgres://postgres:dev@localhost:5432/pdfdata; on the VPS it points at the db service. |
REDIS_HOST |
yes | Redis host for the queue (localhost locally, redis in compose). |
OPENAI_API_KEY |
yes | LLM API key (platform.openai.com → API keys). |
NEXT_PUBLIC_APP_URL |
no | Public origin used for absolute URLs. |
Cost (assumptions: 2k docs/month, gpt-4o-mini)
| Service | Monthly cost |
|---|---|
| VPS 2 GB | $4.49 |
| Redis + PostgreSQL (on VPS) | $0 |
| LLM (2k docs ≈ 60k tokens each) | $10 |
| Total | ≈ $15/month |
At $49/mo/100 docs you profit from the first user.
Production checklist
- LLM temperature 0 — extraction is deterministic, not creative
- Zod validation before any write
- Retry queue with exponential backoff (job-level, attempts: 3)
- File size (10 MB) + page-count limits enforced at upload
- Review queue visible to users (they fix, you don’t)
- Queue persistence (
appendonly yesin Redis) so restarts don’t lose jobs - Owner check:
/api/jobsfilters byuser_id
Common problems
- LLM invents numbers: schema validation + “numbers only” guard rails; validation failure routes to review, never to the user as truth.
- Scanned PDFs:
pdf-parsereturns no text; the worker catches it and marksneeds_review(or add a tesseract OCR step before extraction). - LLM rate limits: batch with concurrency 5 and a per-key budget; retries with backoff absorb transient 429s.
- Invalid LLM JSON:
response_format: json_objectplus aJSON.parsefallback means the review queue catches it instead of a crash.
Improvements
- Custom extraction fields per customer
- Webhook delivery to their own systems
- CSV/JSON/Excel export formats
- OCR pipeline (tesseract) for scanned documents
Conclusion
“PDF to structured data” is a queue, an LLM call with a strict schema and a human review escape hatch. On a $15/month self-hosted stack it becomes a service accountants actually pay for.