Back to Blog

How to Build a PDF Generation System for Invoices and Reports

How to Build a PDF Generation System for Invoices and Reports

Every business application eventually needs to generate PDFs. Invoices, contracts, reports, receipts, certificates — the moment your product handles money or data, someone will ask for a downloadable document. We have built PDF generation into several of our products, and the right approach depends entirely on what kind of documents you need and how many of them you generate.

This post covers the three main approaches we have used: React-PDF for programmatic document creation, browser-based rendering with Puppeteer for pixel-perfect HTML-to-PDF conversion, and hosted services for when you do not want to manage the infrastructure. We will walk through real code examples from our invoice generation system.

The Three Approaches

React-PDF (@react-pdf/renderer) — You build documents using React components that map to PDF primitives (pages, text, views, images). The output is a PDF, not HTML. You have full control over layout, but you are not using CSS as you know it. Best for structured documents like invoices and reports.

Browser rendering (Puppeteer/Playwright) — You render an HTML page and print it to PDF. You get full CSS support, including flexbox, grid, and web fonts. Best for complex layouts or when you already have an HTML template. Heavier to run in production.

Hosted services (html-pdf-service, DocRaptor, PDFShift) — You send HTML, they return a PDF. Zero infrastructure on your end. Best when you generate PDFs infrequently and do not want to manage headless browsers.

We use React-PDF for 90% of our PDF work. Here is why.

React-PDF: Our Go-To Approach

React-PDF lets you define documents as React components. The mental model is similar to building UI, but instead of rendering to the DOM, you render to a PDF buffer. This means your PDF templates are version-controlled, composable, and testable like any other code.

Here is a simplified version of the invoice template we built for LancerSpace:

import { Document, Page, Text, View, StyleSheet, Font } from "@react-pdf/renderer";

Font.register({
  family: "Inter",
  fonts: [
    { src: "/fonts/Inter-Regular.ttf", fontWeight: 400 },
    { src: "/fonts/Inter-SemiBold.ttf", fontWeight: 600 },
    { src: "/fonts/Inter-Bold.ttf", fontWeight: 700 },
  ],
});

const styles = StyleSheet.create({
  page: {
    fontFamily: "Inter",
    fontSize: 10,
    padding: 40,
    color: "#1a1a1a",
  },
  header: {
    flexDirection: "row",
    justifyContent: "space-between",
    marginBottom: 40,
  },
  title: {
    fontSize: 24,
    fontWeight: 700,
    color: "#111827",
  },
  invoiceInfo: {
    alignItems: "flex-end",
  },
  label: {
    fontSize: 8,
    color: "#6b7280",
    textTransform: "uppercase",
    letterSpacing: 0.5,
    marginBottom: 2,
  },
  tableHeader: {
    flexDirection: "row",
    borderBottomWidth: 1,
    borderBottomColor: "#e5e7eb",
    paddingBottom: 8,
    marginBottom: 8,
  },
  tableRow: {
    flexDirection: "row",
    paddingVertical: 6,
    borderBottomWidth: 1,
    borderBottomColor: "#f3f4f6",
  },
  colDescription: { flex: 3 },
  colQty: { flex: 1, textAlign: "right" },
  colRate: { flex: 1, textAlign: "right" },
  colAmount: { flex: 1, textAlign: "right" },
  totalRow: {
    flexDirection: "row",
    justifyContent: "flex-end",
    marginTop: 16,
    paddingTop: 12,
    borderTopWidth: 2,
    borderTopColor: "#111827",
  },
});

type InvoiceItem = {
  description: string;
  quantity: number;
  rate: number;
};

type InvoiceData = {
  number: string;
  date: string;
  due_date: string;
  from: { name: string; address: string; email: string };
  to: { name: string; address: string; email: string };
  items: InvoiceItem[];
  currency: string;
  notes?: string;
};

