Back to Blog

SaaS Invoice PDF Generation: React PDF, Puppeteer, S3 Storage, and Email Delivery

Generate professional invoice PDFs in your SaaS. Covers @react-pdf/renderer for server-side PDF generation, Puppeteer as an alternative, S3 storage with signed URLs, email delivery via Resend, and bulk PDF export.

Viprasol Tech Team
April 4, 2027
12 min read

Invoice PDF generation is a rite of passage for any SaaS with billing. The options range from simple (HTML-to-PDF with Puppeteer) to structured (React-based PDF layout with @react-pdf/renderer). Each has tradeoffs: Puppeteer gives you full CSS control but requires a headless browser; react-pdf gives you a component model but a limited layout engine.

This guide covers both approaches, S3 storage for generated PDFs, and the email delivery workflow.

Approach 1: @react-pdf/renderer

@react-pdf/renderer renders React components to PDF directly โ€” no browser needed, runs in Node.js serverlessly.

npm install @react-pdf/renderer

Invoice Template

// lib/pdf/invoice-template.tsx
import React from "react";
import {
  Document, Page, View, Text, StyleSheet, Image, Font,
} from "@react-pdf/renderer";

// Register a font if needed (optional โ€” uses built-in fonts by default)
Font.register({
  family: "Inter",
  fonts: [
    { src: "https://fonts.gstatic.com/s/inter/v13/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuLyfAZ9hiJ-Ek-_EeA.woff2", fontWeight: 400 },
    { src: "https://fonts.gstatic.com/s/inter/v13/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuGKYAZ9hiJ-Ek-_EeA.woff2", fontWeight: 700 },
  ],
});

const styles = StyleSheet.create({
  page: {
    fontFamily: "Helvetica",
    fontSize: 10,
    padding: "40 50",
    color: "#111827",
    backgroundColor: "#ffffff",
  },
  header: {
    flexDirection: "row",
    justifyContent: "space-between",
    marginBottom: 40,
  },
  logo: {
    fontSize: 20,
    fontWeight: "bold",
    color: "#2563eb",
  },
  invoiceLabel: {
    fontSize: 28,
    fontWeight: "bold",
    color: "#111827",
    textAlign: "right",
  },
  invoiceNumber: {
    fontSize: 11,
    color: "#6b7280",
    textAlign: "right",
    marginTop: 4,
  },
  section: {
    marginBottom: 24,
  },
  sectionLabel: {
    fontSize: 8,
    fontWeight: "bold",
    color: "#6b7280",
    textTransform: "uppercase",
    letterSpacing: 1,
    marginBottom: 6,
  },
  addressText: {
    fontSize: 10,
    color: "#374151",
    lineHeight: 1.5,
  },
  table: {
    marginTop: 24,
    marginBottom: 24,
  },
  tableHeader: {
    flexDirection: "row",
    backgroundColor: "#f9fafb",
    borderRadius: 4,
    padding: "8 10",
    marginBottom: 2,
  },
  tableRow: {
    flexDirection: "row",
    padding: "10 10",
    borderBottomWidth: 1,
    borderBottomColor: "#f3f4f6",
  },
  tableRowAlt: {
    flexDirection: "row",
    padding: "10 10",
    borderBottomWidth: 1,
    borderBottomColor: "#f3f4f6",
    backgroundColor: "#fafafa",
  },
  colDescription: { flex: 4, fontSize: 10, color: "#111827" },
  colQty:         { flex: 1, fontSize: 10, textAlign: "center", color: "#374151" },
  colRate:        { flex: 1.5, fontSize: 10, textAlign: "right", color: "#374151" },
  colAmount:      { flex: 1.5, fontSize: 10, textAlign: "right", color: "#111827", fontWeight: "bold" },
  headerText:     { fontSize: 9, fontWeight: "bold", color: "#6b7280", textTransform: "uppercase" },
  totalsSection: {
    alignItems: "flex-end",
    marginBottom: 32,
  },
  totalsRow: {
    flexDirection: "row",
    width: 200,
    justifyContent: "space-between",
    marginBottom: 6,
  },
  totalsLabel: { fontSize: 10, color: "#6b7280" },
  totalsValue: { fontSize: 10, color: "#111827" },
  totalDue: {
    flexDirection: "row",
    width: 200,
    justifyContent: "space-between",
    borderTopWidth: 2,
    borderTopColor: "#111827",
    paddingTop: 8,
    marginTop: 4,
  },
  totalDueLabel: { fontSize: 12, fontWeight: "bold", color: "#111827" },
  totalDueValue: { fontSize: 12, fontWeight: "bold", color: "#111827" },
  footer: {
    position: "absolute",
    bottom: 40,
    left: 50,
    right: 50,
    borderTopWidth: 1,
    borderTopColor: "#e5e7eb",
    paddingTop: 12,
    flexDirection: "row",
    justifyContent: "space-between",
  },
  footerText: { fontSize: 9, color: "#9ca3af" },
});

