This document outlines the complete implementation plan for replacing the current React PDF approach with Puppeteer to generate PDFs directly from the existing InvoiceRender React component.
- ✅ Working
InvoiceRendercomponent atpackages/ui/src/invoice/invoice-render.tsx - ✅ Component used in
apps/invoice/src/app/pay/[handle]/[invoiceId]/invoice-payment-client.tsx - ✅ Current React PDF implementation in
packages/lib/src/functions/invoice-pdf.fns.ts - ✅ tRPC route at
packages/api/src/public/invoice-render.route.tswithdownloadInvoicePdfprocedure - ✅ Inter font files available at
apps/invoice/public/inter/(all weights)
- Perfect visual consistency - Same component renders web and PDF
- Single source of truth - No duplicate styling code to maintain
- Better typography - Use existing Inter fonts seamlessly via CSS
- Easier maintenance - Changes to invoice design apply everywhere
- Future-proof - Easy to add features like logos, custom branding
Add to packages/lib/package.json:
{
"dependencies": {
"puppeteer": "^21.5.0"
}
}File: apps/invoice/src/app/[handle]/[invoiceId]/pdf/page.tsx
This route will:
- Render the same
InvoiceRendercomponent - Include PDF-specific styling
- Accept special server-side authentication
- Be optimized for A4 printing
Key features:
- Use
isPdfMode={true}prop for PDF optimizations - Include print CSS for proper page sizing
- Remove any interactive elements
- Ensure consistent Inter font loading
File: packages/ui/src/invoice/invoice-render.tsx
Add new props:
export interface InvoiceRenderProps {
// ... existing props
isPdfMode?: boolean; // New prop for PDF-specific optimizations
}PDF mode optimizations:
- Remove any interactive elements (buttons, etc.)
- Optimize spacing for A4 pages
- Ensure print-friendly colors
- Handle page breaks appropriately
File: apps/invoice/src/app/[handle]/[invoiceId]/pdf/page.module.css
Print-specific CSS:
@page {
size: A4;
margin: 20mm 15mm;
}
@media print {
.pdf-container {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 10pt;
line-height: 1.4;
}
.no-print {
display: none !important;
}
.page-break {
page-break-before: always;
}
}File: packages/lib/src/functions/invoice-pdf-puppeteer.fns.ts
Core function:
export async function generateInvoicePDFWithPuppeteer(props: InvoicePDFProps): Promise<Buffer> {
const puppeteer = await import('puppeteer');
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
const page = await browser.newPage();
// Navigate to PDF route
const pdfUrl = getAbsoluteUrl('invoice', `/${props.workspace.handle}/${props.invoice.id}/pdf`);
await page.goto(pdfUrl, { waitUntil: 'networkidle0' });
// Generate PDF
const pdf = await page.pdf({
format: 'A4',
margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
printBackground: true,
preferCSSPageSize: true
});
await browser.close();
return Buffer.from(pdf);
}Key considerations:
- Authentication bypass for server-side access
- Proper error handling and browser cleanup
- Font loading wait conditions
- Memory management for production use
File: packages/api/src/public/invoice-render.route.ts
Update the downloadInvoicePdf procedure:
downloadInvoicePdf: publicProcedure
.input(/* existing input schema */)
.mutation(async ({ input }) => {
// ... existing validation logic
try {
// Try Puppeteer approach first
const pdfBuffer = await generateInvoicePDFWithPuppeteer({
invoice,
workspace: invoice.workspace,
client: invoice.client,
});
return {
pdf: pdfBuffer.toString('base64'),
filename: `invoice-${invoice.invoiceNumber}.pdf`,
};
} catch (error) {
console.warn('Puppeteer PDF generation failed, falling back to React PDF:', error);
// Fallback to existing React PDF implementation
const pdfBase64 = await generateInvoicePDFBase64({
invoice,
workspace: invoice.workspace,
client: invoice.client,
});
return {
pdf: pdfBase64,
filename: `invoice-${invoice.invoiceNumber}.pdf`,
};
}
}),The PDF route needs to be accessible server-side. Options:
- Special server header - Add internal auth header for Puppeteer requests
- Temporary token - Generate short-lived token for PDF access
- IP allowlist - Allow localhost/server IPs to bypass auth
Recommended approach: Special header method:
// In PDF route
export async function GET(request: Request) {
const isServerRequest = request.headers.get('X-Internal-PDF-Request') === process.env.INTERNAL_PDF_SECRET;
if (!isServerRequest) {
// Normal authentication flow
}
// Render PDF version
}- Docker compatibility - Ensure Puppeteer works in containerized environments
- Memory limits - Configure proper memory limits for browser instances
- Concurrent requests - Handle multiple PDF generations gracefully
- Caching - Consider caching identical invoice PDFs
- Error monitoring - Comprehensive error tracking and fallbacks
Add to .env:
INTERNAL_PDF_SECRET=your-secret-key-here
- Unit tests - Test PDF generation with mock invoices
- Integration tests - End-to-end PDF download testing
- Visual regression - Compare PDF output with web version
- Performance tests - Memory usage and generation speed
- Fallback tests - Ensure React PDF fallback works
- Implement Puppeteer approach alongside existing React PDF
- Feature flag to toggle between approaches
- A/B test with small percentage of requests
- Monitor error rates and performance
- Full rollout once stable
- Remove old React PDF code after successful migration
apps/invoice/
├── src/app/[handle]/[invoiceId]/
│ ├── page.tsx (existing)
│ └── pdf/
│ ├── page.tsx (new PDF route)
│ └── page.module.css (print styles)
packages/lib/src/functions/
├── invoice-pdf.fns.ts (existing React PDF)
├── invoice-pdf-puppeteer.fns.ts (new Puppeteer)
packages/api/src/public/
└── invoice-render.route.ts (updated with fallback logic)
packages/ui/src/invoice/
└── invoice-render.tsx (updated with isPdfMode)
- PDF visual fidelity matches web version 100%
- Generation time under 3 seconds
- Memory usage stays within container limits
- Error rate under 1% with fallback working
- Inter fonts render correctly in all PDF viewers