function InvoiceDocument({ invoice }: { invoice: InvoiceData }) {
  const subtotal = invoice.items.reduce((sum, item) => sum + item.quantity * item.rate, 0);

  return (
    <Document>
      <Page size="A4" style={styles.page}>
        <View style={styles.header}>
          <View>
            <Text style={styles.title}>INVOICE</Text>
            <Text style={{ marginTop: 4, color: "#6b7280" }}>{invoice.from.name}</Text>
          </View>
          <View style={styles.invoiceInfo}>
            <Text style={styles.label}>Invoice Number</Text>
            <Text style={{ fontWeight: 600 }}>{invoice.number}</Text>
            <Text style={[styles.label, { marginTop: 8 }]}>Date</Text>
            <Text>{invoice.date}</Text>
            <Text style={[styles.label, { marginTop: 8 }]}>Due Date</Text>
            <Text style={{ fontWeight: 600 }}>{invoice.due_date}</Text>
          </View>
        </View>

        {/* Bill To */}
        <View style={{ marginBottom: 30 }}>
          <Text style={styles.label}>Bill To</Text>
          <Text style={{ fontWeight: 600 }}>{invoice.to.name}</Text>
          <Text style={{ color: "#6b7280" }}>{invoice.to.address}</Text>
          <Text style={{ color: "#6b7280" }}>{invoice.to.email}</Text>
        </View>

        {/* Line Items Table */}
        <View style={styles.tableHeader}>
          <Text style={[styles.colDescription, { fontWeight: 600 }]}>Description</Text>
          <Text style={[styles.colQty, { fontWeight: 600 }]}>Qty</Text>
          <Text style={[styles.colRate, { fontWeight: 600 }]}>Rate</Text>
          <Text style={[styles.colAmount, { fontWeight: 600 }]}>Amount</Text>
        </View>

        {invoice.items.map((item, i) => (
          <View key={i} style={styles.tableRow}>
            <Text style={styles.colDescription}>{item.description}</Text>
            <Text style={styles.colQty}>{item.quantity}</Text>
            <Text style={styles.colRate}>
              {invoice.currency} {item.rate.toFixed(2)}
            </Text>
            <Text style={[styles.colAmount, { fontWeight: 600 }]}>
              {invoice.currency} {(item.quantity * item.rate).toFixed(2)}
            </Text>
          </View>
        ))}

        {/* Total */}
        <View style={styles.totalRow}>
          <Text style={{ fontSize: 12, fontWeight: 700 }}>
            Total: {invoice.currency} {subtotal.toFixed(2)}
          </Text>
        </View>

        {/* Notes */}
        {invoice.notes && (
          <View style={{ marginTop: 40 }}>
            <Text style={styles.label}>Notes</Text>
            <Text style={{ color: "#6b7280", lineHeight: 1.5 }}>{invoice.notes}</Text>
          </View>
        )}
      </Page>
    </Document>
  );
}

Invoice template document with structured line items and totals

Generating the PDF on the Server

React-PDF works both on the server and in the browser. For server-side generation (which is what you want for API endpoints, email attachments, and batch jobs), use renderToBuffer or renderToStream:

import { renderToBuffer } from "@react-pdf/renderer";

async function generateInvoicePdf(invoice: InvoiceData): Promise<Buffer> {
  const buffer = await renderToBuffer(<InvoiceDocument invoice={invoice} />);
  return Buffer.from(buffer);
}

// API route handler
async function handleInvoiceDownload(req: Request) {
  const invoiceId = getParam(req, "id");
  const invoice = await getInvoice(invoiceId);

  if (!invoice) {
    return new Response("Not found", { status: 404 });
  }

  const pdf = await generateInvoicePdf(invoice);

  return new Response(pdf, {
    headers: {
      "Content-Type": "application/pdf",
      "Content-Disposition": `attachment; filename="invoice-${invoice.number}.pdf"`,
    },
  });
}

For LancerSpace, freelancers generate invoices directly from their project data. The invoice template pulls line items from the project’s logged hours and expenses, formats them with the client’s preferred currency, and outputs a professional PDF in under 200ms.

When to Use Puppeteer Instead

React-PDF has limitations. Its layout engine does not support CSS grid, position: absolute is limited, and complex designs require more effort than they would in HTML/CSS. If you already have an HTML template that looks exactly right, or if your document requires complex visual layouts, Puppeteer is the better choice.

import puppeteer from "puppeteer";

async function htmlToPdf(html: string): Promise<Buffer> {
  const browser = await puppeteer.launch({
    headless: true,
    args: ["--no-sandbox", "--disable-setuid-sandbox"],
  });

  const page = await browser.newPage();
  await page.setContent(html, { waitUntil: "networkidle0" });

  const pdf = await page.pdf({
    format: "A4",
    margin: { top: "1cm", right: "1cm", bottom: "1cm", left: "1cm" },
    printBackground: true,
  });

  await browser.close();
  return Buffer.from(pdf);
}

The trade-off is infrastructure complexity. Puppeteer requires a headless Chromium instance, which is 200+ MB and needs careful resource management. In serverless environments, you need a Chromium layer (like @sparticuz/chromium for AWS Lambda) or a dedicated service.

We use Puppeteer for report generation where the layout needs charts, complex tables, or branded styling that would be painful to replicate in React-PDF. For straightforward documents like invoices, receipts, and contracts, React-PDF is simpler and faster.

Printed report documents stacked for review and distribution

Building Report Templates

Reports differ from invoices in two ways: they usually span multiple pages, and they often include dynamic sections that appear or disappear based on the data. React-PDF handles multi-page documents naturally — content flows across pages automatically.

Here is a pattern for a multi-section report:

