PDF Generation in React: react-pdf, Puppeteer and @react-pdf/renderer
Compare react-pdf, @react-pdf/renderer, and Puppeteer for generating PDFs in React apps - with real code, honest tradeoffs, and when to use each.
The PDF Problem in React
PDF generation sounds boring until you're three days into building an invoice download feature and you've already trashed two libraries. React wasn't designed with print output in mind - the DOM model and PDF's fixed-page model are fundamentally different things, and every library you'll use is essentially fighting that mismatch.
There are three approaches that actually work in production: @react-pdf/renderer (write JSX, get PDF), Puppeteer (render HTML in headless Chrome, print to PDF), and the older react-pdf package (which is actually a PDF *viewer*, not a generator - more on that in a second). Each has a genuinely different use case, and picking the wrong one will cost you days.
Honestly, the naming situation is a mess. react-pdf on npm is a viewer. @react-pdf/renderer is a generator. They're unrelated packages from different authors. If you've already confused the two, you're not alone - this trips up nearly every dev who touches PDF work in React for the first time.
Worth noting: your deployment environment matters enormously here. @react-pdf/renderer runs in the browser and in Node. Puppeteer needs a real Chromium binary, which rules it out of serverless edge runtimes unless you use @sparticuz/chromium or a dedicated render service. Plan your architecture before you commit.
react-pdf vs @react-pdf/renderer: Clearing Up the Confusion
react-pdf (package: react-pdf, maintained by Wojciech Maj) is a PDF viewer component. It wraps Mozilla's PDF.js and renders existing PDF files inside your React app. You'd use it to display invoices, contracts, or any uploaded PDF - not to generate new ones. If you need a viewer, it's excellent. Version 7.x ships with solid Next.js App Router support and handles large documents well.
@react-pdf/renderer is the generator. You write JSX using its own set of layout primitives - <Document>, <Page>, <View>, <Text>, <Image> - and the library converts your component tree into a binary PDF using its own PDF specification implementation. No browser required. That's the key advantage: you can call it in a Node API route, a React Server Component, or even directly in the browser via the PDFDownloadLink component.
The layout engine in @react-pdf/renderer is Yoga-based (the same flexbox engine React Native uses), so if you know React Native's layout model, you'll feel at home. If you don't, expect a learning curve - you can't use arbitrary CSS, only a specific subset of flexbox and text styling properties.
One more thing - @react-pdf/renderer v3.x (released late 2024) brought significant performance improvements for large documents and added support for SVG gradients. If you're on v2.x, upgrade. The breaking changes are minimal and the render speed difference on multi-page documents is noticeable.
Building an Invoice PDF with @react-pdf/renderer
Here's a realistic invoice component. The key thing to internalize is that you're not writing HTML - <View> is your div, <Text> is your span/p, and styles are plain objects, not class names.
import {
Document,
Page,
View,
Text,
StyleSheet,
PDFDownloadLink,
} from '@react-pdf/renderer';
const styles = StyleSheet.create({
page: {
padding: 40,
fontFamily: 'Helvetica',
fontSize: 11,
color: '#1a1a1a',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 32,
},
brand: { fontSize: 20, fontWeight: 'bold' },
table: { marginTop: 16 },
tableRow: {
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: '#e5e7eb',
paddingVertical: 8,
},
cell: { flex: 1 },
totalRow: {
flexDirection: 'row',
justifyContent: 'flex-end',
marginTop: 16,
},
});
interface LineItem {
description: string;
qty: number;
unitPrice: number;
}
interface InvoicePDFProps {
invoiceNumber: string;
items: LineItem[];
clientName: string;
}
function InvoiceDocument({ invoiceNumber, items, clientName }: InvoicePDFProps) {
const total = items.reduce((sum, i) => sum + i.qty * i.unitPrice, 0);
return (
<Document>
<Page size="A4" style={styles.page}>
<View style={styles.header}>
<Text style={styles.brand}>Acme Corp</Text>
<View>
<Text>Invoice #{invoiceNumber}</Text>
<Text>{new Date().toLocaleDateString()}</Text>
</View>
</View>
<Text>Bill to: {clientName}</Text>
<View style={styles.table}>
{items.map((item, idx) => (
<View key={idx} style={styles.tableRow}>
<Text style={styles.cell}>{item.description}</Text>
<Text style={styles.cell}>{item.qty}</Text>
<Text style={styles.cell}>${item.unitPrice.toFixed(2)}</Text>
<Text style={styles.cell}>${(item.qty * item.unitPrice).toFixed(2)}</Text>
</View>
))}
</View>
<View style={styles.totalRow}>
<Text>Total: ${total.toFixed(2)}</Text>
</View>
</Page>
</Document>
);
}
// Usage in a React component
export function InvoiceDownloadButton(props: InvoicePDFProps) {
return (
<PDFDownloadLink
document={<InvoiceDocument {...props} />}
fileName={`invoice-${props.invoiceNumber}.pdf`}
>
{({ loading }) => (loading ? 'Generating…' : 'Download Invoice PDF')}
</PDFDownloadLink>
);
}The PDFDownloadLink renders an anchor tag that generates the PDF client-side on click. For server-side generation (Next.js API route), swap to renderToBuffer from @react-pdf/renderer - it returns a Buffer you can pipe into an HTTP response with Content-Type: application/pdf.
Quick aside: custom fonts are a common pain point. You register them with Font.register({ family: 'Inter', src: '/fonts/Inter-Regular.ttf' }) before your component tree renders. Google Fonts URLs don't work reliably - self-host the TTF files in your public/ folder. The 400, 700, and italic variants cover 90% of cases.
In practice, @react-pdf/renderer handles documents up to ~50 pages smoothly on a 512 MB Lambda function. Beyond that, stream the output with renderToStream instead of buffering the whole thing in memory.
When Puppeteer is the Right Answer
Puppeteer's pitch is simple: it prints whatever Chrome would print. That means pixel-perfect fidelity with your existing React components, full CSS support including backdrop-filter, CSS Grid, web fonts loaded from CDN, everything. If you've invested in a polished HTML design - say, a UI built on Empire UI's component system - and you want the PDF to look *exactly* like the browser output, Puppeteer is the tool.
The tradeoff is infrastructure. Puppeteer needs a Chromium binary (~150 MB). On AWS Lambda or Vercel you'll need @sparticuz/chromium which strips the binary down to ~50 MB compressed, but you're still dealing with cold start times of 2–5 seconds and memory limits. Vercel's Edge Runtime won't work at all - you'd route PDF generation to a standard Node function.
// pages/api/invoice-pdf.ts (Next.js Pages Router)
import type { NextApiRequest, NextApiResponse } from 'next';
import puppeteer from 'puppeteer-core';
import chromium from '@sparticuz/chromium';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const browser = await puppeteer.launch({
args: chromium.args,
defaultViewport: chromium.defaultViewport,
executablePath: await chromium.executablePath(),
headless: chromium.headless,
});
const page = await browser.newPage();
// Navigate to a server-rendered invoice page
const { invoiceId } = req.query;
await page.goto(
`${process.env.NEXT_PUBLIC_URL}/invoices/${invoiceId}?print=1`,
{ waitUntil: 'networkidle0' },
);
const pdf = await page.pdf({
format: 'A4',
printBackground: true,
margin: { top: '40px', bottom: '40px', left: '40px', right: '40px' },
});
await browser.close();
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename=invoice-${invoiceId}.pdf`);
res.send(pdf);
}That waitUntil: 'networkidle0' is important - it waits until there are no pending network requests, so web fonts and async data are loaded before printing. If your page uses client-side fetching you might need waitUntil: 'networkidle2' or an explicit page.waitForSelector() targeting an element that only renders after data loads.
Look, Puppeteer is operationally heavier but it unlocks things @react-pdf/renderer simply can't do: CSS Grid layouts, SVG charts, images with object-fit, and any component from a design system built for the browser. For complex, design-heavy documents it's worth the infrastructure complexity. For data-driven tables and reports, stick with @react-pdf/renderer.
Server-Side Generation with Next.js App Router
In the App Router world (Next.js 14+), the cleanest pattern for @react-pdf/renderer is a Route Handler that returns a streaming PDF response. React Server Components can't render @react-pdf/renderer components directly because the library uses browser APIs internally - but a Route Handler runs in Node and works perfectly.
// app/api/pdf/invoice/[id]/route.ts
import { NextRequest } from 'next/server';
import { renderToStream } from '@react-pdf/renderer';
import { InvoiceDocument } from '@/components/pdf/InvoiceDocument';
import { getInvoice } from '@/lib/db';
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } },
) {
const invoice = await getInvoice(params.id);
if (!invoice) {
return new Response('Not found', { status: 404 });
}
const stream = await renderToStream(
<InvoiceDocument
invoiceNumber={invoice.number}
items={invoice.lineItems}
clientName={invoice.clientName}
/>
);
return new Response(stream as unknown as ReadableStream, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="invoice-${invoice.number}.pdf"`,
},
});
}The renderToStream call returns a Node.js Readable stream. Casting it to ReadableStream for the Web API Response constructor is a bit awkward but works. If you need a tighter integration you can convert it explicitly: Readable.toWeb(stream as Readable) from Node's stream/web module.
Worth noting: mark this route segment with export const runtime = 'nodejs' if you're on Vercel and have Edge as your default runtime. The PDF library won't work on the edge runtime.
For Puppeteer in the App Router, the same pattern applies - a GET Route Handler spins up Chromium, navigates to a print-optimized version of your page, and streams back the result. Pair it with a ?print=true query param your page checks to hide navigation, add @media print CSS, and set explicit page break rules.
Styling PDFs to Match Your Design System
One of the frustrations of @react-pdf/renderer is maintaining two style systems - one for your web UI and one for the PDF layout. There's no Tailwind in PDF-land. That said, you can build a small set of shared design tokens (spacing scale, color palette, type scale) in a plain TypeScript file and import it in both your web components and your PDF components.
// lib/design-tokens.ts
export const tokens = {
colors: {
brand: '#6366f1',
gray100: '#f3f4f6',
gray900: '#111827',
white: '#ffffff',
},
spacing: {
xs: 4,
sm: 8,
md: 16,
lg: 24,
xl: 40,
},
fontSize: {
xs: 9,
sm: 10,
base: 11,
lg: 14,
xl: 18,
'2xl': 24,
},
} as const;Import those tokens in StyleSheet.create() calls and you get consistent spacing and colors across web and PDF without drift. It's not as ergonomic as Tailwind but it prevents the situation where your PDF looks like it was designed in 2008 while your web app looks great.
If your app uses Empire UI components or a similar design library, the PDF output won't visually match anyway - that's fine. The goal for PDFs is usually *brand consistency*, not pixel-matching. Use your brand colors, your font (registered via Font.register), and your logo (as a PNG in the <Image> component). The 40px page margin is a good starting default for A4.
For Puppeteer-generated PDFs, add a dedicated globals.css block gated on @media print {}. Hide anything that shouldn't appear in the PDF (nav, footer, button), switch to font-size: 11pt, set color: #000 to override custom colors that won't reproduce well, and use page-break-inside: avoid on table rows and card components. The browser's print engine is surprisingly capable when you give it those hints.
Choosing the Right Tool: A Decision Framework
So which one do you use? Here's the honest breakdown. If you need client-side PDF generation with no server round-trip - like a quote builder where the user fills in fields and downloads instantly - use @react-pdf/renderer with PDFDownloadLink. Works in the browser, no server needed, bundle size is around 300 KB gzipped which is acceptable for a feature-gated download.
If you need server-side generation from structured data (invoices, reports, receipts) and you want to control the output format tightly, use @react-pdf/renderer server-side via renderToBuffer or renderToStream. It's fast, it's predictable, and it doesn't need Chromium. The performance ceiling is comfortably above what most apps need - 100 ms for a 10-page document on a basic Node server.
If you already have a polished HTML/CSS template that matches your web UI exactly, and the visual fidelity matters (think client-facing proposal documents, design reports, portfolio exports), use Puppeteer. Yes, it's heavier operationally, but the output quality is unmatched and your design team won't hate you. Just cache aggressively - PDFs for the same data shouldn't regenerate on every request.
That said, there's a fourth option worth knowing about: hosted PDF APIs like Browserless, Gotenberg, or DocRaptor. If you're on a serverless stack and don't want the Chromium deployment headache, these services accept HTML or a URL and return a PDF. They add a network hop and a per-call cost, but they're genuinely easier to operate. Gotenberg v8 (released 2024) has excellent Docker support and a clean REST API that pairs well with Next.js server actions.
The React ecosystem has no single canonical PDF library the way it has React Query for server state or Zod for validation. That's annoying but it also means the field is still moving - @react-pdf/renderer v4.x is in active development as of mid-2026 with experimental streaming support. Keep an eye on it. For now, the patterns in this article cover 95% of what real production apps need.
FAQ
react-pdf is a PDF viewer component - it displays existing PDF files in your React app using PDF.js. @react-pdf/renderer is a generator that takes JSX and outputs a new PDF file. They're unrelated packages from different maintainers.
Yes, use a Route Handler with export const runtime = 'nodejs' and call renderToStream or renderToBuffer. It won't work in Edge Runtime or as a React Server Component directly.
Yes, but you need @sparticuz/chromium to get a stripped Chromium binary that fits serverless function size limits. Plan for 2–5 second cold starts and set your function memory to at least 1 GB.
No - @react-pdf/renderer uses its own Yoga-based layout engine with a subset of flexbox properties passed as plain style objects. Extract shared values (colors, spacing) into TypeScript tokens and import them in both your web and PDF components.