Shared PDF Generation Between Frontend and Backend: One Template, Two Platforms

Table of Contents
The Problem: PDF Inconsistency Nightmare
βHey, the PDF downloaded from the website is different from the one in the emailβ¦β
βThe customer name in the web download shows βJohn Doeβ, but the email attachment shows βDoe Electronics Inc.β - which one is correct?β
That message from our QA team made me realize: maintaining two separate PDF codebases (frontend + backend) is a recipe for disaster.
Today Iβm sharing how we built a shared PDF package that works on both browser (Next.js) and server (NestJS), ensuring:
- β PDFs from both sources are identical
- β No code duplication
- β Type-safe with TypeScript
- β Easy to maintain and extend
Main tools: @react-pdf/renderer + Turborepo monorepo
Why We Need PDF in Multiple Places
In an e-commerce system, we need to generate PDFs in three different scenarios:
1οΈβ£ Frontend (Browser):
User clicks "Download Invoice"
β Generate PDF in browser
β No API call needed
β Instant download
2οΈβ£ Backend (Email Attachment):
Order confirmed β Send email with PDF invoice
β Generate PDF on server
β Attach to email
3οΈβ£ Backend (API Endpoint):
GET /api/invoices/:id/pdf
β Generate PDF on-demand
β Stream to browser
The Naive Approach (And Why It Fails)
Hereβs what many teams do initially:
// β Approach: Duplicate code
// Frontend (using jsPDF)
function generateInvoicePDF(invoice: Invoice) {
const doc = new jsPDF();
doc.text(`Invoice #${invoice.number}`, 10, 10);
doc.text(`Customer: ${invoice.customer.name}`, 10, 20);
doc.text(`Total: $${invoice.total}`, 10, 30);
// ... 100 lines of code
}
// Backend (using PDFKit)
function generateInvoicePDF(invoice: Invoice) {
const doc = new PDFDocument();
doc.text(`Invoice #${invoice.number}`, 10, 10);
doc.text(`Customer: ${invoice.customer.businessName}`, 10, 20); // β Different!
doc.text(`Total: $${invoice.total}`, 10, 30);
// ... 100 lines of DIFFERENT code
}
Problems with this approach:
β Code duplication β maintain two places
β Different logic β different outputs
β Inconsistent data mapping β bugs
β Different styling β poor UX
β Update one place, forget the other β disaster
Real bugs we encountered:
Bug #1: Bill-to field
- Browser PDF: Shows customer.name β
- Email PDF: Shows customer.businessName β
β Customer complained "the name is wrong"
Bug #2: Product pricing
- Browser: Shows discounted unit price
- Email: Shows original price
β Numbers don't match
Bug #3: Logo rendering
- Browser: Logo looks crisp
- Email: Logo stretched/pixelated
β Unprofessional look
Solution: Shared Package with @react-pdf
Why @react-pdf/renderer?
Letβs compare popular PDF libraries:
ββββββββββββββββ¬βββββββββ¬βββββββββ¬ββββββββββββββ¬βββββββββββ
β Library βBrowser β Server β Approach β Rating β
ββββββββββββββββΌβββββββββΌβββββββββΌββββββββββββββΌβββββββββββ€
β jsPDF β β
β β β Imperative β ββ β
β PDFKit β β β β
β Imperative β ββ β
β Puppeteer β β β β
β HTML to PDF β βββ β
β @react-pdf β β
β β
β Declarative β ββββ ββ Pick this
ββββββββββββββββ΄βββββββββ΄βββββββββ΄ββββββββββββββ΄βββββββββββ
Why @react-pdf wins:
β
Works on both browser & Node.js
β
React component-based (familiar to most developers)
β
Type-safe with TypeScript
β
Reusable components
β
Great documentation & active community
β
Easy styling with React-style objects
Architecture Overview
ecommerce-monorepo/
βββ packages/
β βββ pdf-templates/ # β Shared package
β βββ src/
β β βββ invoice/
β β β βββ InvoiceTemplate.tsx
β β β βββ InvoiceHeader.tsx
β β β βββ styles.ts
β β βββ components/
β β β βββ Button.tsx
β β β βββ Table.tsx
β β β βββ Badge.tsx
β β βββ utils/
β β β βββ data-transformer.ts
β β βββ index.ts
β βββ package.json
β
βββ apps/
β βββ web/ # Next.js frontend
β β βββ uses pdf-templates β
β β
β βββ api/ # NestJS backend
β βββ uses pdf-templates β
β
βββ turbo.json # Turborepo config
Implementation: Building the PDF Package
Step 1: Package Setup
// packages/pdf-templates/package.json
{
"name": "@my-shop/pdf-templates",
"version": "1.0.0",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc",
"dev": "tsc --watch"
},
"peerDependencies": {
"react": "^18.0.0",
"@react-pdf/renderer": "^3.1.0"
},
"devDependencies": {
"typescript": "^5.0.0",
"@types/react": "^18.0.0"
}
}
Step 2: TypeScript Interfaces (Type Safety First!)
// packages/pdf-templates/src/types.ts
export interface InvoicePDFProps {
// Basic info
invoiceNumber: string;
invoiceDate: Date;
dueDate: Date;
// Merchant info
merchant: {
name: string;
logo?: string; // URL or base64
address: string;
phone?: string;
};
// Customer info (single source of truth!)
billTo: {
name: string; // β Consistent field name
address: string;
email?: string;
};
// Line items
lineItems: Array<{
description: string;
quantity: number;
unitPrice: number;
total: number;
}>;
// Totals
subtotal: number;
tax: number;
total: number;
// Optional fields
paymentTerms?: string;
notes?: string;
}
Why this matters: Having a clear interface prevents mapping errors and makes it impossible to pass wrong data types.
Step 3: Building Reusable Components
Build a component library for common PDF elements:
// src/components/InvoiceHeader.tsx
import { View, Text, Image } from '@react-pdf/renderer';
export const InvoiceHeader = ({ merchantName, merchantLogo, invoiceNumber, invoiceDate }) => (
<View style=>
{merchantLogo && <Image src={merchantLogo} style= />}
<Text style=>{merchantName}</Text>
<View style=>
<Text style=>INVOICE</Text>
<Text style=>#{invoiceNumber}</Text>
<Text style=>{invoiceDate.toLocaleDateString()}</Text>
</View>
</View>
);
// src/components/LineItemsTable.tsx
import { View, Text } from '@react-pdf/renderer';
export const LineItemsTable = ({ items }) => (
<View style=>
{/* Header */}
<View style=>
<Text style=>Description</Text>
<Text style=>Qty</Text>
<Text style=>Price</Text>
<Text style=>Total</Text>
</View>
{/* Rows */}
{items.map((item, i) => (
<View key={i} style=>
<Text style=>{item.description}</Text>
<Text style=>{item.quantity}</Text>
<Text style=>${item.unitPrice}</Text>
<Text style=>${item.total}</Text>
</View>
))}
</View>
);
π‘ Tip: Keep styles inline for simple components. For complex styling, use
StyleSheet.create().
Step 4: Main Invoice Template
// src/invoice/InvoiceTemplate.tsx
import { Document, Page, View, Text } from '@react-pdf/renderer';
import { InvoiceHeader } from '../components/InvoiceHeader';
import { LineItemsTable } from '../components/LineItemsTable';
export const InvoiceTemplate = (props) => (
<Document>
<Page size="A4" style=>
{/* Header */}
<InvoiceHeader
merchantName={props.merchant.name}
merchantLogo={props.merchant.logo}
invoiceNumber={props.invoiceNumber}
invoiceDate={props.invoiceDate}
/>
{/* Merchant & Customer */}
<View style=>
<View>
<Text style=>FROM:</Text>
<Text style=>{props.merchant.name}</Text>
<Text style=>{props.merchant.address}</Text>
</View>
<View>
<Text style=>BILL TO:</Text>
<Text style=>{props.billTo.name}</Text>
<Text style=>{props.billTo.address}</Text>
</View>
</View>
{/* Line Items */}
<LineItemsTable items={props.lineItems} />
{/* Totals */}
<View style=>
<View style=>
<Text>Subtotal:</Text>
<Text>${props.subtotal}</Text>
</View>
<View style=>
<Text>Tax:</Text>
<Text>${props.tax}</Text>
</View>
<View style=>
<Text>Total:</Text>
<Text>${props.total}</Text>
</View>
</View>
{/* Footer */}
<Text style=>
Thank you for your business!
</Text>
</Page>
</Document>
);
π Full Example: See @react-pdf documentation for advanced styling and components.
Step 5: Data Transformation Layer (Critical!)
This is the key to consistency. Create ONE function to transform your Order entity into PDF props:
// src/utils/data-transformer.ts
export function toPDFProps(order: Order): InvoicePDFProps {
return {
invoiceNumber: order.invoiceNumber,
invoiceDate: new Date(order.createdAt),
dueDate: new Date(order.dueDate),
merchant: {
name: order.merchant.name,
logo: order.merchant.logoUrl ? `https://cdn.myshop.com/${order.merchant.logoUrl}` : undefined,
address: formatAddress(order.merchant.address)
},
// β
Single source of truth - ALWAYS use customer.fullName
billTo: {
name: order.customer.fullName, // Not businessName!
address: formatAddress(order.customer.billingAddress),
email: order.customer.email
},
lineItems: order.items.map(item => ({
description: item.product.name,
quantity: item.quantity,
unitPrice: item.unitPrice,
total: item.quantity * item.unitPrice
})),
subtotal: order.subtotal,
tax: order.tax,
total: order.total
};
}
Why this matters:
- β Used by BOTH frontend and backend
- β Impossible to have different mapping logic
- β Fix bug once, fixed everywhere
- β Type-safe with TypeScript
Step 6: Export the Package
// src/index.ts
export { InvoiceTemplate } from './invoice/InvoiceTemplate';
export { toPDFProps } from './utils/data-transformer';
export type { InvoicePDFProps } from './types';
Using the Package
Frontend (Browser Download)
// Button component in Next.js/React
import { pdf } from '@react-pdf/renderer';
import { InvoiceTemplate, toPDFProps } from '@my-shop/pdf-templates';
async function handleDownload(order) {
// 1. Transform data
const pdfProps = toPDFProps(order);
// 2. Generate PDF blob
const blob = await pdf(<InvoiceTemplate {...pdfProps} />).toBlob();
// 3. Trigger download
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `invoice-${order.invoiceNumber}.pdf`;
link.click();
URL.revokeObjectURL(url);
}
Backend (Email Attachment / API)
// NestJS PDF service
import { renderToStream, renderToBuffer } from '@react-pdf/renderer';
import { InvoiceTemplate, toPDFProps } from '@my-shop/pdf-templates';
// For email attachments
async function generatePDFForEmail(order) {
const pdfProps = toPDFProps(order);
const pdfBuffer = await renderToBuffer(<InvoiceTemplate {...pdfProps} />);
await mailer.send({
to: order.customer.email,
subject: 'Your Invoice',
attachments: [{
filename: `invoice-${order.invoiceNumber}.pdf`,
content: pdfBuffer
}]
});
}
// For API endpoint (GET /orders/:id/pdf)
@Get(':id/pdf')
async downloadPDF(@Param('id') id: string, @Res() res: Response) {
const order = await this.ordersService.findOne(id);
const pdfProps = toPDFProps(order);
const stream = await renderToStream(<InvoiceTemplate {...pdfProps} />);
res.set({
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="invoice-${order.invoiceNumber}.pdf"`
});
return new StreamableFile(stream);
}
Key points:
renderToBuffer()for email attachmentsrenderToStream()for API streaming- Same
toPDFProps()transformation used everywhere
Common Gotchas
1. Images Must Use Absolute URLs
// β Relative paths don't work
<Image src="/assets/logo.png" />
// β
Use absolute URLs or base64
<Image src="https://cdn.myshop.com/logo.png" />
<Image src="data:image/png;base64,iVBORw0KG..." />
2. Lazy Load on Frontend (Bundle Size)
// β
Lazy load to avoid 200KB in initial bundle
const handleDownload = async () => {
const { pdf } = await import('@react-pdf/renderer');
const { InvoiceTemplate } = await import('@my-shop/pdf-templates');
// Generate PDF...
};
3. Custom Fonts
import { Font } from '@react-pdf/renderer';
Font.register({
family: 'Roboto',
src: 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu4mxP.ttf'
});
// Then use in styles
<Text style=>Hello</Text>
Testing
Unit Test: PDF Generates Successfully
import { pdf } from '@react-pdf/renderer';
import { InvoiceTemplate } from '../src';
it('should generate PDF without errors', async () => {
const mockProps = { /* minimal props */ };
const buffer = await pdf(<InvoiceTemplate {...mockProps} />).toBuffer();
expect(buffer).toBeInstanceOf(Buffer);
expect(buffer.length).toBeGreaterThan(10000); // At least 10KB
});
Consistency Test: Same Input = Same Output
it('should generate identical PDFs for same input', async () => {
const order = await getTestOrder();
const pdf1 = await pdfService.generateInvoicePDF(order);
const pdf2 = await pdfService.generateInvoicePDF(order);
// Compare checksums
const hash1 = crypto.createHash('md5').update(pdf1).digest('hex');
const hash2 = crypto.createHash('md5').update(pdf2).digest('hex');
expect(hash1).toBe(hash2); // Must be identical
});
This test ensures frontend and backend generate the same PDF.
Results & Benefits
After implementing this shared PDF package, we saw:
β Bug Reduction:
- PDF inconsistency bugs: 15 tickets/month β 0 tickets/month
- Data mapping errors: Eliminated completely
β Development Speed:
- Time to add new PDF template: 2 days β 4 hours (83% faster)
- Code reuse: Every component used in 2+ places
β Maintainability:
- Single source of truth for PDF logic
- Type-safe props prevent errors
- Easy to add new templates
β Developer Experience:
- Familiar React syntax
- Hot reload during development
- Clear error messages
Best Practices & Takeaways
Key Takeaways:
1οΈβ£ @react-pdf/renderer works on both browser & Node.js - Perfect for shared packages
2οΈβ£ Monorepo package = one codebase, multiple consumers - No more duplication
3οΈβ£ Data transformer layer is crucial - Single source of truth for mapping
4οΈβ£ Type-safe props catch errors at compile-time - Better than runtime failures
5οΈβ£ Reusable components speed up development - Build once, use everywhere
Best Practices Checklist:
β
Define clear TypeScript interfaces for all props
β
Build reusable components (Header, Table, Footer)
β
Use absolute URLs for images (or base64)
β
Implement data transformer for consistent mapping
β
Add visual regression tests for PDFs
β
Lazy load PDF library on frontend (bundle size optimization)
β
Handle errors gracefully with fallback options
β
Document component props and usage examples
β
Version your package properly (semver)
β
Test on both platforms (browser and Node.js)
When to Use This Approach
β Use this approach when:
- You need PDFs from both frontend AND backend
- Youβre already using monorepo (Turborepo, Nx, Lerna)
- Your team is familiar with React
- You need high consistency across platforms
- You have multiple PDF templates to maintain
β Donβt use this approach when:
- You only need PDFs from ONE place (FE or BE)
- PDFs are extremely complex with heavy styling (consider Puppeteer)
- Your team doesnβt know React
- You need pixel-perfect HTML-to-PDF conversion
Conclusion
Building a shared PDF package might seem like extra work upfront, but it pays dividends in the long run:
- No more inconsistencies between frontend and backend PDFs
- Faster development with reusable components
- Type safety prevents bugs before they reach production
- Single source of truth for all PDF-related logic
If youβre generating PDFs in multiple places and struggling with consistency, this approach could be a game-changer for your team.
Hope this helps you build a better PDF generation system! π¨
Resources:
Have you implemented something similar? What challenges did you face? Let me know in the comments!