You have a React component (an invoice, a report, a receipt) and want a "Download PDF" button. The popular libraries look interchangeable in their READMEs, so we ran all three on the same 40-row invoice component and opened the PDFs with poppler. Versions: react-to-pdf 3.2.2, html2pdf.js 0.14.0, @react-pdf/renderer 4.9.0, React 19.3, run in headless Chromium and Node 22 on 21 October 2026.
| Approach | What came out |
|---|---|
| react-to-pdf html2canvas + jsPDF, in the browser | 1.2 MB for 2 pages, 0 selectable characters, no clickable links, and one table row sliced in half across the page break. |
| html2pdf.js html2canvas + jsPDF, in the browser | 202 KB, 0 selectable characters, the link kept, rows kept whole with pagebreak: { mode: ["css"] }, but each page is a 719 px wide picture, soft when zoomed or printed. |
| @react-pdf/renderer its own layout engine, no HTML | Our HTML component produced a blank page and a stream of "string child outside <Text> component" warnings. Real text, but only after a rewrite in its own primitives. |
| Server route + Chromium API renderToStaticMarkup, then /v1/pdf | 20 KB, 1,082 selectable characters, the link kept, rows kept whole, "1/2" page numbers, 0.4 s. |
An ordinary component with a CSS grid header, a link and a 40-row table, the kind of thing you already have on screen. The same file is used by every approach below.
import React from "react"; // tsx compiles JSX to React.createElement
export default function Invoice({ number, customer, items }) {
const total = items.reduce((s, i) => s + i.qty * i.price, 0);
return (
<div className="invoice">
<style>{`
.invoice { font-family: system-ui, sans-serif; color: #111; padding: 32px; }
.invoice header { display: grid; grid-template-columns: 1fr auto; gap: 16px; align-items: start; }
.invoice h1 { margin: 0; font-size: 28px; }
.invoice table { width: 100%; border-collapse: collapse; margin-top: 24px; }
.invoice th, .invoice td { padding: 8px; border-bottom: 1px solid #ddd; text-align: left; }
.invoice td.num, .invoice th.num { text-align: right; }
.invoice tr { break-inside: avoid; }
.invoice .total { font-weight: 700; font-size: 18px; }
`}</style>
<header>
<div><h1>Invoice {number}</h1><p>Billed to {customer}</p></div>
<div>Acme Ltd<br /><a href="https://example.com/pay">Pay online</a></div>
</header>
<table>
<thead><tr><th>Item</th><th className="num">Qty</th><th className="num">Amount</th></tr></thead>
<tbody>
{items.map((i, n) => (
<tr key={n}><td>{i.name}</td><td className="num">{i.qty}</td><td className="num">${(i.qty * i.price).toFixed(2)}</td></tr>
))}
<tr className="total"><td>Total</td><td></td><td className="num">${total.toFixed(2)}</td></tr>
</tbody>
</table>
</div>
);
}
import generatePDF from "react-to-pdf";
export default function ReactToPdfButton() {
return (
<button onClick={() => generatePDF(() => document.getElementById("invoice"), { filename: "invoice.pdf" })}>
Download PDF
</button>
);
}
It's the easiest to wire up, and the output looks right at a glance. But react-to-pdf rasterises the element with html2canvas and pastes the image into jsPDF. Our 2-page invoice came out at 1,235,677 bytes, as two images of 2400×3394 and 2400×1663 pixels, and pdftotext found no text at all: customers can't search or copy the invoice number, screen readers get nothing, and accounting tools that read PDFs see a blank document. The "Pay online" link is gone too.
The page break is a pixel cut. One tall image is sliced every 297 mm, wherever that falls. In our run, it went straight through the "Consulting, week 27" row: the top half of the text sits at the bottom of page 1 and the bottom half at the top of page 2. CSS break-inside: avoid can't help, because by the time the page is cut there is no CSS any more, just pixels. It also took 7.7 s in headless Chromium.
import html2pdf from "html2pdf.js";
export default function Html2PdfButton() {
const save = () =>
html2pdf()
.set({ margin: 10, filename: "invoice.pdf", pagebreak: { mode: ["css"] } })
.from(document.getElementById("invoice"))
.save();
return <button onClick={save}>Download PDF</button>;
}
html2pdf.js uses the same html2canvas + jsPDF pipeline, but it can move elements to the next page before capturing. With pagebreak: { mode: ["css"] } it honoured the break-inside: avoid on our rows (a gap at the bottom of page 1, no sliced row), and it kept the link as a clickable area. The PDF was 202 KB. The text is still an image, though: 0 selectable characters again, and each page is a 719 px wide JPEG, which looks soft when zoomed in and prints visibly fuzzy. You can raise html2canvas.scale, but the file size grows with the square of the scale. (We generated it with .outputPdf() to measure it; .save() runs the same pipeline.)
Both libraries also inherit html2canvas's limits: it re-implements CSS rather than using the browser's renderer, so newer CSS (filters, some gradients, object-fit, web fonts that haven't loaded yet) can come out differently from the screen. See html2canvas alternatives for measured examples.
@react-pdf/renderer produces proper vector PDFs with real text, but it doesn't render HTML. It has its own primitives (<Document>, <Page>, <View>, <Text>) and a StyleSheet with a subset of CSS (flexbox via Yoga; no grid, no <table>, no class names). Passing it our invoice component didn't throw. It logged "Invalid 'Invoice ' string child outside <Text> component" for each piece of text and wrote a blank one-page PDF. It's a good choice if you design the document for it from scratch. It's a rewrite if you want to reuse a component you already have.
Keep the component, turn it into an HTML string with renderToStaticMarkup, and have a real Chromium print it. You get the browser's own layout (grid, flexbox, web fonts, break-inside), real text and links, and the API key stays on your server. This Express route is the exact file we ran:
// server.jsx: run with `npx tsx server.jsx` (tsx compiles the JSX)
import express from "express";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import Invoice from "./Invoice.jsx";
const app = express();
app.get("/invoice/:number.pdf", async (req, res) => {
// Load the real invoice from your database here.
const data = {
number: req.params.number,
customer: "Jane Doe",
items: Array.from({ length: 40 }, (_, i) => ({ name: `Consulting, week ${i + 1}`, qty: 1 + (i % 3), price: 120 })),
};
const html = "<!doctype html><meta charset=utf-8>" + renderToStaticMarkup(<Invoice {...data} />);
const r = await fetch("https://shotmint.creasework.com/v1/pdf", {
method: "POST",
headers: { "X-API-Key": process.env.SHOTMINT_KEY, "Content-Type": "application/json" },
body: JSON.stringify({ html, format: "A4", margin: "12mm", page_numbers: true }),
});
if (!r.ok) return res.status(502).send(await r.text());
res.set("Content-Type", "application/pdf");
res.set("Content-Disposition", `attachment; filename="invoice-${req.params.number}.pdf"`);
res.send(Buffer.from(await r.arrayBuffer()));
});
app.listen(3001, () => console.log("http://localhost:3001/invoice/INV-2048.pdf"));
Run it with npm i express react react-dom, then SHOTMINT_KEY=your_key npx tsx server.jsx. tsx compiles the JSX with the classic React.createElement transform, so both files import React; without that import the route fails with "ReferenceError: React is not defined". In the React app, the button is just a link to that route:
// The browser only asks your server for the file; the API key never reaches the client.
export default function DownloadButton({ number }) {
return <a href={`/invoice/${number}.pdf`} download>Download PDF</a>;
}
Result on the same invoice: a 20,533-byte A4 PDF with 2 pages, 1,082 characters of selectable text, the "Pay online" link clickable, the page break falling between rows 23 and 24 (no sliced row), and "1/2" and "2/2" at the bottom of each page from page_numbers. The API call took 0.4 s.
Tips for the HTML string:
<style> tag, as above) or inline your stylesheet: the HTML is rendered on its own, so relative <link href="/app.css"> paths don't resolve. Tailwind users, read why Tailwind layouts collapse on A4.break-inside: avoid on rows and cards, break-before: page for sections. More in HTML to PDF page breaks.pagebreak: { mode: ["css"] }. Avoid react-to-pdf for anything longer than a page, because it slices rows.One PDF = one credit, however many pages it has. €5 buys 2,000 renders, credits never expire and there's no subscription. Get a free key to try the route above.
No signup, no card. Get a key in one click, then buy credits only if you need more (from €5 for 2,000, never expire). From a script, an AI agent, Make or Zapier, POST /v1/keys/free (no body) returns a key as JSON (openapi.json, no-code recipes).
All guides: guides · integrations · comparisons