Engineering9 min read

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
  1. HTML-to-PDF: render HTML and CSS, then hand it to a headless browser that "prints to PDF".
  2. Canvas / imperative drawing (jsPDF, pdfmake): you call .text(), .line(), .rect() yourself on an in-memory canvas.
  3. 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 @page CSS and hope. Complex tables get split in the wrong place. Chrome's CSS page-break-inside: avoid lies 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-autotable help, 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, no position: 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