export interface InvoiceData {
  invoiceNumber: string;
  issueDate: string;
  dueDate: string;
  from: {
    companyName: string;
    address: string;
    city: string;
    country: string;
    email: string;
    taxId?: string;
  };
  to: {
    companyName: string;
    contactName?: string;
    address: string;
    city: string;
    country: string;
    email: string;
    taxId?: string;
  };
  lineItems: Array<{
    description: string;
    quantity: number;
    unitPrice: number;
    total: number;
  }>;
  currency: string;
  subtotal: number;
  tax?: { label: string; rate: number; amount: number };
  discount?: { label: string; amount: number };
  total: number;
  notes?: string;
  paymentTerms?: string;
}

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

export function InvoicePDF({ data }: { data: InvoiceData }) {
  return (
    <Document
      title={`Invoice ${data.invoiceNumber}`}
      author={data.from.companyName}
    >
      <Page size="A4" style={styles.page}>
        {/* Header */}
        <View style={styles.header}>
          <View>
            <Text style={styles.logo}>{data.from.companyName}</Text>
          </View>
          <View>
            <Text style={styles.invoiceLabel}>INVOICE</Text>
            <Text style={styles.invoiceNumber}>#{data.invoiceNumber}</Text>
          </View>
        </View>

        {/* From / To */}
        <View style={{ flexDirection: "row", justifyContent: "space-between", marginBottom: 32 }}>
          <View style={{ flex: 1 }}>
            <Text style={styles.sectionLabel}>From</Text>
            <Text style={styles.addressText}>{data.from.companyName}</Text>
            <Text style={styles.addressText}>{data.from.address}</Text>
            <Text style={styles.addressText}>{data.from.city}, {data.from.country}</Text>
            <Text style={styles.addressText}>{data.from.email}</Text>
            {data.from.taxId && <Text style={styles.addressText}>Tax ID: {data.from.taxId}</Text>}
          </View>
          <View style={{ flex: 1, alignItems: "flex-end" }}>
            <Text style={styles.sectionLabel}>Bill To</Text>
            <Text style={styles.addressText}>{data.to.companyName}</Text>
            {data.to.contactName && <Text style={styles.addressText}>{data.to.contactName}</Text>}
            <Text style={styles.addressText}>{data.to.address}</Text>
            <Text style={styles.addressText}>{data.to.city}, {data.to.country}</Text>
            <Text style={styles.addressText}>{data.to.email}</Text>
          </View>
        </View>

        {/* Dates */}
        <View style={{ flexDirection: "row", gap: 32, marginBottom: 24 }}>
          <View>
            <Text style={styles.sectionLabel}>Issue Date</Text>
            <Text style={styles.addressText}>{data.issueDate}</Text>
          </View>
          <View>
            <Text style={styles.sectionLabel}>Due Date</Text>
            <Text style={[styles.addressText, { fontWeight: "bold" }]}>{data.dueDate}</Text>
          </View>
          {data.paymentTerms && (
            <View>
              <Text style={styles.sectionLabel}>Payment Terms</Text>
              <Text style={styles.addressText}>{data.paymentTerms}</Text>
            </View>
          )}
        </View>

        {/* Line items */}
        <View style={styles.table}>
          <View style={styles.tableHeader}>
            <Text style={[styles.colDescription, styles.headerText]}>Description</Text>
            <Text style={[styles.colQty, styles.headerText]}>Qty</Text>
            <Text style={[styles.colRate, styles.headerText]}>Rate</Text>
            <Text style={[styles.colAmount, styles.headerText]}>Amount</Text>
          </View>
          {data.lineItems.map((item, i) => (
            <View key={i} style={i % 2 === 0 ? styles.tableRow : styles.tableRowAlt}>
              <Text style={styles.colDescription}>{item.description}</Text>
              <Text style={styles.colQty}>{item.quantity}</Text>
              <Text style={styles.colRate}>{formatCurrency(item.unitPrice, data.currency)}</Text>
              <Text style={styles.colAmount}>{formatCurrency(item.total, data.currency)}</Text>
            </View>
          ))}
        </View>

        {/* Totals */}
        <View style={styles.totalsSection}>
          <View style={styles.totalsRow}>
            <Text style={styles.totalsLabel}>Subtotal</Text>
            <Text style={styles.totalsValue}>{formatCurrency(data.subtotal, data.currency)}</Text>
          </View>
          {data.discount && (
            <View style={styles.totalsRow}>
              <Text style={styles.totalsLabel}>{data.discount.label}</Text>
              <Text style={styles.totalsValue}>-{formatCurrency(data.discount.amount, data.currency)}</Text>
            </View>
          )}
          {data.tax && (
            <View style={styles.totalsRow}>
              <Text style={styles.totalsLabel}>{data.tax.label} ({data.tax.rate}%)</Text>
              <Text style={styles.totalsValue}>{formatCurrency(data.tax.amount, data.currency)}</Text>
            </View>
          )}
          <View style={styles.totalDue}>
            <Text style={styles.totalDueLabel}>Total Due</Text>
            <Text style={styles.totalDueValue}>{formatCurrency(data.total, data.currency)}</Text>
          </View>
        </View>

        {/* Notes */}
        {data.notes && (
          <View style={styles.section}>
            <Text style={styles.sectionLabel}>Notes</Text>
            <Text style={{ fontSize: 9, color: "#6b7280", lineHeight: 1.5 }}>{data.notes}</Text>
          </View>
        )}

        {/* Footer */}
        <View style={styles.footer} fixed>
          <Text style={styles.footerText}>{data.from.companyName}</Text>
          <Text style={styles.footerText}>Invoice #{data.invoiceNumber}</Text>
          <Text style={styles.footerText} render={({ pageNumber, totalPages }) =>
            `Page ${pageNumber} of ${totalPages}`
          } />
        </View>
      </Page>
    </Document>
  );
}

