HTML to PDF in React
Generate PDFs from React with react-to-print, @react-pdf/renderer, jsPDF with html2canvas, or server-side rendering to a PDF API, with tradeoffs.
"Export this page to PDF" sounds simple in React, but the options behave very differently. Some use the browser's print dialog, some draw a screenshot into a PDF, and one doesn't use HTML at all.
The most reliable approach renders on the server. Here is what each one does, and when to use it.
Quick answer. Render your component to HTML on the server with renderToStaticMarkup, then send it to the API from your backend (never from the browser).
curl -X POST https://html-pdf.com/v1/pdf \
-H "Authorization: Bearer $HTML_PDF_KEY" \
-H "Content-Type: application/json" \
-d '{"html": "<h1>Invoice #042</h1>"}' \
-o invoice.pdf
React PDF options compared
This table shows what each approach produces and where it runs.
| Approach | Output | Selectable text | Uses your HTML/CSS | Runs |
|---|---|---|---|---|
react-to-print / window.print() |
Whatever the user saves from the print dialog | Yes | Yes | Browser, user clicks through a dialog |
@react-pdf/renderer |
Real PDF | Yes | No (its own components and styles) | Browser or Node |
| jsPDF + html2canvas | Image of the DOM inside a PDF | No (with the image approach) | Roughly | Browser |
| Server render + html-pdf.com | Chromium PDF | Yes | Yes | Your backend |
Option 1: react-to-print (the print dialog)
react-to-print lets the user print one component instead of the whole page. In v3 you pass a ref.
import { useRef } from "react";
import { useReactToPrint } from "react-to-print";
export function InvoicePage({ invoice }) {
const contentRef = useRef(null);
const print = useReactToPrint({ contentRef, documentTitle: `invoice-${invoice.id}` });
return (
<>
<button onClick={print}>Download PDF</button>
<div ref={contentRef}>
<Invoice invoice={invoice} />
</div>
</>
);
}
Style the printed version with @media print and @page { size: A4; margin: 15mm }. It costs nothing and keeps your exact CSS.
The downside is that the user has to pick "Save as PDF" in a dialog, and margins, headers and scaling depend on their browser settings. You also can't email or store the file, because it never reaches your code.
Option 2: @react-pdf/renderer (not HTML)
@react-pdf/renderer builds real PDFs from React components. Those components are its own primitives, not DOM elements, so your existing JSX and CSS classes don't carry over.
import { Document, Page, Text, View, StyleSheet, PDFDownloadLink } from "@react-pdf/renderer";
const styles = StyleSheet.create({
page: { padding: 40 },
row: { flexDirection: "row", justifyContent: "space-between" },
});
const InvoicePdf = ({ invoice }) => (
<Document>
<Page size="A4" style={styles.page}>
<Text>Invoice #{invoice.number}</Text>
<View style={styles.row}>
<Text>Design work</Text>
<Text>$120.00</Text>
</View>
</Page>
</Document>
);
export const DownloadButton = ({ invoice }) => (
<PDFDownloadLink document={<InvoicePdf invoice={invoice} />} fileName="invoice.pdf">
Download PDF
</PDFDownloadLink>
);
It is a good choice when you design the PDF from scratch and want precise control. The cost is a second version of every layout, with a subset of CSS and fonts you register yourself.
Option 3: jsPDF + html2canvas
This approach gives you a PDF with no server. It captures the DOM as a canvas and places the image in a PDF.
import html2canvas from "html2canvas";
import { jsPDF } from "jspdf";
async function exportPdf(element) {
const canvas = await html2canvas(element, { scale: 2 });
const pdf = new jsPDF({ unit: "mm", format: "a4" });
const width = pdf.internal.pageSize.getWidth();
const height = (canvas.height * width) / canvas.width;
pdf.addImage(canvas.toDataURL("image/png"), "PNG", 0, 0, width, height);
pdf.save("invoice.pdf");
}
The result is a picture. Text isn't selectable or searchable, files are large, and content longer than one page needs manual slicing.
html2canvas also re-implements CSS rendering, so some styles come out differently.
Option 4: render on the server, convert with Chromium
For PDFs that look exactly like your components, have selectable text and can be emailed or stored, render the component to HTML on your backend and send it to a headless Chromium. html-pdf.com runs the Chromium part.
Never call the API from the browser. Anyone could read your API key from the network tab and spend your credits. The browser calls your backend, and your backend calls html-pdf.com.
Get a key and keep it in a server-side environment variable. Open the link we email you to confirm your address, which adds 100 free credits.
curl -X POST https://html-pdf.com/v1/signup -H "Content-Type: application/json" -d '{"email": "you@example.com"}'
Backend route (Node + Express)
// server.jsx: run with a JSX-aware runtime such as `tsx server.jsx`
import express from "express";
import { renderToStaticMarkup } from "react-dom/server";
import { Invoice } from "./src/Invoice.jsx";
const app = express();
const css = `body { font-family: system-ui, sans-serif } .row { display: flex; justify-content: space-between }`;
app.get("/api/invoices/:id/pdf", async (req, res) => {
const invoice = await loadInvoice(req.params.id); // check the user may see it
const html = "<!doctype html>" + renderToStaticMarkup(<Invoice invoice={invoice} />);
const r = await fetch("https://html-pdf.com/v1/pdf", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.HTML_PDF_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ html, css, format: "A4", margin: "15mm", max_pages: 20 }),
});
if (!r.ok) {
const { error } = await r.json();
return res.status(502).json({ message: error.message });
}
res.set("Content-Type", "application/pdf");
res.set("Content-Disposition", `attachment; filename="invoice-${invoice.id}.pdf"`);
res.send(Buffer.from(await r.arrayBuffer()));
});
app.listen(3001);
renderToStaticMarkup outputs plain HTML without React's hydration attributes. Components should render their data synchronously, because effects and client-side fetching don't run on the server.
If you use Tailwind or CSS modules, pass the built stylesheet in css, or inline it in a <style> tag. Images need absolute https:// URLs or data URIs, since the renderer can't reach localhost.
Download button in React
The button calls your backend route and saves the file.
async function downloadPdf(id) {
const res = await fetch(`/api/invoices/${id}/pdf`);
if (!res.ok) throw new Error("PDF failed");
const url = URL.createObjectURL(await res.blob());
const a = Object.assign(document.createElement("a"), { href: url, download: `invoice-${id}.pdf` });
a.click();
URL.revokeObjectURL(url);
}
The same backend pattern works in a Next.js route handler, Remix loader or any server framework.
Already have the page deployed publicly? Send {"url": "https://your-app.com/invoices/42/print"} instead of HTML, with wait_for_expression (for example "window.ready === true") so client-side data has loaded before printing.
Cost
You pay per page, and you start free. 1 credit = 1 PDF page, with 100 free credits and then packs from $10 for 1,000 pages. Purchased credits never expire, failed renders are free, and max_pages stops a runaway render without charge.
More: HTML to PDF in Node.js · page breaks · headers, footers and page numbers · API docs
FAQ
How do I generate a PDF from a React component?
Render the component to HTML on your backend with renderToStaticMarkup, then POST it to https://html-pdf.com/v1/pdf. Return the PDF bytes to the browser from your own route.
Can I call the PDF API directly from React in the browser?
No. Your API key would be visible in the network tab. The browser should call your backend, and your backend calls html-pdf.com.
Why is my jsPDF and html2canvas PDF blurry or not selectable?
That approach saves a screenshot of the DOM as an image, so text isn't real text. Rendering the HTML in Chromium gives you selectable, searchable text.
Is there a free tier?
Yes. You get 100 free credits once, with no card needed. Failed renders cost nothing, and purchased credits never expire.
