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 yes in Redis) so restarts don’t lose jobs
  • Owner check: /api/jobs filters by user_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-parse returns no text; the worker catches it and marks needs_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_object plus a JSON.parse fallback 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.

Related guides