Generate PDF and Upload to S3

// lib/pdf/generate-invoice.ts
import { renderToBuffer } from "@react-pdf/renderer";
import React from "react";
import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { InvoicePDF, type InvoiceData } from "./invoice-template";
import { prisma } from "@/lib/prisma";

const s3 = new S3Client({ region: process.env.AWS_REGION! });
const BUCKET = process.env.PDF_BUCKET!;

export async function generateAndStoreInvoice(
  invoiceId: string
): Promise<{ s3Key: string; downloadUrl: string }> {
  // Load invoice data from DB
  const invoice = await prisma.invoice.findUniqueOrThrow({
    where: { id: invoiceId },
    include: {
      lineItems: true,
      workspace: { select: { name: true, address: true, taxId: true } },
      customer: true,
    },
  });

  // Build InvoiceData
  const data: InvoiceData = {
    invoiceNumber: invoice.number,
    issueDate: invoice.issuedAt.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" }),
    dueDate: invoice.dueAt.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" }),
    from: {
      companyName: invoice.workspace.name,
      address: invoice.workspace.address ?? "",
      city: "",
      country: "",
      email: process.env.BILLING_EMAIL!,
      taxId: invoice.workspace.taxId ?? undefined,
    },
    to: {
      companyName: invoice.customer.companyName ?? invoice.customer.name,
      address: invoice.customer.billingAddress ?? "",
      city: "",
      country: invoice.customer.country ?? "",
      email: invoice.customer.email,
    },
    lineItems: invoice.lineItems.map((li) => ({
      description: li.description,
      quantity: li.quantity,
      unitPrice: li.unitPriceCents,
      total: li.totalCents,
    })),
    currency: invoice.currency,
    subtotal: invoice.subtotalCents,
    tax: invoice.taxCents > 0
      ? { label: "Tax", rate: invoice.taxRate, amount: invoice.taxCents }
      : undefined,
    total: invoice.totalCents,
    notes: invoice.notes ?? undefined,
    paymentTerms: "Net 30",
  };

  // Generate PDF buffer
  const pdfBuffer = await renderToBuffer(React.createElement(InvoicePDF, { data }));

  // Upload to S3
  const s3Key = `invoices/${invoice.workspaceId}/${invoice.number}.pdf`;
  await s3.send(new PutObjectCommand({
    Bucket: BUCKET,
    Key: s3Key,
    Body: pdfBuffer,
    ContentType: "application/pdf",
    ContentDisposition: `attachment; filename="Invoice-${invoice.number}.pdf"`,
    Metadata: {
      invoiceId,
      workspaceId: invoice.workspaceId,
    },
  }));

  // Generate signed download URL (valid 7 days)
  const downloadUrl = await getSignedUrl(
    s3,
    new GetObjectCommand({ Bucket: BUCKET, Key: s3Key }),
    { expiresIn: 7 * 24 * 60 * 60 }
  );

  // Update invoice record with S3 key
  await prisma.invoice.update({
    where: { id: invoiceId },
    data: { pdfS3Key: s3Key, pdfGeneratedAt: new Date() },
  });

  return { s3Key, downloadUrl };
}

