How to Extract Data from PDF with Node.js: A Complete Developer Guide (2026)
March 16, 2026 · Updated
The Problem With PDF Parsing in Node.js
PDFs are everywhere in production systems. Invoices, bank statements, contracts, insurance forms — businesses run on documents, and developers are constantly asked to extract structured data from them.
The Node.js ecosystem has several libraries for this. Most of them are frustrating to use for anything beyond simple text extraction.
Here's what you're actually dealing with:
- pdf-parse — extracts raw text, but returns it as one flat string. You have to write regex to find your fields. Works okay for simple docs. Fails badly on scanned documents (which have no embedded text — just image layers).
- pdfjs-dist — the Mozilla PDF renderer, ported to Node. Can extract text with position data, which helps. But scanned PDFs still return nothing useful without a separate OCR step.
- tesseract.js — open-source OCR you can run locally. Good for simple scans. Accuracy drops fast on handwriting, rotated pages, low-DPI scans, or anything with a complex layout.
- AWS Textract / Google Document AI — production-grade OCR, but you're now managing AWS credentials, IAM roles, or GCP service accounts. Textract returns raw key-value pairs that still need significant post-processing. Not a 5-minute integration.
This guide walks through all three tiers: raw library parsing, self-hosted OCR, and a REST API (Dokyumi) that handles OCR and structured extraction in one call.
Option 1: pdf-parse (Simple PDFs Only)
npm install pdf-parse
import pdfParse from 'pdf-parse';
import fs from 'fs';
async function extractText(filePath: string): Promise<string> {
const dataBuffer = fs.readFileSync(filePath);
const data = await pdfParse(dataBuffer);
return data.text;
}
// Usage
const text = await extractText('./invoice.pdf');
console.log(text);
// Returns: flat string with newlines. All formatting collapsed.
// "Acme Corp\nInvoice #1042\nDate: 2026-01-15\nTotal: $1,847.50"
Then you still need to parse the string:
function extractInvoiceTotal(rawText: string): string | null {
const match = rawText.match(/Total[:\s]+\$?([\d,]+\.\d{2})/i);
return match ? match[1] : null;
}
const total = extractInvoiceTotal(text); // "1847.50" (maybe)
The regex approach works until it doesn't. The moment a vendor changes their template, or you get a scanned PDF instead of a digital one, it breaks. And you need different regexes for every document type.
Option 2: pdfjs-dist (Text with Position Data)
For documents where field positions matter (like tables), pdfjs-dist gives you more control:
npm install pdfjs-dist
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.js';
interface TextItem {
str: string;
transform: number[]; // [scaleX, skewX, skewY, scaleY, x, y]
}
async function getTextWithPositions(filePath: string) {
const data = new Uint8Array(fs.readFileSync(filePath));
const doc = await pdfjsLib.getDocument({ data }).promise;
const page = await doc.getPage(1);
const content = await page.getTextContent();
return content.items
.filter((item): item is TextItem => 'str' in item)
.map(item => ({
text: item.str,
x: item.transform[4],
y: item.transform[5],
}));
}
This gives you positional text, which you can use to reconstruct tables or find fields by coordinate. It's a lot more code to maintain and still fails completely on scanned documents.
Option 3: tesseract.js (OCR for Scanned Docs)
If you need to handle scanned PDFs, you need OCR. tesseract.js runs Tesseract locally:
npm install tesseract.js pdf-to-img
import Tesseract from 'tesseract.js';
import { pdf } from 'pdf-to-img';
async function ocrPdf(filePath: string): Promise<string> {
const pages = await pdf(filePath, { scale: 2 });
let fullText = '';
for await (const page of pages) {
const { data: { text } } = await Tesseract.recognize(page, 'eng', {
logger: () => {}, // suppress verbose output
});
fullText += text + '\n';
}
return fullText;
}
This works. But Tesseract accuracy on real-world documents — low DPI scans, skewed pages, forms with checkboxes, mixed fonts — ranges from okay to bad. You'll spend a lot of time tuning and you still end up with raw text that you have to parse with regexes.
Option 4: Dokyumi REST API (OCR + Structured Extraction in One Call)
Dokyumi handles OCR and schema-guided field extraction in one request. The response includes structured data, confidence, and validation details, so check a review result before using it; you do not need to write OCR-text parsing or positional-coordinate logic.
Setup:
- Sign up at dokyumi.com (free tier: 25 credits/month; one credit covers a document up to five pages)
- Create a schema — describe your document and the fields you want
- Generate an API key
- Call the API
Basic extraction:
import FormData from 'form-data';
import fs from 'fs';
import fetch from 'node-fetch';
interface ExtractionResult {
id: string;
status: 'completed' | 'review';
schema: string;
data: Record<string, unknown>;
confidence: Record<string, number>;
validation: {
valid: boolean;
errors: unknown[];
low_confidence_fields: string[];
};
meta: {
processing_time_ms: number;
page_count: number;
credits_used: number;
ocr_cached: boolean;
model: string;
};
request_id: string;
}
async function extractFromPdf(
filePath: string,
schemaSlug: string,
apiKey: string
): Promise<ExtractionResult> {
const form = new FormData();
form.append('file', fs.createReadStream(filePath), {
filename: 'document.pdf',
contentType: 'application/pdf',
});
form.append('schema', schemaSlug);
const response = await fetch('https://dokyumi.com/api/v1/extract', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
...form.getHeaders(),
},
body: form,
});
if (!response.ok) {
const error = await response.json();
throw new Error(`Extraction failed: ${JSON.stringify(error)}`);
}
return response.json() as Promise<ExtractionResult>;
}
// Usage
const result = await extractFromPdf(
'./invoice.pdf',
'invoice-standard',
'dk_live_your_key_here'
);
if (result.status === 'review') {
console.warn(result.validation.errors, result.validation.low_confidence_fields);
}
console.log('Data:', result.data);
// {
// vendor_name: "Acme Corp",
// invoice_number: "INV-1042",
// invoice_date: "2026-01-15",
// due_date: "2026-02-15",
// subtotal: 1650.00,
// tax_amount: 132.00,
// total_amount: 1782.00,
// currency: "USD"
// }
console.log(result.confidence);
// { vendor_name: 0.98, invoice_number: 0.99, total_amount: 0.97, ... }
No regex. No text parsing. Works on both digital PDFs and scanned documents.
Handling Multiple Document Types
If you're processing different document types — invoices from one folder, bank statements from another — create a separate schema for each type:
const SCHEMAS = {
invoice: 'invoice-standard',
bankStatement: 'bank-statement-v2',
contract: 'contract-nda',
} as const;
type DocumentType = keyof typeof SCHEMAS;
async function processDocument(
filePath: string,
docType: DocumentType,
apiKey: string
) {
const result = await extractFromPdf(filePath, SCHEMAS[docType], apiKey);
if (result.status === 'review') {
console.warn(`Review required for ${filePath}`, {
errors: result.validation.errors,
lowConfidenceFields: result.validation.low_confidence_fields,
});
return { ...result, needsReview: true };
}
return result.data;
}
Batch Processing Multiple PDFs
import FormData from 'form-data';
import fs from 'fs';
import path from 'path';
import fetch from 'node-fetch';
import { glob } from 'glob';
interface BatchResult {
file: string;
success: boolean;
status?: 'completed' | 'review';
data?: Record<string, unknown>;
confidence?: Partial<Record<string, number>>;
validation?: ExtractionResult['validation'];
error?: string;
}
const sleep = (milliseconds: number) =>
new Promise<void>(resolve => setTimeout(resolve, milliseconds));
class StartRateLimiter {
private nextStartAt = 0;
private gate: Promise<void> = Promise.resolve();
private readonly intervalMs: number;
constructor(requestsPerMinute: number) {
if (!Number.isFinite(requestsPerMinute) || requestsPerMinute <= 0) {
throw new Error('requestsPerMinute must be positive');
}
this.intervalMs = Math.ceil(60_000 / requestsPerMinute);
}
async waitForStart(): Promise<void> {
let release!: () => void;
const previous = this.gate;
this.gate = new Promise<void>(resolve => { release = resolve; });
await previous;
try {
const waitMs = Math.max(0, this.nextStartAt - Date.now());
if (waitMs > 0) await sleep(waitMs);
this.nextStartAt = Date.now() + this.intervalMs;
} finally {
release();
}
}
}
async function extractForBatch(
filePath: string,
schemaSlug: string,
apiKey: string,
limiter: StartRateLimiter,
maxRateLimitRetries = 3
): Promise<ExtractionResult> {
for (let attempt = 0; ; attempt += 1) {
await limiter.waitForStart();
// A retry needs a new stream and multipart body.
const form = new FormData();
form.append('file', fs.createReadStream(filePath), {
filename: path.basename(filePath),
contentType: 'application/pdf',
});
form.append('schema', schemaSlug);
const response = await fetch('https://dokyumi.com/api/v1/extract', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
...form.getHeaders(),
},
body: form,
});
if (response.ok) {
return response.json() as Promise<ExtractionResult>;
}
const errorBody = await response.json().catch(() => ({})) as {
error?: string;
code?: string;
};
if (
response.status === 429 &&
errorBody.code === 'rate_limited' &&
attempt < maxRateLimitRetries
) {
const headerSeconds = Number(response.headers.get('retry-after'));
const retryAfterMs = Number.isFinite(headerSeconds) && headerSeconds > 0
? headerSeconds * 1000
: 1000;
await sleep(retryAfterMs);
continue;
}
throw new Error(
`Dokyumi HTTP ${response.status} ${errorBody.code || 'unknown_error'}: ` +
(errorBody.error || 'Request failed')
);
}
}
async function batchExtract(
directory: string,
schemaSlug: string,
apiKey: string,
concurrency = 3,
requestsPerMinute = 60
): Promise<BatchResult[]> {
if (!Number.isInteger(concurrency) || concurrency < 1) {
throw new Error('concurrency must be a positive integer');
}
const files = await glob('**/*.pdf', { cwd: directory, absolute: true });
const results = new Array<BatchResult>(files.length);
const limiter = new StartRateLimiter(requestsPerMinute);
let cursor = 0;
async function worker(): Promise<void> {
while (true) {
const index = cursor;
cursor += 1;
if (index >= files.length) return;
const file = files[index];
try {
const result = await extractForBatch(
file,
schemaSlug,
apiKey,
limiter
);
results[index] = {
file: path.basename(file),
success: true,
status: result.status,
data: result.data,
confidence: result.confidence,
validation: result.validation,
};
} catch (error) {
results[index] = {
file: path.basename(file),
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
}
const workerCount = Math.min(concurrency, files.length);
await Promise.all(Array.from({ length: workerCount }, () => worker()));
return results;
}
const results = await batchExtract(
'./invoices',
'invoice-standard',
'dk_live_your_key_here',
3,
60 // Replace with this API key's configured requests-per-minute limit.
);
console.log(`Processed: ${results.filter(r => r.success).length}/${results.length}`);
With Express: Building a Document Processing Endpoint
If you're building a backend service that accepts document uploads:
import express from 'express';
import multer from 'multer';
import FormData from 'form-data';
import fetch from 'node-fetch';
const app = express();
const upload = multer({ storage: multer.memoryStorage() });
app.post('/process-document', upload.single('file'), async (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
const { schema } = req.body;
if (!schema) {
return res.status(400).json({ error: 'Schema slug required' });
}
try {
const form = new FormData();
form.append('file', req.file.buffer, {
filename: req.file.originalname,
contentType: req.file.mimetype,
});
form.append('schema', schema);
const response = await fetch('https://dokyumi.com/api/v1/extract', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.DOKYUMI_API_KEY}`,
...form.getHeaders(),
},
body: form,
});
if (!response.ok) {
const err = await response.json();
return res.status(response.status).json(err);
}
const result = await response.json();
res.json(result);
} catch (err) {
console.error('Document processing error:', err);
res.status(500).json({ error: 'Processing failed' });
}
});
app.listen(3000, () => console.log('Document processor running on :3000'));
Comparison: When to Use What
| Approach | Best for | Handles scanned docs? | Setup time | Maintenance |
|---|---|---|---|---|
| pdf-parse | Digital PDFs, simple extraction | ❌ No | 5 min | High (regex upkeep) |
| pdfjs-dist | Position-aware parsing | ❌ No | 30 min | High (coordinate logic) |
| tesseract.js | Simple scans, offline/local only | ✅ (limited accuracy) | 1-2 hrs | High (accuracy tuning) |
| Dokyumi API | Schema-defined fields from supported PDF and image inputs | ✅ Full OCR pipeline | 5 min | None |
Performance Notes
- Request timing: Direct extraction is synchronous, and latency varies with page count, input quality, OCR work, and model processing. Set a client timeout appropriate to your workload and measure representative files before choosing worker timeouts.
- Async patterns: For direct API batches, use a job queue (Bull, BullMQ) with bounded concurrency and store each synchronous response. Webhook delivery is separate: it applies to documents submitted through a configured upload site, and every plan includes at least one site.
- Review handling: A successful
reviewresult can reflectvalidation.errors,validation.low_confidence_fields, or both. Inspect both arrays and route the result to human verification before downstream use.
Getting Started
- Sign up at dokyumi.com — free tier includes 25 credits/month; one credit covers a document up to five pages
- Create a schema — describe your document and required fields in plain English, then review the suggested field names and types.
- Generate an API key from the dashboard
- Test with curl first:
curl -X POST https://dokyumi.com/api/v1/extract \
-H "Authorization: Bearer dk_live_YOUR_KEY" \
-F "file=@invoice.pdf" \
-F "schema=YOUR_SCHEMA_SLUG"
- Drop in the TypeScript wrapper above and you're processing PDFs.
If you're coming from a Python stack and need the equivalent guide, see How to Extract Data from PDF with Python.
Continue this path
These articles are selected from the same editorial cluster, not generated from keyword overlap.
Put build and ship api pipelines to work
Confirm request fields, response data, validation, confidence, and webhook signing.
See how schema, endpoint, confidence review, and ledger delivery fit together.
Create a schema and test the pipeline against a real source document.
Test the extraction on your own documents
25 free credits each month. One credit covers a document up to 5 pages; self-serve documents can be up to 50 pages. No credit card required.