How to Render HTML as a PDF in React
Choose the right React-to-PDF approach, then follow runnable browser, Puppeteer, html2pdf.js, react-pdf, and ScreenshotNeo examples.
Short answer: React renders HTML; it does not create PDF files by itself. For a person saving the current page, build a print stylesheet and open the browser print dialog. For automated server output that preserves HTML and CSS, render the page in Chromium with Puppeteer and call page.pdf(). For client-only capture, use html2pdf.js. If the PDF is a document with its own layout rather than a copy of existing DOM, compose it with react-pdf.
The correct choice depends on whether you need a user-controlled download, an unattended server file, a client-side export, or a purpose-built document. The examples below show each route and the edge cases that usually determine production quality.
1. Choose the rendering model
| Approach | Best fit | Tradeoffs |
|---|---|---|
| Browser print flow | A user clicks Export and saves from the browser | Print dialog and browser-specific pagination; requires print CSS |
html2pdf.js |
Client-only capture of a DOM element | Runs in the browser, uses html2canvas and jsPDF, and needs testing for long or complex layouts |
Puppeteer Page.pdf() |
Automated files from a server or job worker | You must provision Chromium and control loading, fonts, resources, and concurrency |
| Hosted conversion API | You want managed browser execution | HTML or URLs leave your system; evaluate privacy, limits, cost, latency, and output behavior |
react-pdf |
A report or invoice designed as a PDF document | You compose with PDF primitives instead of exporting arbitrary DOM |
React’s renderToStaticMarkup and renderToString return HTML strings. They are not PDF APIs, and renderToString does not wait for data or support streaming.
2. Browser download with print CSS
Use this when the person is present and can confirm the browser’s print or Save as PDF dialog. Keep the printable content in a stable component, remove controls, define page size and margins, and manage page breaks.
import React from 'react';
import { createRoot } from 'react-dom/client';
import './print.css';
function Invoice({ invoice }) {
return (
<main className="invoice">
<header className="invoice__header">
<h1>Invoice {invoice.number}</h1>
<p>Issued {invoice.issuedAt}</p>
</header>
<section className="invoice__customer">
<strong>Bill to</strong>
<div>{invoice.customer.name}</div>
<div>{invoice.customer.email}</div>
</section>
<table className="invoice__items">
<thead><tr><th>Item</th><th>Qty</th><th>Amount</th></tr></thead>
<tbody>
{invoice.items.map((item) => (
<tr key={item.id}>
<td>{item.description}</td>
<td>{item.quantity}</td>
<td>{item.amount}</td>
</tr>
))}
</tbody>
</table>
<p className="invoice__total">Total: {invoice.total}</p>
<button className="screen-only" onClick={() => window.print()}>
Save as PDF
</button>
</main>
);
}
createRoot(document.getElementById('root')).render(
<Invoice invoice={window.__INVOICE__} />
);
/* print.css */
.invoice { max-width: 800px; margin: 2rem auto; font: 14px/1.5 system-ui, sans-serif; }
.invoice__items { width: 100%; border-collapse: collapse; }
.invoice__items th, .invoice__items td { border-bottom: 1px solid #ddd; padding: 8px; text-align: left; }
@media print {
@page { size: A4; margin: 16mm 14mm; }
body { margin: 0; color: #000; background: #fff; }
.screen-only { display: none !important; }
.invoice { max-width: none; margin: 0; }
thead { display: table-header-group; }
tr, img, .avoid-break { break-inside: avoid; }
h1, h2, h3 { break-after: avoid; }
.page-break-before { break-before: page; }
}
Call window.print() from a user action. A browser controls the final dialog, printer settings, and some pagination behavior, so test the browsers your users actually run. Use real data, long descriptions, images, and tables when checking page breaks.
3. Generate a PDF on the server with Puppeteer
Puppeteer launches Chromium, loads a route containing the intended HTML, waits for data, images, and fonts, then returns PDF bytes. Its PDF guide documents print CSS behavior, and Page.pdf() waits for fonts by default.
Install and run
npm install express puppeteer
node server.mjs
// server.mjs
import express from 'express';
import puppeteer from 'puppeteer';
const app = express();
app.get('/invoices/:id.pdf', async (req, res) => {
const browser = await puppeteer.launch({ headless: true });
try {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 900, deviceScaleFactor: 1 });
await page.goto(`https://your-app.example/invoices/${encodeURIComponent(req.params.id)}/print`, {
waitUntil: 'networkidle0',
timeout: 60000
});
await page.evaluate(async () => {
if (document.fonts) await document.fonts.ready;
await Promise.all([...document.images].map((img) => img.complete
? Promise.resolve()
: new Promise((resolve) => { img.addEventListener('load', resolve); img.addEventListener('error', resolve); })
));
});
const pdf = await page.pdf({
format: 'A4',
printBackground: true,
preferCSSPageSize: true,
margin: { top: '16mm', right: '14mm', bottom: '16mm', left: '14mm' }
});
res.type('application/pdf').set('Content-Disposition', 'inline; filename="invoice.pdf"').send(pdf);
} catch (error) {
res.status(500).json({ error: 'PDF generation failed' });
} finally {
await browser.close();
}
});
app.listen(3000, () => console.log('PDF server listening on :3000'));
Important Puppeteer options
format,width, andheightselect paper dimensions. Use one model consistently.printBackground: truepreserves background colors and images.preferCSSPageSize: truelets@pagerules control size.landscape: truerotates the paper.marginsets PDF margins; CSS margins still affect content.pageRangeslimits output to ranges such as1-3.displayHeaderFooter,headerTemplate, andfooterTemplateadd Chromium-generated headers and footers.
Authenticate the print route separately from the public app, pass only the data needed for the document, and prevent untrusted users from turning your renderer into an arbitrary URL fetcher. Reuse a browser process or a bounded pool for throughput, but create a fresh page per job and close it in a finally block.
4. Client-side export with html2pdf.js
html2pdf.js converts a webpage or element through html2canvas and jsPDF. It runs in the browser, not Node.js.
npm install html2pdf.js
import html2pdf from 'html2pdf.js';
export function downloadElementAsPdf(element) {
return html2pdf().set({
margin: [12, 12, 12, 12],
filename: 'report.pdf',
image: { type: 'jpeg', quality: 0.95 },
html2canvas: { scale: 2, useCORS: true, backgroundColor: '#ffffff' },
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' },
pagebreak: { mode: ['css', 'legacy'] }
}).from(element).save();
}
Cross-origin images need suitable CORS headers. Canvas-based output can change selectable text, links, scaling, and page breaks, especially for very long or image-heavy pages. Validate the actual document before adopting this route.
5. Build a PDF document with react-pdf
Use react-pdf when the output is a designed document rather than a printout of an existing interface.
import { Document, Page, Text, View, StyleSheet, PDFDownloadLink } from '@react-pdf/renderer';
const styles = StyleSheet.create({ page: { padding: 40 }, title: { fontSize: 22, marginBottom: 16 }, row: { flexDirection: 'row', marginBottom: 8 }, cell: { flex: 1, fontSize: 11 } });
function ReportPdf({ rows }) {
return (<Document><Page size="A4" style={styles.page}>
<Text style={styles.title}>Report</Text>
{rows.map((row) => <View style={styles.row} key={row.id}><Text style={styles.cell}>{row.name}</Text><Text style={styles.cell}>{row.value}</Text></View>)}
</Page></Document>);
}
export function DownloadReport({ rows }) {
return <PDFDownloadLink document={<ReportPdf rows={rows} />} fileName="report.pdf">Download PDF</PDFDownloadLink>;
}
6. Or skip the browser setup
For a hosted capture, ScreenshotNeo accepts one GET request and returns PNG, JPEG, WebP, or PDF. It can render a URL after accepting cookie banners and removing more than 60 known consent platforms, newsletter popups, and chat widgets. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed; response headers identify the page verdict and billing result.
Use the API directly for a print route or any public page. See the ScreenshotNeo API documentation for all options.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://your-app.example/invoices/123/print -d format=pdf -o invoice.pdf
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://your-app.example/invoices/123/print", "format": "pdf"}, timeout=90)
r.raise_for_status()
open("invoice.pdf", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://your-app.example/invoices/123/print', format: 'pdf' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
await Bun.write('invoice.pdf', res);
Relevant controls include paper size, margins, landscape mode, page ranges, custom CSS and JavaScript, waiting for a selector or network idle, custom headers and cookies, timezone and geolocation, blocked resource types, caching with a chosen TTL, asynchronous jobs with signed webhooks, bulk capture of up to 100 URLs per call, and signed links for public embeds. An MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients.
There are 1,000 free shots per month with no card. Paid plans start at $5 for 3,000 shots; every feature is available on every plan. Create a free ScreenshotNeo account.
7. Loading, layout, and pagination checklist
- Wait for the data request that populates the page, not only the initial navigation event.
- Wait for
document.fonts.readyand for important images to finish or fail. - Use print media rules and explicit
@pagedimensions. - Prevent breaks inside signatures, table rows, cards, and figures with
break-inside: avoid. - Repeat table headings with
thead { display: table-header-group; }. - Remove sticky navigation, animations, hover-only content, dialogs, and export buttons.
- Set a deterministic timezone, locale, and data snapshot so repeated jobs produce the same pages.
- Keep assets reachable from the renderer and configure CORS for client-side canvas capture.
8. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| PDF is blank | Capture ran before data rendered or route requires authentication | Wait for a content selector, authenticate the print route, and log the rendered HTML |
| Fonts or images are missing | Asset requests failed or were still loading | Use absolute reachable URLs, await fonts and images, and check network responses |
| Colors disappear | Print backgrounds are disabled | Enable printBackground in Puppeteer and set print colors deliberately |
| Rows split awkwardly | No break rules or oversized content | Apply break-inside: avoid; redesign items that cannot fit one page |
| html2pdf output is blurry | Low canvas scale or oversized rasterization | Raise html2canvas.scale carefully and test memory use |
| Puppeteer times out | Long requests, blocked resources, or a browser process under load | Set an explicit timeout, inspect network logs, block unnecessary resources, and bound concurrency |
| PDF differs between runs | Animations, changing data, fonts, timezone, or ads | Freeze inputs, disable motion, set locale/timezone, and use a dedicated print route |
9. Performance, reliability, and cost
Browser print has no server rendering cost but moves work and user interaction to the client. html2pdf.js avoids a browser server but can consume substantial memory for long pages. Puppeteer gives the most faithful HTML/CSS result, while Chromium startup and page rendering consume CPU and memory; reuse a bounded browser pool and measure queue time, render time, PDF size, and failure rate with your own documents.
For production jobs, use idempotent job identifiers, retry transient navigation failures with a limit, close pages after every job, and store the source data version alongside the PDF. Hosted APIs shift browser operations to a vendor, so review data handling, retention, request limits, pricing, and regional requirements before sending sensitive HTML.
10. FAQ
Can React itself export arbitrary JSX to PDF?
No. JSX must first become HTML in a browser or be composed with a PDF renderer such as react-pdf.
Which option preserves existing CSS best?
A real browser print engine, usually Puppeteer for automation, because it renders the page’s HTML and CSS before producing PDF bytes.
Should I use renderToStaticMarkup?
Use it only when you need a static HTML string for another pipeline. It does not create a PDF and is not a replacement for browser rendering.
How do I export only one component?
Give the component a stable root selector, use that element with html2pdf.js, or create a dedicated print route containing only that component for Puppeteer or ScreenshotNeo.
How can I keep PDFs consistent?
Control fonts, viewport, paper settings, timezone, locale, data snapshot, animations, external resources, and page-break rules, then test representative documents in the target runtime.