export async function getInvoiceDownloadUrl(invoiceId: string): Promise<string> {
  const invoice = await prisma.invoice.findUniqueOrThrow({ where: { id: invoiceId } });

  if (!invoice.pdfS3Key) {
    const { downloadUrl } = await generateAndStoreInvoice(invoiceId);
    return downloadUrl;
  }

  // Regenerate signed URL (they expire)
  return getSignedUrl(
    s3,
    new GetObjectCommand({ Bucket: BUCKET, Key: invoice.pdfS3Key }),
    { expiresIn: 7 * 24 * 60 * 60 }
  );
}

๐Ÿš€ SaaS MVP in 8 Weeks โ€” Seriously

We have launched 50+ SaaS platforms. Multi-tenant architecture, Stripe billing, auth, role-based access, and cloud deployment โ€” all handled by one senior team.

  • Week 1โ€“2: Architecture design + wireframes
  • Week 3โ€“6: Core features built + tested
  • Week 7โ€“8: Launch-ready on AWS/Vercel with CI/CD
  • Post-launch: Maintenance plans from month 3

API Route: Download Invoice

// app/api/invoices/[id]/pdf/route.ts
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { getInvoiceDownloadUrl } from "@/lib/pdf/generate-invoice";
import { prisma } from "@/lib/prisma";

export async function GET(
  req: NextRequest,
  { params }: { params: { id: string } }
) {
  const session = await auth();
  if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

  // Verify invoice belongs to user's workspace
  const invoice = await prisma.invoice.findFirst({
    where: { id: params.id, workspaceId: session.user.organizationId },
  });
  if (!invoice) return NextResponse.json({ error: "Not found" }, { status: 404 });

  const url = await getInvoiceDownloadUrl(params.id);

  // Redirect to signed S3 URL
  return NextResponse.redirect(url);
}

Email Invoice to Customer

// lib/pdf/email-invoice.ts
import { resend } from "@/lib/resend";
import { generateAndStoreInvoice } from "./generate-invoice";
import { prisma } from "@/lib/prisma";

export async function emailInvoiceToCustomer(invoiceId: string): Promise<void> {
  const invoice = await prisma.invoice.findUniqueOrThrow({
    where: { id: invoiceId },
    include: { customer: true, workspace: true },
  });

  // Generate (or use cached) PDF
  const { downloadUrl } = await generateAndStoreInvoice(invoiceId);

  await resend.emails.send({
    from: `${invoice.workspace.name} <billing@yourapp.com>`,
    to: invoice.customer.email,
    subject: `Invoice ${invoice.number} from ${invoice.workspace.name}`,
    html: `
      <p>Hi ${invoice.customer.name},</p>
      <p>Please find your invoice ${invoice.number} for $${(invoice.totalCents / 100).toFixed(2)} attached.</p>
      <p><a href="${downloadUrl}" style="background:#2563eb;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;">Download Invoice</a></p>
      <p>Due date: ${invoice.dueAt.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })}</p>
      <p>Thank you for your business.</p>
    `,
  });

  await prisma.invoice.update({
    where: { id: invoiceId },
    data: { sentAt: new Date() },
  });
}

๐Ÿ’ก The Difference Between a SaaS Demo and a SaaS Business

Anyone can build a demo. We build SaaS products that handle real load, real users, and real payments โ€” with architecture that does not need to be rewritten at 1,000 users.

  • Multi-tenant PostgreSQL with row-level security
  • Stripe subscriptions, usage billing, annual plans
  • SOC2-ready infrastructure from day one
  • We own zero equity โ€” you own everything

Cost and Timeline Estimates

ApproachProsConsDev Time
@react-pdf/rendererNo browser, fast, serverlessLimited CSS, custom fonts tricky3โ€“5 days
Puppeteer/HTML โ†’ PDFFull CSS, WYSIWYGNeeds headless Chrome, cold starts2โ€“3 days
PDFKit (low-level)Maximum controlNo component model, tedious5โ€“7 days
Third-party (DocRaptor, etc.)Hosted, reliable$50โ€“200/mo1 day

Infrastructure cost:

  • S3 storage: ~$0.023/GB/month (1K invoices โ‰ˆ 50MB โ‰ˆ $0.001/month)
  • Lambda/serverless PDF generation: ~$0.01 per 1K invocations
  • EC2 Puppeteer: $15โ€“30/mo for t3.small dedicated instance

See Also


Working With Viprasol

Invoice PDFs need to look professional, generate reliably at scale, and deliver to customers' inboxes without manual intervention. Our team builds invoice generation pipelines with React PDF templates, S3-backed storage, signed download URLs, and automatic email delivery triggered by payment events.

What we deliver:

  • @react-pdf/renderer invoice template with your branding
  • Server-side PDF generation in Next.js API route or Lambda
  • S3 upload with ContentDisposition for clean downloads
  • Pre-signed URL generation with configurable expiry
  • Resend email delivery with inline download button

Talk to our team about your invoice generation pipeline โ†’

Or explore our SaaS development services.

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Building a SaaS Product?

We've helped launch 50+ SaaS platforms. Let's build yours โ€” fast.

Free consultation โ€ข No commitment โ€ข Response within 24 hours

Viprasol ยท AI Agent Systems

Add AI automation to your SaaS product?

Viprasol builds custom AI agent crews that plug into any SaaS workflow โ€” automating repetitive tasks, qualifying leads, and responding across every channel your customers use.