Skip to content

Backend

PDF Generation in 2026 — react-pdf vs Puppeteer vs DocRaptor

Every SaaS ships PDFs — invoices, receipts, SOAs. Three approaches dominate 2026: react-pdf (JSX-driven, lightweight), Puppeteer (HTML/CSS renderer, browser-level fidelity), DocRaptor (API outsourcing). We default to react-pdf on every Velocity build. Here's how to pick.

📄 ⚙️

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.

Let us make some quick suggestions?

Please provide your full name.
Please provide your phone number.
Please provide a valid phone number.
Please provide your email address.
Please provide a valid email address.
Please provide your brand name or website.
Please provide your brand name or website.