React PDF — Generate PDF Documents from React Components

Sanjeev SharmaSanjeev Sharma
5 min read

Advertisement

Introduction

Why This Matters

PDF generation is a common requirement: invoices, reports, certificates, contracts. Doing it with raw PDF libraries is painful. React PDF lets you define documents with familiar component syntax and renders them to PDF on both the server and client, integrating naturally with Next.js API routes.

Installation

npm install @react-pdf/renderer

Mark usage as client-only or use server-side rendering via API routes (recommended for large documents).

Invoice PDF Component

// components/invoice-pdf.tsx
import {
  Document,
  Page,
  Text,
  View,
  StyleSheet,
  Font
} from '@react-pdf/renderer'
 
Font.register({
  family: 'Inter',
  fonts: [
    { src: 'https://fonts.gstatic.com/s/inter/v12/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuLyfAZ9hiJ-Ek-_EeA.woff', fontWeight: 400 },
    { src: 'https://fonts.gstatic.com/s/inter/v12/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuI6fAZ9hiJ-Ek-_EeA.woff', fontWeight: 700 }
  ]
})
 
const styles = StyleSheet.create({
  page: { fontFamily: 'Inter', fontSize: 11, color: '#1a202c', padding: 48 },
  header: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 32 },
  companyName: { fontSize: 22, fontWeight: 700, color: '#1d4ed8' },
  companyDetails: { fontSize: 9, color: '#718096', marginTop: 4, lineHeight: 1.5 },
  invoiceTitle: { fontSize: 28, fontWeight: 700, textAlign: 'right', color: '#1a202c' },
  invoiceMeta: { fontSize: 10, color: '#718096', textAlign: 'right', marginTop: 4 },
  billTo: { marginBottom: 24 },
  label: { fontSize: 9, color: '#718096', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 4 },
  clientName: { fontSize: 13, fontWeight: 700 },
  clientDetail: { fontSize: 10, color: '#4a5568', lineHeight: 1.5 },
  tableHeader: { flexDirection: 'row', backgroundColor: '#f7fafc', padding: '8 12', borderRadius: 4, marginBottom: 4 },
  tableHeaderCell: { fontSize: 9, fontWeight: 700, textTransform: 'uppercase', color: '#718096' },
  tableRow: { flexDirection: 'row', padding: '10 12', borderBottom: '1 solid #e2e8f0' },
  cell: { fontSize: 10, color: '#1a202c' },
  totals: { marginTop: 24, alignItems: 'flex-end' },
  totalRow: { flexDirection: 'row', justifyContent: 'space-between', width: 200, marginBottom: 6 },
  totalLabel: { fontSize: 10, color: '#718096' },
  totalValue: { fontSize: 10, fontWeight: 700, color: '#1a202c' },
  grandTotal: { borderTop: '2 solid #1d4ed8', paddingTop: 8, marginTop: 4 },
  grandTotalLabel: { fontSize: 12, fontWeight: 700, color: '#1d4ed8' },
  grandTotalValue: { fontSize: 14, fontWeight: 700, color: '#1d4ed8' }
})
 
type LineItem = { description: string; quantity: number; unitPrice: number }
type InvoiceData = {
  invoiceNumber: string
  issueDate: string
  dueDate: string
  client: { name: string; email: string; address: string }
  items: LineItem[]
  taxRate?: number
}
 
