PDF Generation with PDFKit: Invoices, Reports, and Certificates
PDF generation is one of those features that looks simple until you start implementing it. Dynamic tables that overflow pages correctly, multi-language text with Arabic RTL support, proper page numbering, consistent headers and footers across pages, embedded logos — all of these require a layout engine that handles the complexity for you. PDFKit is a low-level but powerful Node.js PDF generation library that gives you precise control over every element.
This guide covers PDFKit patterns for production PDF generation in NestJS: invoice templates, data tables with pagination, multi-column layouts, custom fonts, image embedding, and streaming delivery — all the way through to emailing the generated PDF as an attachment.
Key Takeaways
- PDFKit generates PDFs programmatically in Node.js — no browser, no Puppeteer, no HTML-to-PDF conversion
- Always buffer the PDF to completion before sending — do not stream an incomplete PDF to the client
- Embed fonts via
registerFont()for consistent cross-platform rendering — never rely on system fonts- Multi-page documents require manual page break detection: check
doc.yposition againstdoc.page.height- For Arabic and RTL text, use a proper Arabic font (Noto Sans Arabic) and handle text direction manually
- Stream PDFs to AWS S3 and return a pre-signed URL — do not pass large buffers through your API
- Use
doc.pipe(fs.createWriteStream())for testing;doc.pipe(res)for HTTP streaming; buffer for email attachments- Add PDF/A metadata for archival compliance:
Title,Author,Creator,Producerfields
Installation
pnpm add pdfkit
pnpm add -D @types/pdfkit
For image support (PNG, JPEG) and font subsetting, no additional packages are needed — PDFKit handles them natively.
Basic Invoice Structure
// src/modules/billing/pdf/invoice.generator.ts
import PDFDocument from 'pdfkit';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as fs from 'fs';
import * as path from 'path';
interface InvoiceData {
invoiceNumber: string;
date: Date;
dueDate: Date;
customer: {
name: string;
email: string;
address: string;
city: string;
country: string;
};
items: Array<{
description: string;
quantity: number;
unitPrice: number;
total: number;
}>;
subtotal: number;
tax: number;
total: number;
currency: string;
notes?: string;
}
@Injectable()
export class InvoiceGenerator {
private readonly primaryColor = '#0f172a';
private readonly accentColor = '#f59e0b';
private readonly mutedColor = '#64748b';
private readonly borderColor = '#e2e8f0';
constructor(private config: ConfigService) {}
async generate(data: InvoiceData): Promise<Buffer> {
return new Promise((resolve, reject) => {
const buffers: Buffer[] = [];
const doc = new PDFDocument({
size: 'A4',
margin: 50,
info: {
Title: `Invoice ${data.invoiceNumber}`,
Author: 'ECOSIRE Private Limited',
Creator: 'ECOSIRE Billing System',
Producer: 'PDFKit',
},
});
// Collect chunks into buffer (do not stream incomplete PDFs)
doc.on('data', (chunk: Buffer) => buffers.push(chunk));
doc.on('end', () => resolve(Buffer.concat(buffers)));
doc.on('error', reject);
this.renderHeader(doc, data);
this.renderCustomerSection(doc, data);
this.renderItemsTable(doc, data);
this.renderTotals(doc, data);
if (data.notes) this.renderNotes(doc, data.notes);
this.renderFooter(doc, data);
doc.end();
});
}
private renderHeader(doc: PDFKit.PDFDocument, data: InvoiceData): void {
// Company logo
const logoPath = path.join(process.cwd(), 'public', 'logo-dark.png');
if (fs.existsSync(logoPath)) {
doc.image(logoPath, 50, 45, { width: 120 });
} else {
doc
.fontSize(18)
.fillColor(this.primaryColor)
.font('Helvetica-Bold')
.text('ECOSIRE', 50, 50);
}
// Invoice label and number
doc
.fontSize(28)
.fillColor(this.accentColor)
.font('Helvetica-Bold')
.text('INVOICE', 0, 50, { align: 'right' });
doc
.fontSize(10)
.fillColor(this.mutedColor)
.font('Helvetica')
.text(`#${data.invoiceNumber}`, 0, 82, { align: 'right' });
// Divider line
doc
.moveTo(50, 110)
.lineTo(545, 110)
.lineWidth(1)
.strokeColor(this.borderColor)
.stroke();
doc.moveDown(2);
}
private renderCustomerSection(doc: PDFKit.PDFDocument, data: InvoiceData): void {
const startY = 130;
// Billed To
doc
.fontSize(8)
.fillColor(this.mutedColor)
.font('Helvetica-Bold')
.text('BILLED TO', 50, startY)
.fontSize(11)
.fillColor(this.primaryColor)
.text(data.customer.name, 50, startY + 16)
.fontSize(9)
.fillColor(this.mutedColor)
.font('Helvetica')
.text(data.customer.email, 50, startY + 32)
.text(data.customer.address, 50, startY + 46)
.text(`${data.customer.city}, ${data.customer.country}`, 50, startY + 60);
// Invoice dates (right column)
const dateRows = [
['Invoice Date:', this.formatDate(data.date)],
['Due Date:', this.formatDate(data.dueDate)],
['Currency:', data.currency],
];
let dateY = startY;
for (const [label, value] of dateRows) {
doc
.fontSize(9)
.fillColor(this.mutedColor)
.font('Helvetica')
.text(label, 350, dateY, { width: 90 });
doc
.fontSize(9)
.fillColor(this.primaryColor)
.font('Helvetica-Bold')
.text(value, 445, dateY, { width: 100, align: 'right' });
dateY += 18;
}
doc.moveDown(5);
}
private renderItemsTable(doc: PDFKit.PDFDocument, data: InvoiceData): void {
const tableTop = 265;
const colWidths = { desc: 230, qty: 60, unitPrice: 90, total: 90 };
const startX = 50;
// Table header background
doc
.rect(startX, tableTop, 495, 22)
.fillColor(this.primaryColor)
.fill();
// Header text
doc
.fontSize(9)
.fillColor('#ffffff')
.font('Helvetica-Bold')
.text('DESCRIPTION', startX + 8, tableTop + 6, { width: colWidths.desc })
.text('QTY', startX + colWidths.desc + 8, tableTop + 6, { width: colWidths.qty, align: 'center' })
.text('UNIT PRICE', startX + colWidths.desc + colWidths.qty + 8, tableTop + 6, { width: colWidths.unitPrice, align: 'right' })
.text('TOTAL', startX + colWidths.desc + colWidths.qty + colWidths.unitPrice + 8, tableTop + 6, { width: colWidths.total, align: 'right' });
let rowY = tableTop + 28;
const fmt = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: data.currency,
});
for (const [index, item] of data.items.entries()) {
// Alternating row background
if (index % 2 === 0) {
doc
.rect(startX, rowY - 4, 495, 24)
.fillColor('#f8fafc')
.fill();
}
doc
.fontSize(9)
.fillColor(this.primaryColor)
.font('Helvetica')
.text(item.description, startX + 8, rowY, { width: colWidths.desc })
.text(String(item.quantity), startX + colWidths.desc + 8, rowY, { width: colWidths.qty, align: 'center' })
.text(fmt.format(item.unitPrice), startX + colWidths.desc + colWidths.qty + 8, rowY, { width: colWidths.unitPrice, align: 'right' })
.text(fmt.format(item.total), startX + colWidths.desc + colWidths.qty + colWidths.unitPrice + 8, rowY, { width: colWidths.total, align: 'right' });
rowY += 24;
// Page break check
if (rowY > doc.page.height - 150) {
doc.addPage();
rowY = 50;
}
}
// Table bottom border
doc
.moveTo(startX, rowY + 4)
.lineTo(545, rowY + 4)
.lineWidth(1)
.strokeColor(this.borderColor)
.stroke();
doc.y = rowY + 20;
}
private renderTotals(doc: PDFKit.PDFDocument, data: InvoiceData): void {
const fmt = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: data.currency,
});
const totalsX = 350;
let currentY = doc.y + 10;
const rows = [
['Subtotal', fmt.format(data.subtotal), false],
[`Tax (${((data.tax / data.subtotal) * 100).toFixed(0)}%)`, fmt.format(data.tax), false],
['Total Due', fmt.format(data.total), true],
];
for (const [label, value, isTotal] of rows) {
if (isTotal) {
doc
.rect(totalsX, currentY - 4, 195, 28)
.fillColor(this.accentColor)
.fill();
doc
.fontSize(11)
.fillColor('#000000')
.font('Helvetica-Bold')
.text(label as string, totalsX + 8, currentY + 2, { width: 90 })
.text(value as string, totalsX + 98, currentY + 2, { width: 89, align: 'right' });
} else {
doc
.fontSize(9)
.fillColor(this.mutedColor)
.font('Helvetica')
.text(label as string, totalsX + 8, currentY, { width: 90 })
.fillColor(this.primaryColor)
.text(value as string, totalsX + 98, currentY, { width: 89, align: 'right' });
}
currentY += 28;
}
doc.y = currentY + 10;
}
private renderNotes(doc: PDFKit.PDFDocument, notes: string): void {
doc.moveDown(2);
doc
.fontSize(9)
.fillColor(this.mutedColor)
.font('Helvetica-Bold')
.text('NOTES', 50, doc.y);
doc
.fontSize(9)
.fillColor(this.primaryColor)
.font('Helvetica')
.text(notes, 50, doc.y + 12, { width: 495 });
}
private renderFooter(doc: PDFKit.PDFDocument, _data: InvoiceData): void {
const footerY = doc.page.height - 60;
doc
.moveTo(50, footerY - 10)
.lineTo(545, footerY - 10)
.lineWidth(1)
.strokeColor(this.borderColor)
.stroke();
doc
.fontSize(8)
.fillColor(this.mutedColor)
.font('Helvetica')
.text(
'ECOSIRE Private Limited | [email protected] | ecosire.com',
50,
footerY,
{ align: 'center', width: 495 }
);
}
private formatDate(date: Date): string {
return new Intl.DateTimeFormat('en-US', {
year: 'numeric', month: 'long', day: 'numeric',
}).format(date);
}
}
NestJS Controller Integration
// src/modules/billing/billing.controller.ts
@Controller('billing')
export class BillingController {
constructor(
private readonly billingService: BillingService,
private readonly invoiceGenerator: InvoiceGenerator,
private readonly emailService: EmailService,
) {}
@Get('invoices/:id/pdf')
async downloadInvoice(
@Param('id') id: string,
@Req() req: AuthenticatedRequest,
@Res() res: Response
): Promise<void> {
const invoice = await this.billingService.getInvoice(id, req.user.organizationId);
if (!invoice) {
throw new NotFoundException('Invoice not found');
}
const pdfBuffer = await this.invoiceGenerator.generate({
invoiceNumber: invoice.number,
date: invoice.createdAt,
dueDate: invoice.dueDate,
customer: {
name: req.user.name,
email: req.user.email,
address: invoice.billingAddress,
city: invoice.billingCity,
country: invoice.billingCountry,
},
items: invoice.lineItems,
subtotal: invoice.subtotal,
tax: invoice.tax,
total: invoice.total,
currency: invoice.currency,
});
res.set({
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="invoice-${invoice.number}.pdf"`,
'Content-Length': pdfBuffer.length,
'Cache-Control': 'private, no-cache',
});
res.send(pdfBuffer);
}
@Post('invoices/:id/email')
async emailInvoice(
@Param('id') id: string,
@Req() req: AuthenticatedRequest
) {
const invoice = await this.billingService.getInvoice(id, req.user.organizationId);
const pdfBuffer = await this.invoiceGenerator.generate({
invoiceNumber: invoice.number,
// ... populate from invoice data
} as InvoiceData);
// Non-blocking email send
this.emailService.sendInvoiceEmail(
req.user.email,
req.user.name,
invoice.number,
pdfBuffer
).catch((err) =>
this.logger.warn(`Invoice email failed: ${err.message}`)
);
return { message: 'Invoice sent to ' + req.user.email };
}
}
Multi-Page Report with Page Numbers
// For reports spanning multiple pages
async generateReport(data: ReportData): Promise<Buffer> {
return new Promise((resolve, reject) => {
const buffers: Buffer[] = [];
const doc = new PDFDocument({ size: 'A4', margin: 50 });
doc.on('data', (chunk: Buffer) => buffers.push(chunk));
doc.on('end', () => resolve(Buffer.concat(buffers)));
doc.on('error', reject);
const pageCount = Math.ceil(data.rows.length / 30); // 30 rows per page
let currentPage = 0;
// Add page numbers to every page on finalize
doc.on('pageAdded', () => {
currentPage++;
doc
.fontSize(8)
.fillColor('#94a3b8')
.text(
`Page ${currentPage}`,
0,
doc.page.height - 30,
{ align: 'center', width: doc.page.width }
);
});
// Render content
this.renderReportHeader(doc, data);
let rowIndex = 0;
for (const row of data.rows) {
if (doc.y > doc.page.height - 100) {
doc.addPage(); // Triggers 'pageAdded' for footer
this.renderReportHeader(doc, data); // Re-render header on new page
}
this.renderTableRow(doc, row, rowIndex++);
}
doc.end();
});
}
Streaming to S3
For large reports, stream directly to S3 rather than buffering in memory:
import { PassThrough } from 'stream';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
async function generateAndUploadToS3(
invoiceData: InvoiceData,
s3: S3Client,
bucket: string,
key: string
): Promise<string> {
const passThrough = new PassThrough();
const doc = new PDFDocument({ size: 'A4', margin: 50 });
doc.pipe(passThrough);
// Render invoice content
renderInvoice(doc, invoiceData);
doc.end();
// Upload stream to S3
await s3.send(new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: passThrough,
ContentType: 'application/pdf',
Metadata: {
'invoice-number': invoiceData.invoiceNumber,
},
}));
return `s3://${bucket}/${key}`;
}
Certificate Template
async generateCertificate(data: CertificateData): Promise<Buffer> {
return new Promise((resolve, reject) => {
const buffers: Buffer[] = [];
const doc = new PDFDocument({
size: [792, 612], // Letter landscape
margin: 60,
layout: 'landscape',
});
doc.on('data', (chunk: Buffer) => buffers.push(chunk));
doc.on('end', () => resolve(Buffer.concat(buffers)));
doc.on('error', reject);
// Background
doc.rect(0, 0, 792, 612).fillColor('#fefce8').fill();
// Gold border (double line)
doc
.rect(20, 20, 752, 572)
.lineWidth(3)
.strokeColor('#f59e0b')
.stroke();
doc
.rect(28, 28, 736, 556)
.lineWidth(1)
.strokeColor('#f59e0b')
.stroke();
// Title
doc
.fontSize(36)
.fillColor('#0f172a')
.font('Helvetica-Bold')
.text('CERTIFICATE OF COMPLETION', 60, 100, { align: 'center' });
// Recipient name
doc
.fontSize(28)
.fillColor('#f59e0b')
.text(data.recipientName, 60, 200, { align: 'center' });
// Course name
doc
.fontSize(16)
.fillColor('#0f172a')
.font('Helvetica')
.text(`has successfully completed`, 60, 260, { align: 'center' })
.fontSize(20)
.font('Helvetica-Bold')
.text(data.courseName, 60, 290, { align: 'center' });
// Date and signature
doc
.fontSize(11)
.fillColor('#64748b')
.font('Helvetica')
.text(`Issued on ${this.formatDate(data.issuedDate)}`, 60, 400, { align: 'center' });
doc.end();
});
}
Frequently Asked Questions
PDFKit vs Puppeteer for PDF generation — which should I use?
PDFKit for structured documents: invoices, reports, certificates, letters. It is fast (no browser startup), uses minimal memory, and gives pixel-perfect control over layout. Puppeteer for HTML-to-PDF conversion — best when you already have an HTML template and need it rendered as-is, including complex CSS, web fonts, and dynamic JavaScript. Puppeteer is 5-10x slower and requires a headless Chrome process, but handles web-era layouts better. For invoices and business documents, PDFKit is the right choice.
How do I handle page breaks for dynamic content in PDFKit?
PDFKit does not auto-paginate — you must check doc.y against doc.page.height - margin before rendering each element. The pattern: if (doc.y > doc.page.height - minSpaceNeeded) { doc.addPage(); renderPageHeader(doc); }. For table rows, calculate the minimum space needed for one row and check before each row. For multi-line text, estimate height as fontSize * lineCount * 1.2.
How do I embed custom fonts in PDFKit?
Register fonts with doc.registerFont('FontName', '/path/to/font.ttf') before using them. Call this in your service constructor or a setup function. PDFKit ships with the 14 standard PDF fonts (Helvetica, Times, Courier and their Bold/Italic variants) — these do not need to be registered. For Unicode support (Arabic, Chinese, Japanese), you must register a Unicode-compatible TTF/OTF font; the standard PDF fonts only cover Latin characters.
How do I generate PDFs for Arabic or other RTL languages?
PDFKit supports Unicode but does not natively handle bidirectional text reordering. For Arabic, register Noto Sans Arabic or Amiri font. Use a bidi algorithm library (like bidi-js) to reorder the text before passing it to PDFKit. Reverse the layout manually for RTL documents (right-aligned text, mirrored table columns). Test thoroughly — Arabic PDF generation requires careful handling of text joining, diacritics, and number direction.
How do I add digital signatures or PDF/A compliance?
PDFKit supports basic PDF metadata for archival (Title, Author, Subject, Keywords) via the info option. For digital signatures, use the node-signpdf library with PDFKit — it modifies the PDF stream to embed a PKCS#7 signature. For PDF/A compliance (long-term archival), ensure embedded fonts, no transparency, and proper color space definitions. PDFKit v0.13+ has improved support for these features, but complex compliance requirements may benefit from a dedicated library like pdf-lib.
Next Steps
PDFKit enables professional, programmatic PDF generation directly in your Node.js backend — no browser overhead, no external services, no per-page pricing. Invoices, reports, certificates, and statements become first-class features of your API.
ECOSIRE implements PDF invoice generation, automated email delivery, and S3 archival as part of the complete billing pipeline on every NestJS project. Explore our backend engineering services to learn how we build production-grade invoice and reporting systems.
Written by
ECOSIRE Research and Development Team
Building enterprise-grade digital products at ECOSIRE. Sharing insights on Odoo integrations, e-commerce automation, and AI-powered business solutions.