PDF generation is a solved problem with three competing philosophies. PDF-from-JSX using react-pdf (2020+) lets you write invoices as React components — props in, PDF bytes out, declarative, version-controlled. Puppeteer (headless Chrome) renders any HTML/CSS to PDF — you write normal HTML, boot a browser, capture the page. DocRaptor is the outsourced API: send HTML, get PDF back, no infra. Each trades off complexity, deploy weight, and flexibility. Velocity X ships react-pdf by default because most SaaS PDFs are branded invoices and receipts — structured data layouts, not rich HTML fidelity. But when you need pixel-perfect CSS or complex charts, Puppeteer wins. DocRaptor is the escape hatch when neither fits. Here's the architecture, code, decision matrix, and 4 production patterns.
Why PDF Generation Matters
PDFs are legal anchors. An invoice PDF is a business record — audit trail, tax proof, customer reference. The rendering must be reliable, consistent, and fast. Email a 10MB Puppeteer container every time? No. Call a third-party API for every receipt? Costs add up. Write invoice PDFs in React and cache them? Yes. PDFs also compress — that invoice email attachment is 200KB, not 5MB. The system you pick shapes your deploy footprint, request latency, and billing (especially if you scale to 10K invoices/day).
react-pdf: JSX-Driven PDFs
react-pdf is a library that renders React components to PDFs. No browser. No HTML intermediate. Just JSX → PDF. Installation:
npm install @react-pdf/renderer
Write an invoice component:
// lib/pdfs/InvoicePDF.tsx
import React from 'react';
import { Document, Page, Text, View, StyleSheet } from '@react-pdf/renderer';
const styles = StyleSheet.create({
page: { padding: 40, fontFamily: 'Helvetica' },
header: { fontSize: 24, fontWeight: 'bold', marginBottom: 20, color: '#1f2937' },
section: { marginBottom: 20 },
label: { fontSize: 10, color: '#6b7280', fontWeight: 'bold' },
value: { fontSize: 12, color: '#1f2937', marginTop: 4 },
table: { display: 'flex', width: '100%', borderStyle: 'solid', borderWidth: 1, borderColor: '#e5e7eb', marginVertical: 20 },
row: { display: 'flex', flexDirection: 'row', borderBottomWidth: 1, borderColor: '#e5e7eb' },
rowHeader: { display: 'flex', flexDirection: 'row', borderBottomWidth: 2, borderColor: '#374151', backgroundColor: '#f3f4f6' },
cell: { flex: 1, padding: 8, fontSize: 10 },
cellRight: { flex: 1, padding: 8, fontSize: 10, textAlign: 'right' },
total: { fontSize: 14, fontWeight: 'bold', color: '#1f2937', marginTop: 20, textAlign: 'right' },
});
interface InvoiceItem {
description: string;
quantity: number;
rate: number;
}
interface InvoicePDFProps {
invoiceNumber: string;
issueDate: string;
dueDate: string;
clientName: string;
clientEmail: string;
items: InvoiceItem[];
total: number;
companyName: string;
companyEmail: string;
brandColor: string;
}
export const InvoicePDF = ({
invoiceNumber,
issueDate,
dueDate,
clientName,
clientEmail,
items,
total,
companyName,
companyEmail,
brandColor,
}: InvoicePDFProps) => (
<Document>
<Page size="A4" style={styles.page}>
<View style={{ ...styles.header, color: brandColor }}>
Invoice {invoiceNumber}
</View>
<View style={{ display: 'flex', flexDirection: 'row', marginBottom: 40 }}>
<View style={{ flex: 1 }}>
<View style={styles.section}>
<Text style={styles.label}>FROM</Text>
<Text style={styles.value}>{companyName}</Text>
<Text style={styles.value}>{companyEmail}</Text>
</View>
<View style={styles.section}>
<Text style={styles.label}>ISSUE DATE</Text>
<Text style={styles.value}>{issueDate}</Text>
</View>
</View>
<View style={{ flex: 1 }}>
<View style={styles.section}>
<Text style={styles.label}>TO</Text>
<Text style={styles.value}>{clientName}</Text>
<Text style={styles.value}>{clientEmail}</Text>
</View>
<View style={styles.section}>
<Text style={styles.label}>DUE DATE</Text>
<Text style={styles.value}>{dueDate}</Text>
</View>
</View>
</View>
<View style={styles.table}>
<View style={styles.rowHeader}>
<Text style={styles.cell}>Description</Text>
<Text style={styles.cellRight}>Qty</Text>
<Text style={styles.cellRight}>Rate</Text>
<Text style={styles.cellRight}>Total</Text>
</View>
{items.map((item, idx) => (
<View key={idx} style={styles.row}>
<Text style={styles.cell}>{item.description}</Text>
<Text style={styles.cellRight}>{item.quantity}</Text>
<Text style={styles.cellRight}>${item.rate.toFixed(2)}</Text>
<Text style={styles.cellRight}>${(item.quantity * item.rate).toFixed(2)}</Text>
</View>
))}
</View>
<Text style={styles.total}>Total: ${total.toFixed(2)}</Text>
<View style={{ marginTop: 40, paddingTop: 20, borderTopWidth: 1, borderColor: '#e5e7eb' }}>
<Text style={{ fontSize: 10, color: '#6b7280' }}>
Thank you for your business. Payment terms: Net {dueDate}.
</Text>
</View>
</Page>
</Document>
);
export default InvoicePDF;
Then render it to bytes in a function:
// netlify/functions/generate-invoice.ts
import { renderToBuffer } from '@react-pdf/renderer';
import { InvoicePDF } from '../../lib/pdfs/InvoicePDF';
export default async (event: any) => {
try {
const { invoiceNumber, clientName, items, total, brandColor } = JSON.parse(event.body);
const pdf = await renderToBuffer(
InvoicePDF({
invoiceNumber,
issueDate: new Date().toLocaleDateString(),
dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toLocaleDateString(),
clientName,
clientEmail: 'client@example.com',
items,
total,
companyName: 'Velocity Inc.',
companyEmail: 'hello@velocity9.dev',
brandColor: brandColor || '#6366f1',
})
);
return {
statusCode: 200,
headers: { 'Content-Type': 'application/pdf', 'Content-Disposition': 'attachment; filename=invoice.pdf' },
body: pdf.toString('base64'),
isBase64Encoded: true,
};
} catch (err: any) {
console.error('PDF generation failed:', err.message);
return {
statusCode: 500,
body: JSON.stringify({ error: err.message }),
};
}
};
Key wins: pure JavaScript, no browser, lightweight (adds ~5MB to your bundle), props-driven (brand color from config), type-safe (TypeScript). Trade-off: react-pdf is limited to basic layouts — no complex CSS (Grid, positioned elements), no images beyond base64, no fonts beyond PDFs bundled set. But for invoices, receipts, SOAs? Perfect.
Puppeteer: Browser-Rendered PDFs
Puppeteer is headless Chrome. You write HTML, boot a browser, navigate to it, capture the page as PDF. Pixel-perfect fidelity — any CSS that works in Chrome renders identically in PDF. Installation (or use Lambda layer):
npm install puppeteer
Write a normal HTML page:
// lib/pdfs/ReportHTML.ts
export const generateReportHTML = ({ title, data, brandColor }: { title: string; data: any[]; brandColor: string }) => `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
padding: 40px;
color: #1f2937;
background: white;
}
h1 {
color: ${brandColor};
margin-bottom: 30px;
font-size: 28px;
}
.chart {
width: 100%;
height: 400px;
margin-bottom: 40px;
background: linear-gradient(135deg, ${brandColor}20 0%, ${brandColor}40 100%);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
color: #666;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
th {
background: #f3f4f6;
padding: 12px;
text-align: left;
border-bottom: 2px solid ${brandColor};
font-weight: 600;
}
td {
padding: 10px 12px;
border-bottom: 1px solid #e5e7eb;
}
tr:last-child td {
border-bottom: none;
}
</style>
</head>
<body>
<h1>${title}</h1>
<div class="chart">Chart visualization (embed SVG/canvas here)</div>
<table>
<thead>
<tr>
<th>Metric</th>
<th>Value</th>
</tr>
</thead>
<tbody>
${data.map((row) => \`<tr><td>\${row.label}</td><td>\${row.value}</td></tr>\`).join('')}
</tbody>
</table>
</body>
</html>
`;
Then render via Puppeteer:
// netlify/functions/generate-report.ts
import puppeteer from 'puppeteer';
import { generateReportHTML } from '../../lib/pdfs/ReportHTML';
export default async (event: any) => {
let browser;
try {
const { title, data, brandColor } = JSON.parse(event.body);
browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
const page = await browser.newPage();
const html = generateReportHTML({ title, data, brandColor });
await page.setContent(html, { waitUntil: 'networkidle0' });
const pdf = await page.pdf({
format: 'A4',
margin: { top: '20px', right: '20px', bottom: '20px', left: '20px' },
});
await browser.close();
return {
statusCode: 200,
headers: { 'Content-Type': 'application/pdf', 'Content-Disposition': 'attachment; filename=report.pdf' },
body: Buffer.from(pdf).toString('base64'),
isBase64Encoded: true,
};
} catch (err: any) {
if (browser) await browser.close();
console.error('Report generation failed:', err.message);
return {
statusCode: 500,
body: JSON.stringify({ error: err.message }),
};
}
};
Key wins: CSS parity with Chrome (Grid, Flexbox, animations all work), images as URLs, web fonts, SVG charts. Trade-off: Puppeteer adds 200–400MB to your Netlify function bundle (zipped via Lambda layers, still heavy). Cold starts can be slow (2–5 seconds to boot Chrome). Not ideal for 1000s of PDFs/day on tiny budgets. Best for: complex reports, styled charts, pixel-perfect layouts.
DocRaptor: Outsourced PDF API
DocRaptor is the escape hatch. Send HTML + CSS, get PDF back. No browser on your machine, no react-pdf limitations. Let them manage the infrastructure.
// netlify/functions/generate-via-docraptor.ts
import fetch from 'node-fetch';
export default async (event: any) => {
try {
const { html, title } = JSON.parse(event.body);
const response = await fetch('https://docraptor.com/docs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_credentials: process.env.DOCRAPTOR_API_KEY,
doc: {
document_content: html,
name: title,
document_type: 'pdf',
},
}),
});
if (!response.ok) {
throw new Error(\`DocRaptor API error: \${response.statusText}\`);
}
const pdf = await response.buffer();
return {
statusCode: 200,
headers: { 'Content-Type': 'application/pdf', 'Content-Disposition': \`attachment; filename=\${title}.pdf\` },
body: pdf.toString('base64'),
isBase64Encoded: true,
};
} catch (err: any) {
console.error('DocRaptor generation failed:', err.message);
return {
statusCode: 500,
body: JSON.stringify({ error: err.message }),
};
}
};
Pricing is per-document (~$0.01–0.05 per PDF depending on tier). No infra overhead — good for occasional reports. Bad for 10K invoices/month (costs balloon). Best for: complex, highly-styled PDFs where you can't fit into react-pdf or Puppeteer constraints.
Decision Matrix
Use react-pdf if: invoices, receipts, SOAs, simple structured documents, no complex CSS, deploy size matters. Use Puppeteer if: complex reports, charts, CSS-heavy layouts, you can tolerate 2–5s cold starts, you version control your templates. Use DocRaptor if: one-off complex PDFs, cost-per-doc is acceptable, you don't want infra.
Six FAQs
Can I embed images in react-pdf PDFs?
Yes, but only base64-encoded or file:// URLs (local dev). Not HTTP URLs directly. Convert images: const img = await fetch(url).then(r => r.buffer()).then(b => 'data:image/png;base64,' + b.toString('base64')). For production, preload images or use a CDN-to-base64 utility.
How do I cache PDFs so I don't regenerate every time?
Store the PDF bytes in Supabase Storage or S3, keyed by invoice ID + hash of data. On request, check cache first. Regenerate only on data change. For Netlify: store in KV (Blobs API). Cache-busting: version the hash in your component (e.g. componentVersion: 2).
Can I use custom fonts in react-pdf?
Yes, via Font.register(). Font.register({ family: 'Inter', src: '/fonts/Inter-Regular.ttf' }) before rendering. Make sure the TTF/OTF file is bundled or available at runtime. For Netlify functions, include fonts in your bundle or fetch from CDN before rendering.
What's the latency hit for Puppeteer vs react-pdf?
react-pdf: ~50–200ms (just serialization). Puppeteer: 1–5 seconds (cold start + browser overhead). DocRaptor: 200ms–2s (network + API). Cache aggressively if latency matters.
Can I watermark PDFs?
react-pdf: use a background image or overlay text in your component. Puppeteer: add a CSS pseudo-element with position:fixed, opacity, z-index. DocRaptor: same CSS approach. All three support watermarks — it's just CSS/layout.
How do I test PDF generation locally?
react-pdf: renderToFile() to disk, open in your PDF viewer. Puppeteer: same, plus you can screenshot the page before PDF to debug rendering. DocRaptor: call the API with mock HTML, inspect returned bytes. Use Jest + snapshot testing to catch layout regressions.
The Bottom Line
PDF generation is routine infrastructure by 2026. Pick your stack based on fidelity and cost: react-pdf for invoices (lightweight, fast), Puppeteer for reports (CSS fidelity, heavier), DocRaptor for outlier cases (cost acceptable, infra unwanted). Velocity X defaults to react-pdf for every SaaS because most PDFs are branded receipts and structured data — no browser needed. But when your report needs a gradient background or a chart, Puppeteer wins. Set up caching immediately (PDFs are idempotent — cache aggressively). For email delivery of PDFs, see React Email templates for attachment patterns. Ready to add PDF generation to your SaaS? Check Velocity X template scaffolding for production-ready PDF components.