ERP Invoice PDF Generation: Puppeteer + Handlebars in NestJS

Photo by Unsplash

Photo by Unsplash
Generating PDF invoices sounds like a solved problem—there are a dozen libraries for it. But in an Indonesian business context, the requirements layer on: the invoice must display amounts in Rupiah format (Rp 1.500.000, not 1,500,000), include the company's NPWP tax ID, compute PPN (Pajak Pertambahan Nilai, Indonesia's 11% VAT) correctly, and match the company's brand layout exactly. After evaluating pdf-lib, pdfkit, and jsPDF, I chose Puppeteer—render HTML with full CSS control, then print to PDF.
Pure PDF libraries (pdfkit, pdf-lib) give you programmatic control over every element, but building a visually polished invoice layout in code is painful—positioning, fonts, table borders, and column alignment all require manual coordinate math. With Puppeteer, you write the invoice as an HTML template using familiar CSS: flexbox for the header, CSS Grid for the line items table, standard font loading, and print media queries for page breaks. The rendered output looks exactly like a browser print preview.
Handlebars is the templating engine because it's logic-light, fast to compile, and supports helper functions for formatters. The invoice template (invoice.hbs) renders the company header with logo (embedded as base64), the customer billing address, a table of line items, a totals section with subtotal, PPN amount, and grand total, and a footer with payment terms and bank account details.
The Intl.NumberFormat API with locale 'id-ID' and currency 'IDR' produces the correct Rupiah format: periods as thousand separators, no decimal places for whole amounts (Rp 1.500.000). PPN is calculated at 11% on the subtotal—the rate changed from 10% to 11% in April 2022 per PMK-63/PMK.03/2022. The template also supports non-taxable transactions by making the PPN row conditional.
ERP Invoice PDF Generation Pipeline
─────────────────────────────────────
┌─────────────────┐
│ API Request │ GET /invoices/:id/pdf
│ (NestJS route) │
└────────┬────────┘
│
▼
┌─────────────────────────────────────┐
│ Invoice Service │
│ 1. Fetch invoice + line items │
│ 2. Fetch company branding config │
│ 3. Compute totals, tax (PPN 11%) │
│ 4. Format currency (Rp format) │
└────────┬────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Template Engine (Handlebars) │
│ invoice.hbs → HTML string │
│ - Company logo (base64 embedded) │
│ - Line items table │
│ - Totals + PPN calculation │
│ - Payment terms, bank account │
└────────┬────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Puppeteer / @sparticuz/chromium │
│ page.setContent(html) │
│ page.pdf({ format: 'A4', │
│ printBackground: true }) │
└────────┬────────────────────────────┘
│
▼
Buffer → res.setHeader('Content-Type', 'application/pdf')
→ res.send(pdfBuffer) (or upload to GCS)Embed your company logo as a base64-encoded data URI in the Handlebars template data rather than referencing it via a file path or URL. Puppeteer's page.setContent() doesn't always resolve relative paths or make network requests for external images, which can result in broken logos in the generated PDF.
Running Puppeteer on Cloud Run requires puppeteer-core (no bundled Chromium) plus @sparticuz/chromium, which provides a pre-built Chromium binary optimized for serverless environments. The chromium.executablePath() function returns the correct binary path whether you're running locally or in the container.
I use A4 format with 20mm top/bottom margins and 15mm left/right margins—standard for Indonesian business documents. The printBackground option must be true to render colored table headers. For multi-page invoices, CSS print media queries handle page breaks: table headers repeat on each page using thead, and page-break-inside: avoid on each table row prevents line items from splitting across pages.
// invoice-pdf.service.ts (NestJS)
import puppeteer from 'puppeteer-core';
import chromium from '@sparticuz/chromium';
import Handlebars from 'handlebars';
import { readFileSync } from 'fs';
import { join } from 'path';
@Injectable()
export class InvoicePdfService {
private template: HandlebarsTemplateDelegate;
constructor(private invoiceRepo: InvoiceRepository) {
const templatePath = join(__dirname, 'templates', 'invoice.hbs');
const source = readFileSync(templatePath, 'utf-8');
this.template = Handlebars.compile(source);
// Register Indonesian Rupiah formatter
Handlebars.registerHelper('rupiah', (value: number) =>
new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 0,
}).format(value)
);
}
async generatePdf(invoiceId: string): Promise<Buffer> {
const invoice = await this.invoiceRepo.findWithLineItems(invoiceId);
const subtotal = invoice.lineItems.reduce(
(sum, item) => sum + item.qty * item.unitPrice, 0
);
const tax = Math.round(subtotal * 0.11); // PPN 11%
const total = subtotal + tax;
const html = this.template({
invoice,
subtotal,
tax,
total,
printDate: new Date().toLocaleDateString('id-ID', {
day: 'numeric', month: 'long', year: 'numeric'
}),
});
const browser = await puppeteer.launch({
args: chromium.args,
executablePath: await chromium.executablePath(),
headless: chromium.headless,
});
try {
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle0' });
const pdf = await page.pdf({
format: 'A4',
printBackground: true,
margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
});
return Buffer.from(pdf);
} finally {
await browser.close();
}
}
}Regenerating a PDF on every download request is wasteful if the invoice hasn't changed. I cache generated PDFs in Cloud Storage: the first request generates and stores the PDF, and subsequent requests return the GCS signed URL directly. The cache is invalidated if the invoice is modified. For invoices in APPROVED status (locked from modification), the PDF can be cached indefinitely.
Each Puppeteer browser launch takes 1-3 seconds. For high-volume PDF generation, consider a dedicated PDF service or a Cloud Tasks queue that processes PDF jobs asynchronously and uploads the result to Cloud Storage, returning a download URL instead of streaming the PDF directly.
The invoice PDF endpoint is reused by the email automation module: when finance clicks 'Send to Customer', the system calls the PDF service, gets the buffer, and attaches it to a Nodemailer email. The email template is also a Handlebars template. This means the finance team never needs to download the PDF and manually attach it; the ERP handles the full send workflow.
The main change I'd make is to use a job queue for PDF generation from the start rather than the synchronous HTTP response approach. When the line item table grows (some invoices have 50+ lines), PDF generation can take 4-5 seconds, which strains the HTTP timeout. A queue-based approach—request PDF generation, get a job ID, poll or webhook when ready—decouples the user experience from Puppeteer's execution time.