function ReportDocument({ report }: { report: ReportData }) {
  return (
    <Document>
      <Page size="A4" style={styles.page}>
        {/* Cover page */}
        <View style={styles.coverPage}>
          <Text style={styles.reportTitle}>{report.title}</Text>
          <Text style={styles.reportDate}>
            {report.start_date} - {report.end_date}
          </Text>
          <Text style={styles.reportClient}>{report.client_name}</Text>
        </View>
      </Page>

      {/* Summary section */}
      <Page size="A4" style={styles.page}>
        <Text style={styles.sectionTitle}>Executive Summary</Text>
        <Text style={styles.body}>{report.summary}</Text>

        <View style={styles.metricsGrid}>
          {report.metrics.map((metric) => (
            <View key={metric.label} style={styles.metricCard}>
              <Text style={styles.metricValue}>{metric.value}</Text>
              <Text style={styles.metricLabel}>{metric.label}</Text>
            </View>
          ))}
        </View>
      </Page>

      {/* Dynamic sections */}
      {report.sections.map((section) => (
        <Page key={section.id} size="A4" style={styles.page}>
          <Text style={styles.sectionTitle}>{section.title}</Text>
          <Text style={styles.body}>{section.content}</Text>
          {section.table && <DataTable headers={section.table.headers} rows={section.table.rows} />}
        </Page>
      ))}
    </Document>
  );
}

Batch Generation and Background Jobs

When users need to generate dozens or hundreds of PDFs (monthly invoice runs, quarterly reports, bulk exports), you need a background job system. Generating PDFs synchronously in an API request is a recipe for timeouts.

import PgBoss from "pg-boss";

const boss = new PgBoss(DATABASE_URL);

// Queue a batch of invoice PDFs
async function queueMonthlyInvoices(month: string) {
  const invoices = await getInvoicesForMonth(month);

  const jobs = invoices.map((invoice) => ({
    name: "generate-invoice-pdf",
    data: { invoiceId: invoice.id },
  }));

  await boss.insert(jobs);
}

// Worker that processes the queue
boss.work("generate-invoice-pdf", { teamConcurrency: 5 }, async (job) => {
  const invoice = await getInvoice(job.data.invoiceId);
  const pdf = await generateInvoicePdf(invoice);

  // Upload to storage
  const url = await uploadToStorage(`invoices/${invoice.number}.pdf`, pdf);

  // Update the invoice record with the PDF URL
  await updateInvoice(invoice.id, { pdf_url: url, pdf_generated_at: new Date() });

  // Optionally send via email
  if (invoice.auto_send) {
    await sendInvoiceEmail(invoice, pdf);
  }
});

We limit concurrency to prevent memory spikes. Each PDF generation takes 50-200ms for React-PDF and 1-3 seconds for Puppeteer, but the memory footprint is what matters. Five concurrent React-PDF renders use around 200MB; five concurrent Puppeteer instances can use 1GB+.

Storage and Caching

PDFs should be generated once and stored. Regenerating the same invoice every time someone downloads it wastes compute and risks producing different output if the template or data changed.

Our pattern is:

  1. Generate the PDF
  2. Upload to object storage (S3, Cloudflare R2, or Supabase Storage)
  3. Store the URL and generation timestamp on the record
  4. Serve downloads from storage with signed URLs
async function getInvoicePdf(invoiceId: string): Promise<string> {
  const invoice = await getInvoice(invoiceId);

  // Return cached PDF if it exists and is current
  if (invoice.pdf_url && invoice.pdf_generated_at > invoice.updated_at) {
    return getSignedUrl(invoice.pdf_url);
  }

  // Generate fresh PDF
  const pdf = await generateInvoicePdf(invoice);
  const path = `invoices/${invoice.number}-${Date.now()}.pdf`;
  const url = await uploadToStorage(path, pdf);

  await updateInvoice(invoiceId, {
    pdf_url: url,
    pdf_generated_at: new Date(),
  });

  return getSignedUrl(url);
}

The pdf_generated_at > invoice.updated_at check is important. If the invoice data changes after the PDF was generated, we regenerate. Otherwise, we serve the cached version. This keeps downloads fast while ensuring accuracy.

Document automation system processing files in a digital workflow

Handling Edge Cases

Currency formatting. Never format currencies yourself. Use Intl.NumberFormat:

function formatCurrency(amount: number, currency: string, locale: string = "en-US"): string {
  return new Intl.NumberFormat(locale, {
    style: "currency",
    currency,
    minimumFractionDigits: 2,
  }).format(amount);
}

Page breaks. React-PDF supports break props on views and has wrap={false} to prevent an element from being split across pages. Use these to keep tables and sections from breaking in awkward places.

Fonts. Register all fonts before rendering. Missing fonts silently fall back to Helvetica, which will look wrong if your design uses a different typeface. Bundle font files with your application rather than loading them from URLs.

Large tables. If a table has hundreds of rows, it will span many pages. Add page numbers with the render prop on Page to help readers navigate, and repeat the table header on each page.

Right-to-left languages. React-PDF does not handle RTL natively. If you need Arabic or Hebrew invoices, Puppeteer with proper HTML/CSS direction support is the better choice.

Our Recommendation

Start with React-PDF for invoices, receipts, contracts, and structured reports. It is fast, lightweight, works in serverless environments, and keeps your templates in code. Move to Puppeteer only when you need complex visual layouts that would be painful to build with React-PDF’s layout primitives.

Store generated PDFs in object storage. Use background jobs for batch generation. Cache aggressively but regenerate when source data changes.

If you need a PDF generation system for your product — whether it is invoices for a freelancer platform like LancerSpace, booking confirmations for a service marketplace like MindHyv, or custom reports for a SaaS dashboard — reach out at [email protected].