9 minute read

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 attachments
  • renderToStream() 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!