Generating PDFs in a React app is a common requirement — invoices, reports, certificates, contracts. But the npm ecosystem offers multiple approaches, each with different tradeoffs. Picking the wrong one can mean weeks of debugging CSS rendering issues, fighting Japanese character encoding, or realizing the text in your PDF is actually just a screenshot.
This guide compares the major React PDF libraries with real code examples, a feature matrix, and clear use-case guidance for 2026. We also cover the API-based approach (FUNBREW PDF) and when it beats client-side solutions.
For framework-specific integration, see the Next.js & Nuxt 3 PDF API guide. For PDF generation fundamentals, start with the HTML to PDF complete guide.
The Main Approaches
React PDF generation falls into four categories:
| Category | Representative Options | Runs In |
|---|---|---|
| JSX-based PDF rendering | @react-pdf/renderer | Client or Node.js |
| DOM-to-PDF capture | jsPDF + html2canvas | Client only |
| PDF viewer (display only) | react-pdf (pdfjs-dist) | Client |
| API-based generation | FUNBREW PDF | Server-side |
Let's look at each one in depth.
1. react-pdf (PDF Viewer, not Generator)
GitHub: wojtekmaj/react-pdf
An important disambiguation first: searching "react-pdf" on npm returns two very different packages:
react-pdf(by wojtekmaj): A React wrapper around Mozilla's PDF.js. This is a PDF viewer, not a generator.@react-pdf/renderer(by diegomura): A library for generating PDFs. Covered in the next section.
When to use react-pdf (the viewer)
npm install react-pdf
import { useState } from 'react';
import { Document, Page, pdfjs } from 'react-pdf';
import 'react-pdf/dist/Page/AnnotationLayer.css';
import 'react-pdf/dist/Page/TextLayer.css';
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url,
).toString();
export function PdfViewer({ fileUrl }: { fileUrl: string }) {
const [numPages, setNumPages] = useState<number>(0);
function onDocumentLoadSuccess({ numPages }: { numPages: number }) {
setNumPages(numPages);
}
return (
<Document file={fileUrl} onLoadSuccess={onDocumentLoadSuccess}>
{Array.from(new Array(numPages), (_, index) => (
<Page key={`page_${index + 1}`} pageNumber={index + 1} />
))}
</Document>
);
}
Good for:
- Rendering a PDF fetched from your server inside your React app
- Building a document preview UI
Not for:
- Generating new PDF files — this library has no generation capability
2. @react-pdf/renderer (JSX-Based PDF Generation)
GitHub: diegomura/react-pdf
@react-pdf/renderer lets you define PDF layouts using JSX and custom components (<Document>, <Page>, <Text>, <View>, etc.), then output a PDF binary. It does not convert your existing HTML/CSS — you build the layout from scratch using its own component set.
Installation and basic usage
npm install @react-pdf/renderer
import {
Document,
Page,
Text,
View,
StyleSheet,
PDFDownloadLink,
Font,
} from '@react-pdf/renderer';
// CSS-in-JS style definitions
const styles = StyleSheet.create({
page: {
flexDirection: 'column',
backgroundColor: '#ffffff',
padding: 40,
},
header: {
fontSize: 24,
marginBottom: 20,
color: '#1a56db',
},
table: {
display: 'flex',
flexDirection: 'column',
width: '100%',
},
tableRow: {
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: '#e5e7eb',
paddingVertical: 8,
},
tableCell: {
flex: 1,
fontSize: 10,
},
});
interface InvoiceData {
invoiceNumber: string;
customerName: string;
items: Array<{ name: string; quantity: number; price: number }>;
total: number;
}
// PDF document definition
const InvoiceDocument = ({ invoiceNumber, customerName, items, total }: InvoiceData) => (
<Document>
<Page size="A4" style={styles.page}>
<Text style={styles.header}>Invoice #{invoiceNumber}</Text>
<Text style={{ fontSize: 12, marginBottom: 20 }}>
Bill to: {customerName}
</Text>
<View style={styles.table}>
{items.map((item, i) => (
<View key={i} style={styles.tableRow}>
<Text style={styles.tableCell}>{item.name}</Text>
<Text style={styles.tableCell}>{item.quantity}</Text>
<Text style={styles.tableCell}>${item.price.toLocaleString()}</Text>
<Text style={styles.tableCell}>
${(item.quantity * item.price).toLocaleString()}
</Text>
</View>
))}
</View>
<Text style={{ fontSize: 14, fontWeight: 'bold', marginTop: 20, textAlign: 'right' }}>
Total: ${total.toLocaleString()}
</Text>
</Page>
</Document>
);
// Render as download link
export function InvoiceDownloadButton({ invoiceData }: { invoiceData: InvoiceData }) {
return (
<PDFDownloadLink
document={<InvoiceDocument {...invoiceData} />}
fileName={`invoice-${invoiceData.invoiceNumber}.pdf`}
>
{({ loading }) => (loading ? 'Generating...' : 'Download PDF')}
</PDFDownloadLink>
);
}
Adding font support
Default fonts in @react-pdf/renderer only support Latin characters. For other character sets or custom branding, you need to register fonts explicitly:
import { Font } from '@react-pdf/renderer';
// Register from Google Fonts CDN
Font.register({
family: 'Inter',
src: 'https://fonts.gstatic.com/s/inter/v13/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuLyfAZ9hiA.woff2',
});
// Or from a local file (recommended for production)
Font.register({
family: 'CustomFont',
src: '/fonts/custom-font.ttf', // served from public/
});
const styles = StyleSheet.create({
page: {
fontFamily: 'Inter',
padding: 40,
},
});
Important: Loading fonts from an external URL introduces a network dependency at PDF generation time. In production, serve fonts locally.
Known limitations of @react-pdf/renderer
- No HTML reuse: Your existing React components cannot be converted to PDF. You must rebuild the layout using the library's own components.
- Partial CSS support: Flexbox and basic typography work well, but CSS Grid, box shadows, complex animations, and
::before/::afterpseudo-elements are not supported. - Font management overhead: Every font variant (regular, bold, italic) must be registered manually. Embedding fonts increases bundle size.
- Complex layouts are painful: Multi-column layouts, nested tables, and dynamic content wrapping require significant manual work.
- Client-side only by default: Running in the browser means PDF generation competes for the main thread.
3. jsPDF + html2canvas (DOM Screenshot Approach)
GitHub: parallax/jsPDF, niklasvh/html2canvas
This approach uses html2canvas to screenshot a DOM element, then embeds that screenshot into a PDF using jsPDF. It's popular because it "just works" for simple cases, but comes with significant quality limitations.
Installation and basic usage
npm install jspdf html2canvas
import jsPDF from 'jspdf';
import html2canvas from 'html2canvas';
import { useRef } from 'react';
export function ReportExporter() {
const reportRef = useRef<HTMLDivElement>(null);
const handleExport = async () => {
if (!reportRef.current) return;
try {
// Capture DOM as canvas
const canvas = await html2canvas(reportRef.current, {
scale: 2, // Higher resolution (Retina)
useCORS: true, // Allow external resources
logging: false,
backgroundColor: '#ffffff',
});
const imgData = canvas.toDataURL('image/png');
const pdf = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: 'a4',
});
const pdfWidth = pdf.internal.pageSize.getWidth();
const pdfHeight = pdf.internal.pageSize.getHeight();
const canvasWidth = canvas.width;
const canvasHeight = canvas.height;
const ratio = canvasWidth / pdfWidth;
const imgHeight = canvasHeight / ratio;
if (imgHeight <= pdfHeight) {
// Single page
pdf.addImage(imgData, 'PNG', 0, 0, pdfWidth, imgHeight);
} else {
// Multi-page (manual slicing — element breaks can occur mid-element)
let remainingHeight = imgHeight;
let yPosition = 0;
while (remainingHeight > 0) {
pdf.addImage(imgData, 'PNG', 0, yPosition, pdfWidth, imgHeight);
remainingHeight -= pdfHeight;
yPosition -= pdfHeight;
if (remainingHeight > 0) pdf.addPage();
}
}
pdf.save('report.pdf');
} catch (error) {
console.error('PDF generation error:', error);
}
};
return (
<div>
<div ref={reportRef} style={{ padding: '40px', background: '#fff' }}>
<h1>Monthly Report</h1>
<p>Summary for March 2026</p>
{/* Report content */}
</div>
<button onClick={handleExport}>Export as PDF</button>
</div>
);
}
Direct text rendering with jsPDF
Instead of taking a screenshot, you can draw text and shapes directly with jsPDF's API. This produces selectable, searchable text in the PDF — but requires manual coordinate management:
import jsPDF from 'jspdf';
function generateTextPdf() {
const doc = new jsPDF();
doc.setFont('helvetica', 'bold');
doc.setFontSize(20);
doc.text('Invoice #INV-001', 20, 20);
doc.setFont('helvetica', 'normal');
doc.setFontSize(12);
doc.text('Customer: ACME Corp', 20, 35);
doc.text('Date: 2026-04-05', 20, 45);
// Draw line
doc.setLineWidth(0.5);
doc.line(20, 55, 190, 55);
// Table headers (manual x-coordinate positioning)
const headers = ['Item', 'Qty', 'Price', 'Total'];
const colWidths = [80, 25, 35, 35];
let x = 20;
doc.setFont('helvetica', 'bold');
doc.setFontSize(10);
headers.forEach((header, i) => {
doc.text(header, x, 65);
x += colWidths[i];
});
doc.save('invoice.pdf');
}
For tables, the popular jspdf-autotable plugin simplifies row/column management:
import jsPDF from 'jspdf';
import 'jspdf-autotable';
const doc = new jsPDF();
(doc as any).autoTable({
head: [['Name', 'Score', 'Date']],
body: [
['Alice', '95', '2026-04-01'],
['Bob', '87', '2026-04-02'],
],
});
doc.save('scores.pdf');
Known limitations of jsPDF + html2canvas
Text becomes an image: When using html2canvas, every character in your PDF is a pixel in a PNG image. Users cannot copy text, search engines cannot index it, and screen readers cannot read it.
Page breaks are unpredictable: There's no automatic page break logic. The canvas image is sliced horizontally at page boundaries, which means table rows, headings, or paragraphs can be cut in half. Workarounds exist but are fragile.
CSS fidelity issues: Elements using position: fixed, overflow: hidden, CSS custom properties, or complex box shadows often do not render correctly in html2canvas. External images may not load due to CORS restrictions.
Resolution vs. performance tradeoff: Without scale: 2, PDFs look blurry on high-DPI screens. With it, memory usage doubles.
No server-side support: html2canvas requires a real DOM, so this approach cannot run in Node.js environments.
4. API-Based Generation: FUNBREW PDF
Unlike the client-side libraries above, FUNBREW PDF is a managed API service that renders HTML with a Chromium engine on the server and returns a PDF binary. You send HTML, you get a PDF back.
The conceptual shift: Instead of running PDF generation logic in the browser, you delegate it to a server-side API. This means:
- Your HTML and CSS render with a full Chromium engine
- Japanese and other non-Latin fonts work out of the box
- Text in the PDF is real text, not an image
- The client-side bundle stays small
- Sensitive data never needs to be processed on the client
Architecture
React App → API Route (Next.js / Express / etc.) → FUNBREW PDF API → PDF binary → Download
Never call the PDF API directly from client-side code — always proxy through a server route to keep your API key secret.
Next.js App Router implementation
npm install @funbrew/pdf
// app/api/generate-pdf/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { FunbrewPdf } from '@funbrew/pdf';
const client = new FunbrewPdf({
apiKey: process.env.FUNBREW_PDF_API_KEY!,
});
export async function POST(request: NextRequest) {
const { invoiceData } = await request.json();
// Your existing HTML/CSS works directly — no library-specific syntax
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
body { font-family: Arial, sans-serif; padding: 40px; color: #111; }
.header { display: flex; justify-content: space-between; margin-bottom: 30px; }
h1 { color: #1a56db; font-size: 28px; margin: 0; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th, td { border: 1px solid #e5e7eb; padding: 12px; text-align: left; }
th { background: #f9fafb; font-weight: 600; }
.total-row td { font-weight: bold; background: #f0f9ff; }
.meta { font-size: 13px; color: #666; }
</style>
</head>
<body>
<div class="header">
<h1>Invoice #${invoiceData.invoiceNumber}</h1>
<div class="meta">
<p>Date: ${invoiceData.date}</p>
<p>Due: ${invoiceData.dueDate}</p>
</div>
</div>
<p>Bill to: <strong>${invoiceData.customerName}</strong></p>
<table>
<thead>
<tr><th>Item</th><th>Qty</th><th>Unit Price</th><th>Subtotal</th></tr>
</thead>
<tbody>
${invoiceData.items.map((item: { name: string; quantity: number; price: number }) => `
<tr>
<td>${item.name}</td>
<td>${item.quantity}</td>
<td>$${item.price.toLocaleString()}</td>
<td>$${(item.quantity * item.price).toLocaleString()}</td>
</tr>
`).join('')}
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="3">Total</td>
<td>$${invoiceData.total.toLocaleString()}</td>
</tr>
</tfoot>
</table>
</body>
</html>`;
const pdfBuffer = await client.generate({
html,
options: {
format: 'A4',
margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
},
});
return new NextResponse(pdfBuffer, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="invoice-${invoiceData.invoiceNumber}.pdf"`,
},
});
}
// components/InvoiceDownloadButton.tsx (Client Component)
'use client';
import { useState } from 'react';
interface InvoiceData {
invoiceNumber: string;
customerName: string;
date: string;
dueDate: string;
items: Array<{ name: string; quantity: number; price: number }>;
total: number;
}
export function InvoiceDownloadButton({ invoiceData }: { invoiceData: InvoiceData }) {
const [loading, setLoading] = useState(false);
const handleDownload = async () => {
setLoading(true);
try {
const response = await fetch('/api/generate-pdf', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ invoiceData }),
});
if (!response.ok) throw new Error('PDF generation failed');
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `invoice-${invoiceData.invoiceNumber}.pdf`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
console.error(error);
alert('Download failed. Please try again.');
} finally {
setLoading(false);
}
};
return (
<button
onClick={handleDownload}
disabled={loading}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
>
{loading ? 'Generating...' : 'Download PDF'}
</button>
);
}
Direct API call with cURL
curl -X POST https://api.pdf.funbrew.cloud/v1/pdf/from-html \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"html": "<h1 style=\"font-family: Arial, sans-serif;\">Invoice</h1><p>Total: $10,000</p>",
"engine": "quality",
"format": "A4",
"margin": {
"top": "20mm",
"bottom": "20mm",
"left": "15mm",
"right": "15mm"
}
}' \
-o invoice.pdf
Try it instantly in the Playground — paste any HTML and get a PDF back in seconds.
Feature Comparison Matrix
| Feature | @react-pdf/renderer | jsPDF + html2canvas | FUNBREW PDF (API) |
|---|---|---|---|
| Selectable/searchable text | Yes | No (image-based) | Yes |
| Full CSS support | Partial (custom CSS) | Partial (screenshot fidelity) | Full (Chromium rendering) |
| Japanese / CJK fonts | Requires setup | Environment-dependent | Built-in support |
| Reuse existing HTML | No (rewrite required) | Yes (screenshots DOM) | Yes (send HTML directly) |
| Automatic page breaks | Yes | No (manual slicing) | Yes |
| Headers & footers | Yes | No | Yes |
| Page numbers | Yes | No | Yes |
| Print CSS (@page) | No | No | Yes |
| Bundle size impact | ~300KB gz | ~200KB gz | Minimal (API call) |
| Server-side rendering | Yes | No (DOM required) | Yes |
| No API key needed | Yes | Yes | No (API key required) |
| Cost | Free (OSS) | Free (OSS) | Free tier available |
| Security (sensitive data) | Client-side risk | Client-side risk | Server-side only |
Performance Comparison
| Metric | @react-pdf/renderer | jsPDF + html2canvas | FUNBREW PDF |
|---|---|---|---|
| Initial bundle size (approx. gzipped) | ~300KB | ~200KB | Minimal |
| A4 single-page generation time | 500ms–2s | 1s–5s (DOM complexity varies) | 1s–3s (includes network) |
| Complex layout fidelity | Limited | Low | High |
| Concurrent generation | Browser resource limited | Browser resource limited | API-side scaling |
Use-Case Recommendations
Simple data tables and spreadsheet-like exports
jsPDF with the jspdf-autotable plugin is lightweight and works well for simple tabular data exports where design fidelity is not critical and text content is Latin characters.
import jsPDF from 'jspdf';
import 'jspdf-autotable';
const doc = new jsPDF();
(doc as any).autoTable({
head: [['Product', 'Q1', 'Q2', 'Q3']],
body: [
['Widget A', '1,200', '1,450', '1,800'],
['Widget B', '980', '1,100', '1,350'],
],
styles: { fontSize: 9 },
});
doc.save('quarterly-report.pdf');
Controlled PDF layouts with React component parity
If you want full control over the PDF design and are happy to maintain two parallel component trees (one for screen, one for PDF), @react-pdf/renderer is a solid choice. The JSX authoring experience is good, and it runs in Node.js for SSR.
Best for: Product catalogs, formatted reports where design matters and the content is relatively static.
Converting existing HTML UI to PDF
When you have an existing React component (invoice template, certificate, report card) and want to convert it to a PDF without rebuilding the layout — the API approach is the most direct path. Your HTML and CSS work as-is; Chromium renders it faithfully.
Best for: Invoices, contracts, certificates, any document where you already have an HTML template and need PDF output that matches the screen design.
Sensitive documents with per-user data
Payslips, medical records, financial statements — documents where the content is user-specific and confidential. Client-side generation exposes the full data in the browser's memory and JavaScript context. Server-side API generation keeps sensitive data off the client entirely.
Best for: B2B SaaS applications, HR tools, fintech applications.
Batch PDF generation
Generating 500 invoices at month-end is not a job for a browser. The API approach lets you run batch generation from a server job, parallelized and without consuming any client-side resources. See the batch PDF generation guide for implementation details.
Prototypes and personal projects
If you want zero external dependencies and have no budget for API calls, start with @react-pdf/renderer or jsPDF. When complexity grows or quality becomes important, migrating to an API approach is straightforward — especially since the API accepts standard HTML.
Migration Patterns
Migrating from @react-pdf/renderer to API approach
The layout work you did in @react-pdf/renderer can be ported to HTML relatively directly:
// Before: @react-pdf/renderer
const InvoicePdf = () => (
<Document>
<Page style={{ padding: 40 }}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
<Text style={{ fontSize: 24, color: '#1a56db' }}>Invoice #001</Text>
<Text style={{ fontSize: 12, color: '#666' }}>2026-04-05</Text>
</View>
<View style={{ marginTop: 20 }}>
<Text style={{ fontSize: 12 }}>Bill to: ACME Corp</Text>
</View>
</Page>
</Document>
);
// After: HTML template for API
const invoiceHtml = `
<div style="display: flex; justify-content: space-between; align-items: flex-start; padding: 40px;">
<h1 style="font-size: 24px; color: #1a56db; margin: 0;">Invoice #001</h1>
<span style="font-size: 12px; color: #666;">2026-04-05</span>
</div>
<div style="padding: 0 40px;">
<p style="font-size: 12px;">Bill to: ACME Corp</p>
</div>
`;
// → Pass this HTML to your API Route → FUNBREW PDF
The HTML version uses standard CSS properties, which removes the library-specific subset limitation.
Migrating from jsPDF + html2canvas to API approach
// Before: DOM screenshot (text is an image)
const handleExport = async () => {
const canvas = await html2canvas(reportRef.current!);
const pdf = new jsPDF();
pdf.addImage(canvas.toDataURL(), 'PNG', 0, 0, 210, 297);
pdf.save('report.pdf');
};
// After: Send HTML to API (text is real text)
const handleExport = async () => {
const htmlContent = reportRef.current?.outerHTML ?? '';
const response = await fetch('/api/generate-pdf', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ html: htmlContent }),
});
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'report.pdf';
a.click();
URL.revokeObjectURL(url);
};
This migration gives you selectable text, proper page breaks, and much better rendering fidelity in one change.
Summary
| @react-pdf/renderer | jsPDF + html2canvas | FUNBREW PDF | |
|---|---|---|---|
| Best for | Controlled PDF-first layouts | Quick screenshots of simple DOM | HTML-to-PDF with full CSS fidelity |
| Text quality | Selectable | Image only | Selectable |
| HTML reuse | None | Full (screenshot) | Full (renders HTML) |
| CSS fidelity | Partial | Medium | Full (Chromium) |
| Japanese/CJK | Manual setup | Environment-dependent | Built-in |
| Server-side | Yes | No | Yes |
| Overhead | Bundle size | Performance + quality | Network latency |
The practical path:
- Start with OSS libraries (
@react-pdf/rendereror jsPDF) for prototypes and low-complexity documents - Move to the API approach when CSS fidelity, Japanese support, security, or batch scale becomes a requirement
Try FUNBREW PDF in the Playground — paste any HTML and see the output instantly. Full API reference is in the documentation. For more use cases, see the use cases gallery.
Related Articles
- Next.js & Nuxt 3 PDF API Guide — Framework integration walkthrough
- HTML to PDF Complete Guide — PDF generation fundamentals
- HTML to PDF API Comparison 2026 — Server-side PDF tool comparison
- HTML to PDF CSS Tips — Print CSS optimization
- Japanese Font Guide for PDF — CJK font handling
- PDF API Error Handling Guide — Production error handling
- Automate Invoice PDF Generation — Invoice automation in practice
- Batch PDF Generation Guide — High-volume document generation