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.
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
| Approach | Pros | Cons | Dev Time |
|---|---|---|---|
@react-pdf/renderer | No browser, fast, serverless | Limited CSS, custom fonts tricky | 3โ5 days |
| Puppeteer/HTML โ PDF | Full CSS, WYSIWYG | Needs headless Chrome, cold starts | 2โ3 days |
| PDFKit (low-level) | Maximum control | No component model, tedious | 5โ7 days |
| Third-party (DocRaptor, etc.) | Hosted, reliable | $50โ200/mo | 1 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
- SaaS Billing Portal and Subscription Management
- React PDF Generation with @react-pdf/renderer
- AWS SES Transactional Email
- SaaS Usage-Based Billing
- AWS Secrets Manager for S3 Credentials
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.
About the Author
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.
Building a SaaS Product?
We've helped launch 50+ SaaS platforms. Let's build yours โ fast.
Free consultation โข No commitment โข Response within 24 hours
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.