React to PDF: 4 ways, one invoice, measured
Updated by Cyril Nicodème
Render the React component you already have in a real browser, on the server. In our test of one 45-row invoice, that gave a 64 KB to 103 KB PDF with every word selectable, the CSS grid intact and no row cut in half. react-to-pdf gave a 1.74 MB PDF with 2 characters of text. @react-pdf/renderer needed a second copy of the component and split a row until we told it not to. So the choice is: run that browser yourself with Puppeteer, or send us the HTML and we run it.
How do the 4 React to PDF methods compare?
We built one invoice the way a web developer would: a flex header, a CSS grid for the bill-to and ship-to blocks, a <table> of 45 line items that runs onto a second A4 page, and a totals block. Then we turned it into a PDF four ways and measured each file:
| Method | File size | Selectable text | Row cut at page break |
|---|---|---|---|
| PDFShift API, same HTML | 64 KB | Yes, 3,455 characters | No |
renderToStaticMarkup + Puppeteer | 103 KB | Yes, 3,451 characters | No |
| @react-pdf/renderer | 7.5 KB | Yes, 3,350 characters | Yes, row 24, until we added wrap={false} |
| react-to-pdf (html2canvas + jsPDF) | 1,744 KB | No, 2 characters | Cuts at a fixed height |
Why is the react-to-pdf file so big, and why can't I copy its text?
Because it's a picture. react-to-pdf draws your component onto a canvas with html2canvas, saves it as JPEG, and puts the image on PDF pages with jsPDF. Its README says so: "Not vectorized - the pdf is created from a screenshot of the component". Nobody can search it, copy an amount from it, or read it with a screen reader.
import { usePDF } from 'react-to-pdf';
export function DownloadInvoice() {
const { toPDF, targetRef } = usePDF({ filename: 'invoice.pdf' });
return (
<>
<button onClick={() => toPDF()}>Download PDF</button>
<div ref={targetRef}><Invoice /></div>
</>
);
}
It looked like the browser because it is a picture of the browser. But the image is cut into pages at a fixed height, not between rows. Ours happened to break between rows 44 and 45; a longer description would have sliced a row through its text. The output also changes with the window width, since that's what gets captured. And it only runs in a browser: the README says "No SSR".
Use it for a one-page card or chart a user downloads from the screen. Don't use it for invoices, contracts or anything someone needs to search.
Is @react-pdf/renderer worth rewriting your component?
Only if the PDF is a new document. @react-pdf/renderer made the smallest, fastest file in our test, 7.5 KB in a median 33 ms, but it doesn't render HTML. You write the document again with its own primitives (Document, Page, View, Text) and a StyleSheet, and its styling is Flexbox only: the styling docs list display: flex and none, with no grid. Units are points by default. Custom fonts must be TTF or WOFF files registered with Font.register.
Our port was 48 lines against the original's 46. The grid became two flex columns, and the table became rows of Views:
import { renderToBuffer, Document, Page, View, Text } from '@react-pdf/renderer';
const InvoicePdf = () => (
<Document>
<Page size="A4" style={s.page}>
{/* header, parties and table head as View and Text */}
{items.map(i => (
// wrap={false} keeps each row whole across a page break
<View style={s.row} wrap={false} key={i.id}>
<Text style={s.desc}>{i.desc}</Text>
<Text style={s.num}>{i.qty}</Text>
</View>
))}
</Page>
</Document>
);
const buffer = await renderToBuffer(<InvoicePdf />); // a Node Buffer, ready to save or send
Without wrap={false}, row 24 split across the page break. With it, no row split.
And as long as the web version exists, every change to the invoice gets made twice.
How do you render an existing React component to PDF on the server?
Turn the component into an HTML string with renderToStaticMarkup from react-dom/server, put it in a full HTML document, and print that with headless Chromium. Same component, same CSS, no rewrite. This is what we ran:
import { renderToStaticMarkup } from 'react-dom/server';
import puppeteer from 'puppeteer';
const html = `<!doctype html><html><head><meta charset="utf-8"></head>
<body style="margin:0">${renderToStaticMarkup(<Invoice />)}</body></html>`;
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setContent(html);
const pdf = await page.pdf({ format: 'A4', printBackground: true });
await browser.close();
The PDF kept the grid and the flex header, broke between rows, and repeated the table's header row at the top of page 2. Use a real <table> and you get that for free. Keep printBackground: true. Puppeteer defaults to false, which drops background colors.
Two gotchas. The HTML string has no styles unless your component brings them, so put your CSS in the document's <head> or in a <style> tag. And renderToStaticMarkup doesn't wait for data: if a component suspends, React sends its fallback instead. Load the data first and pass it in as props, or use React 19's prerenderToNodeStream, which waits for it.
Puppeteer took 67 ms with the browser already open, and launching the browser took 404 ms more. And now you run Chromium on your servers, with its memory, its crashes and its updates. Our Puppeteer production guide covers what that takes.
Can you send that HTML string to an API instead of running Chromium?
Yes. The html string from the last step is all the PDFShift API needs. We render it on our Chromium and send the PDF back:
const response = await fetch('https://api.pdfshift.io/v3/convert/pdf', {
method: 'POST',
headers: {
'X-API-Key': process.env.PDFSHIFT_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ source: html, format: 'A4' }),
});
if (!response.ok) {
throw new Error(`PDFShift returned ${response.status}: ${await response.text()}`);
}
const pdf = Buffer.from(await response.arrayBuffer());
In a Next.js Route Handler, return it with new Response(pdf, { headers: { 'Content-Type': 'application/pdf' } }). If your component loads something after the first paint, the wait_for parameter names a global function we wait on before printing, for up to 30 seconds.
Our invoice came back as two pages with every row whole and every word selectable: the same result as Puppeteer, without a browser to install, patch or keep alive. People don't buy PDFs from us. They buy not having to run Chromium.
The free plan has 50 credits a month with no credit card. Get a free API key and send your own component through it.
How we tested
One machine, November 10, 2026: Apple Silicon, macOS 27.0.1, Node 26.11.1. React and react-dom 19.3.1, @react-pdf/renderer 4.9.0, react-to-pdf 3.2.2 (with html2canvas 1.4.1 and jsPDF 4.2.1), Puppeteer 25.17.0 with Chrome for Testing 157. The web component (Invoice.jsx, 46 lines) and its @react-pdf/renderer port (48 lines) read the same 45-item data file.
- react-to-pdf ran in headless Chrome at a 1,000 px wide window, with its default options (A4, JPEG, resolution 3).
- Puppeteer timings are
setContentpluspage.pdf(), with the browser already launched. Chrome needed the--no-sandboxflag on our test machine to load the page; keep the sandbox on for HTML your users write. - PDFShift got the same HTML string. We called it without an API key, so that file has the watermark keyless calls get.
- Each time is the median of 3 runs. Text was counted with pdf.js, and we checked the page break in the @react-pdf/renderer and Puppeteer files by rendering page 2 to an image and looking at it.
Frequently asked questions
Is react-pdf the same package as @react-pdf/renderer?
No. @react-pdf/renderer creates PDFs from its own components (Document, Page, View, Text). The package named react-pdf displays existing PDFs inside a React app. Install the first to generate a PDF, the second to show one.
What about react-to-print?
react-to-print prints a React component in the browser. It opens the print dialog, so a person picks Save as PDF themselves. That's fine for a print button, but your code never gets the PDF file, so you can't email it or store it.
Can I generate a PDF in the browser without taking a screenshot?
Yes, with @react-pdf/renderer, which runs in the browser as well as in Node. Its PDFDownloadLink and BlobProvider components build a real PDF with selectable text on the user's device.
We hope this guide was helpful. If you have any questions or noticed any issues on the code above,
feel free to drop us a line.