How to generate PDFs in React in 2026
Three mature approaches to generating PDFs from a React app, when each one is right, and where they fall apart in production.
Generating PDFs from a React application sounds simple until you ship one. The decision splits into three families, each with a distinct shape. Pick the wrong one and you fight the library for weeks. Pick the right one and the feature lands in an afternoon.
This post is a map of the three families, how to choose between them, and the failure modes nobody warns you about.
The three families
┌─────────────────────────────────────────────────────────┐
│ Your data / React state │
└─────────────────────────────────────────────────────────┘
▼
┌────────────┬──────────────┬──────────────┐
│ HTML-to-PDF│ jsPDF-family │ react-pdf │
│ (Puppeteer,│ (jsPDF, │ (layout │
│ wkhtml) │ pdfmake) │ engine) │
└────────────┴──────────────┴──────────────┘
▼
A PDF file- HTML-to-PDF: render HTML and CSS, then hand it to a headless browser that "prints to PDF".
- Canvas / imperative drawing (
jsPDF,pdfmake): you call.text(),.line(),.rect()yourself on an in-memory canvas. - React-pdf family: a dedicated layout engine that takes JSX-like components and produces PDF primitives directly.
Each family trades something. Let's look at the trade.
1. HTML-to-PDF
How it works: your app already renders HTML. You take that HTML, pass it to a headless browser (Puppeteer, Playwright, or @sparticuz/chromium on serverless), and call page.pdf().
// app/api/invoice/route.ts
import { chromium } from "playwright";
export async function POST(req: Request) {
const { html } = await req.json();
const browser = await chromium.launch();
const page = await browser.newPage();
await page.setContent(html, { waitUntil: "networkidle" });
const buffer = await page.pdf({ format: "A4", printBackground: true });
await browser.close();
return new Response(buffer, {
headers: { "Content-Type": "application/pdf" },
});
}When it's right: the content already exists as HTML and is mostly flow-based (a blog post, a single-page report, a receipt). You can get to "good enough" in an hour.
Where it breaks:
- Cold starts are brutal. Chromium is 150 MB. On serverless you add 300 ms just to spin up the browser, per request, and memory pressure murders your function budget.
- Fonts drift. Anything you don't explicitly register is subject to the system the browser was compiled with. Your dev machine says "looks great" and prod substitutes Times New Roman.
- Pagination is opaque. You set
@pageCSS and hope. Complex tables get split in the wrong place. Chrome's CSSpage-break-inside: avoidlies often enough to hurt. - Backgrounds are off by default. You have to remember
printBackground: true. Miss it and your beautiful card borders disappear.
If you only need to make 100 PDFs a day and the content is a blog post, this is fine. For anything else, be aware you're outsourcing your layout engine to a browser that doesn't care about your PDF.
2. jsPDF-family
How it works: you draw to a virtual canvas imperatively.
import jsPDF from "jspdf";
const doc = new jsPDF();
doc.setFontSize(22);
doc.text("Invoice #INV-001", 20, 30);
doc.setFontSize(12);
doc.text("Acme Corp", 20, 40);
doc.line(20, 44, 190, 44);
// ...many more .text() and .rect() calls
doc.save("invoice.pdf");When it's right: you need small, stable, mostly-text documents (certificates, labels, single-page invoices) and the design will not change often.
Where it breaks:
- You're writing layout code by hand. Every time the design shifts by 4 pixels, you edit numbers. There is no
flex: 1. - Tables are a nightmare. Plugins like
jspdf-autotablehelp, but they bring their own layout quirks and don't compose with the rest. - No word-wrap that respects your typography. The library knows bytes, not grapheme clusters.
jsPDF is the imperative-canvas option. If your mental model is "I'll draw the document," this works. If your mental model is "I'll declare it," skip.
3. react-pdf (and friends)
How it works: a layout engine called Yoga (the same one React Native uses) runs inside Node or the browser. You write components with a limited set of primitives (<Document>, <Page>, <View>, <Text>, <Image>), and the engine produces real PDF drawing instructions.
// InvoiceTemplate.tsx
import { Document, Page, View, Text, StyleSheet } from "@react-pdf/renderer";
const styles = StyleSheet.create({
page: { padding: 40, fontSize: 11, fontFamily: "Helvetica" },
header: { flexDirection: "row", justifyContent: "space-between" },
h1: { fontSize: 22, fontWeight: "bold", marginBottom: 4 },
rule: { height: 1, backgroundColor: "#000", marginVertical: 16 },
});
export function Invoice({ data }: { data: InvoiceData }) {
return (
<Document>
<Page size="A4" style={styles.page}>
<View style={styles.header}>
<View>
<Text style={styles.h1}>{data.client}</Text>
<Text>{data.address}</Text>
</View>
<Text>Invoice #{data.number}</Text>
</View>
<View style={styles.rule} />
{/* ...items, totals, signature */}
</Page>
</Document>
);
}When it's right: anything repeatable. Invoices, receipts, reports, certificates, contracts, lab results. Any document where the layout is stable and the data changes.
Why it's the default in 2026:
- Real PDF output: selectable text, embedded fonts, searchable, accessible.
- Deterministic pagination: Yoga breaks pages based on the layout tree, not on the browser's mood that week.
- Composable:
<Header>,<Footer>,<Signature>are just components. You reuse them across five templates the same way you reuse<Button>. - Serverless-friendly: renders to a
Buffer. No Chromium needed.
Where it breaks:
- Limited primitives. No
grid-template-columns, noposition: sticky, no@media. You lay out with Flex and View stacking. - Fonts must be registered explicitly with
Font.register({ family, src }). Skipping this is the #1 reason "it works in dev, broken in prod." - No arbitrary HTML. You can't dump a blog post's rich HTML into a
<View>. If your content is user-generated markup, you need to parse it and map to react-pdf elements yourself.
How to actually decide
Ask one question. Is the document's layout going to change, or is the data going to change?
| Layout changes often | Data changes often | Use |
|---|---|---|
| Yes | No | HTML-to-PDF (or CMS-driven templates) |
| No | Yes | react-pdf |
| Yes and no, depends | Yes | react-pdf, write a good theme system |
| Neither | Neither | jsPDF, move on |
Most production use-cases are "the layout is stable, but we render ten thousand of them a day with different data." That's react-pdf's sweet spot.
The hidden cost nobody mentions
For any of these approaches, your designer hands you a Figma mockup. You translate by hand. Six months later the designer updates the template. You translate by hand again. Every translation drifts the implementation by 4-8 pixels.
The fix is either:
- A visual editor that outputs react-pdf code (what we built at PDFx Builder), or
- Bite the bullet and tell your designer to design in react-pdf primitives from day one.
Both are fine. Doing neither is how you end up with a 400-line invoice component that nobody wants to touch.
Summary
- HTML-to-PDF is fine for "print the blog post" and not much else in production.
- jsPDF is for static, hand-drawn documents where layout rarely changes.
- react-pdf is the default for data-driven documents in React apps in 2026.
If you're starting today, reach for react-pdf first. If you hit its limits (rich HTML content, exotic CSS features), then consider HTML-to-PDF with its full cost in mind. Skip jsPDF unless the document is trivially small.
And whatever you choose: register your fonts.
Build one of these yourself.
Start free at pdfxbuilder.com. Three templates, no credit card.
Open PDFx Builder