export function InvoicePDF({ data }: { data: InvoiceData }) {
  const subtotal = data.items.reduce((sum, item) => sum + item.quantity * item.unitPrice, 0)
  const tax = subtotal * (data.taxRate ?? 0)
  const total = subtotal + tax
 
  return (
    <Document title={`Invoice ${data.invoiceNumber}`}>
      <Page size="A4" style={styles.page}>
        {/* Header */}
        <View style={styles.header}>
          <View>
            <Text style={styles.companyName}>YourCompany</Text>
            <Text style={styles.companyDetails}>123 Business Ave{'\n'}San Francisco, CA 94105{'\n'}billing@yourcompany.com</Text>
          </View>
          <View>
            <Text style={styles.invoiceTitle}>INVOICE</Text>
            <Text style={styles.invoiceMeta}>#{data.invoiceNumber}</Text>
            <Text style={styles.invoiceMeta}>Issued: {data.issueDate}</Text>
            <Text style={styles.invoiceMeta}>Due: {data.dueDate}</Text>
          </View>
        </View>
 
        {/* Bill To */}
        <View style={styles.billTo}>
          <Text style={styles.label}>Bill To</Text>
          <Text style={styles.clientName}>{data.client.name}</Text>
          <Text style={styles.clientDetail}>{data.client.email}</Text>
          <Text style={styles.clientDetail}>{data.client.address}</Text>
        </View>
 
        {/* Table Header */}
        <View style={styles.tableHeader}>
          <Text style={[styles.tableHeaderCell, { flex: 3 }]}>Description</Text>
          <Text style={[styles.tableHeaderCell, { flex: 1, textAlign: 'center' }]}>Qty</Text>
          <Text style={[styles.tableHeaderCell, { flex: 1.5, textAlign: 'right' }]}>Unit Price</Text>
          <Text style={[styles.tableHeaderCell, { flex: 1.5, textAlign: 'right' }]}>Amount</Text>
        </View>
 
        {/* Line Items */}
        {data.items.map((item, i) => (
          <View key={i} style={styles.tableRow}>
            <Text style={[styles.cell, { flex: 3 }]}>{item.description}</Text>
            <Text style={[styles.cell, { flex: 1, textAlign: 'center' }]}>{item.quantity}</Text>
            <Text style={[styles.cell, { flex: 1.5, textAlign: 'right' }]}>${item.unitPrice.toFixed(2)}</Text>
            <Text style={[styles.cell, { flex: 1.5, textAlign: 'right', fontWeight: 700 }]}>${(item.quantity * item.unitPrice).toFixed(2)}</Text>
          </View>
        ))}
 
        {/* Totals */}
        <View style={styles.totals}>
          <View style={styles.totalRow}>
            <Text style={styles.totalLabel}>Subtotal</Text>
            <Text style={styles.totalValue}>${subtotal.toFixed(2)}</Text>
          </View>
          {data.taxRate && (
            <View style={styles.totalRow}>
              <Text style={styles.totalLabel}>Tax ({(data.taxRate * 100).toFixed(0)}%)</Text>
              <Text style={styles.totalValue}>${tax.toFixed(2)}</Text>
            </View>
          )}
          <View style={[styles.totalRow, styles.grandTotal]}>
            <Text style={styles.grandTotalLabel}>Total Due</Text>
            <Text style={styles.grandTotalValue}>${total.toFixed(2)}</Text>
          </View>
        </View>
      </Page>
    </Document>
  )
}

Server-Side Generation API Route

// app/api/invoices/[id]/pdf/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { renderToBuffer } from '@react-pdf/renderer'
import { InvoicePDF } from '@/components/invoice-pdf'
 
export async function GET(
  _request: NextRequest,
  { params }: { params: { id: string } }
) {
  const invoice = await db.invoices.findUnique({
    where: { id: params.id },
    include: { client: true, items: true }
  })
 
  if (!invoice) {
    return NextResponse.json({ error: 'Invoice not found' }, { status: 404 })
  }
 
  const buffer = await renderToBuffer(
    <InvoicePDF data={{
      invoiceNumber: invoice.number,
      issueDate: invoice.createdAt.toLocaleDateString(),
      dueDate: invoice.dueDate.toLocaleDateString(),
      client: { name: invoice.client.name, email: invoice.client.email, address: invoice.client.address },
      items: invoice.items,
      taxRate: invoice.taxRate
    }} />
  )
 
  return new NextResponse(buffer, {
    headers: {
      'Content-Type': 'application/pdf',
      'Content-Disposition': `attachment; filename="invoice-${invoice.number}.pdf"`
    }
  })
}

Download Button Component

'use client'
 
export function DownloadInvoiceButton({ invoiceId }: { invoiceId: string }) {
  async function handleDownload() {
    const res = await fetch(`/api/invoices/${invoiceId}/pdf`)
    const blob = await res.blob()
    const url = URL.createObjectURL(blob)
    const a = document.createElement('a')
    a.href = url
    a.download = `invoice-${invoiceId}.pdf`
    a.click()
    URL.revokeObjectURL(url)
  }
 
  return (
    <button onClick={handleDownload} className="flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700">
      Download PDF
    </button>
  )
}

Common Mistakes

  • Using className or Tailwind — React PDF uses StyleSheet.create() with its own CSS-like properties
  • Importing fonts from Google Fonts URLs that block in production — host fonts locally or use a CDN that allows cross-origin
  • Using flexbox without specifying flexDirection: 'row' — default is column (same as CSS default)
  • Rendering large PDFs client-side — use the API route approach for documents with many pages

Best Practices

  • Always generate PDFs server-side for sensitive data — do not pass invoice amounts or client data to the browser
  • Register custom fonts with Font.register() before defining styles
  • Use renderToBuffer() in API routes and renderToStream() for streaming large documents

Key Takeaways

  • React PDF uses its own layout engine based on flexbox — not a browser renderer
  • Use StyleSheet.create() for all styles — className, Tailwind, and CSS files do not work
  • renderToBuffer() in a Next.js API route is the correct approach for server-side PDF generation
  • Always set Content-Disposition: attachment in the response to trigger a download instead of browser rendering
  • Font.register() must be called before using the font family in styles
  • The default flex direction is column — add flexDirection: 'row' for horizontal layouts
  • Page supports size (A4, LETTER) and orientation (portrait, landscape)
  • For sensitive documents, always generate server-side — never pass protected data to client-side code

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro