How to Generate and Download Puppeteer PDFs From a React Button
Build a React button that asks a server-side Puppeteer endpoint to generate a PDF, return its bytes, and download it reliably in the browser.
Direct answer: A React button should trigger an event handler that calls a server endpoint. That endpoint runs Puppeteer, opens the report page, calls page.pdf(), and returns the PDF bytes with Content-Type: application/pdf. The browser turns the response into a Blob and starts a download.
Keep Puppeteer on the server. A browser-only React component cannot safely launch the Node.js Chromium process used for PDF generation. React supplies the interaction; your server owns browser startup, navigation, PDF options, cleanup, authentication, and errors.
Architecture: button, API route, and Puppeteer
- The user clicks a semantic
<button>. - The React handler sends
GETorPOSTto an application endpoint. - The endpoint launches or reuses Chromium, navigates to the report, and calls
await page.pdf(options). - The endpoint returns the resulting
Uint8Arrayas an HTTP response. - The browser creates a
Blob, assigns an object URL to a temporary download link, clicks it, and later revokes the URL.
Puppeteer documents Page.pdf() as the PDF generation step and says it returns a promise for PDF bytes. Its PDF guide also demonstrates closing the browser after generation. React documents event handlers passed to onClick; pass the function rather than calling it while rendering. See the Puppeteer PDF API, Puppeteer PDF guide, and React event documentation.
Complete Express and React example
1. Install the server dependencies
mkdir puppeteer-react-pdf
cd puppeteer-react-pdf
npm init -y
npm install express puppeteer
Use a Node.js runtime for this endpoint. The first Puppeteer install may download a compatible browser; follow Puppeteer’s current installation guidance for your deployment environment.
2. Create the PDF endpoint
// server.js
const express = require('express');
const puppeteer = require('puppeteer');
const app = express();
app.use(express.json());
app.get('/api/report.pdf', async (req, res) => {
const reportUrl = 'http://localhost:3000/report';
let browser;
try {
browser = await puppeteer.launch({
// Set executablePath here when your host supplies Chromium.
// headless: true is the default in current Puppeteer releases.
});
const page = await browser.newPage();
await page.goto(reportUrl, {
waitUntil: 'networkidle0',
timeout: 30_000,
});
// PDF uses print media by default. Keep this line when the report has
// print-specific CSS; use 'screen' when you want screen styles instead.
// await page.emulateMediaType('screen');
const pdfBytes = await page.pdf({
format: 'A4',
printBackground: true,
preferCSSPageSize: true,
margin: {
top: '16mm',
right: '16mm',
bottom: '16mm',
left: '16mm',
},
timeout: 30_000,
waitForFonts: true,
});
res.set({
'Content-Type': 'application/pdf',
'Content-Disposition': 'attachment; filename="report.pdf"',
'Content-Length': String(pdfBytes.length),
'Cache-Control': 'no-store',
});
res.send(Buffer.from(pdfBytes));
} catch (error) {
console.error('PDF generation failed:', error);
if (!res.headersSent) {
res.status(500).json({ error: 'PDF generation failed' });
}
} finally {
if (browser) {
await browser.close();
}
}
});
app.listen(4000, () => {
console.log('PDF API listening on http://localhost:4000');
});
The path option is omitted, so Puppeteer returns bytes instead of writing a server-side file. If you need a retained artifact, set path to a file location and still decide separately how the browser will download it.
3. Add the React button
import { useState } from 'react';
export default function DownloadPdfButton() {
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
async function handleDownload() {
setBusy(true);
setError('');
try {
const response = await fetch('http://localhost:4000/api/report.pdf');
if (!response.ok) {
throw new Error(`PDF request failed (${response.status})`);
}
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectUrl;
link.download = 'report.pdf';
document.body.appendChild(link);
link.click();
link.remove();
// Keep the URL alive briefly so the download has started in browsers
// that defer the synthetic click. Revoke it after the current task.
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to download PDF');
} finally {
setBusy(false);
}
}
return (
<div>
<button type="button" onClick={handleDownload} disabled={busy}>
{busy ? 'Generating PDF…' : 'Download PDF'}
</button>
{error && <p role="alert">{error}</p>}
</div>
);
}
The download attribute works with same-origin, blob:, and data: URLs. Object URLs consume browser resources, so revoke them after the download has had time to begin; do not revoke before the resource is accessible. See MDN createObjectURL(), MDN revokeObjectURL(), and the anchor download attribute.
4. Run it
node server.js
Run your React development server separately, make sure http://localhost:3000/report renders the intended content, and allow the React origin in CORS when the API is on another origin.
Making the rendered page PDF-ready
Wait for data and fonts
Navigate only after the report route can render its data. For client-rendered reports, wait for a stable selector or application-ready flag:
await page.goto(reportUrl, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('[data-report-ready]', { timeout: 30_000 });
await page.evaluate(() => document.fonts.ready);
waitUntil: 'networkidle0' is useful for pages that become quiet, but analytics, WebSockets, and long polling can prevent it from completing. A specific readiness selector is usually more predictable.
Control print CSS
@page {
size: A4;
margin: 16mm;
}
@media print {
.no-print { display: none !important; }
.page-break { break-before: page; }
}
.report {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
PDF generation uses print media by default. Call await page.emulateMediaType('screen') when screen styles are intentional. Background graphics are disabled by default; set printBackground: true when they are part of the document. Puppeteer notes that printing can modify colors and points to -webkit-print-color-adjust when exact colors matter.
Important Puppeteer PDF options
| Option | What it controls | Practical note |
|---|---|---|
path |
Writes a server-side file. | Omit it when returning bytes directly. |
format |
Paper preset such as A4 or Letter. | format takes priority over width and height. |
width, height |
Custom paper dimensions. | Use preferCSSPageSize when CSS @page should win. |
preferCSSPageSize |
Uses CSS page dimensions. | Helpful for reports with precise page templates. |
landscape |
Rotates the page. | Useful for wide tables and dashboards. |
margin |
Top, right, bottom, and left margins. | Accepts CSS length strings. |
printBackground |
Includes background colors and images. | Defaults to false. |
displayHeaderFooter |
Adds PDF header and footer templates. | Use headerTemplate and footerTemplate. |
pageRanges |
Restricts output pages. | For example, 1-3 or 1,4. |
waitForFonts |
Waits for fonts before printing. | Defaults to true; custom fonts still need reachable URLs. |
timeout |
PDF generation timeout. | Defaults to 30 seconds; zero disables this timeout. |
Choose format or explicit dimensions deliberately, avoid oversized margins that create unexpected pages, and inspect page breaks for tables, charts, and positioned elements.
Alternative delivery designs
Return bytes immediately
This is the best fit for a button that should download now. It avoids exposing a server filesystem path and lets the browser own the save interaction. Response size and generation time still occupy the request, so use a job queue for very large or slow reports.
Save a file on the server
page.pdf({ path: '/tmp/report.pdf' }) is useful when another process consumes the artifact or you need retention. Use unique filenames, clean up temporary files, and never expose arbitrary user-controlled paths.
Use a remote browser
A hosted browser service can remove some Chromium operations from your deployment, but it adds an external dependency and its own authentication, limits, and failure modes. Browserless documents a Puppeteer download API; evaluate its current service terms separately.
Calling the endpoint with cURL, Python, and Node.js
cURL
curl -f http://localhost:4000/api/report.pdf -o report.pdf
Python
import requests
response = requests.get("http://localhost:4000/api/report.pdf", timeout=90)
response.raise_for_status()
with open("report.pdf", "wb") as output:
output.write(response.content)
Node.js
const response = await fetch('http://localhost:4000/api/report.pdf');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const pdf = Buffer.from(await response.arrayBuffer());
require('node:fs').writeFileSync('report.pdf', pdf);
Security and reliability checklist
- Authenticate the endpoint; otherwise anyone who can reach it may spend CPU generating reports.
- Authorize the report ID on the server. Do not trust a client-supplied URL without an allowlist.
- Prevent server-side request forgery when navigating to user-provided URLs.
- Use a bounded navigation and PDF timeout, and return a useful non-PDF error response on failure.
- Close pages and browsers in
finallyblocks. For throughput, a carefully managed browser pool can reduce launch overhead, but cap concurrent pages. - Keep secrets out of page HTML and logs. Pass required authentication with server-side headers or a controlled session.
- Set
Content-Dispositionwith a safe filename andCache-Control: no-storefor private reports. - For large reports, stream or use an asynchronous job with status polling rather than holding an HTTP request open.
Performance and cost considerations
Launching Chromium for every click is simple but adds startup latency. Reusing a browser process while creating a fresh page can improve throughput, provided you isolate cookies, headers, and user data between requests. Limit concurrency because each page consumes memory and the rendered document, images, and fonts affect both time and PDF size.
Cache deterministic reports by report version and user authorization. Do not cache private PDFs under a shared key. For expensive reports, enqueue a job, store the result for a short period, and return a signed download URL.
Your infrastructure cost includes browser CPU, memory, bandwidth, storage for retained files, and any hosted-browser usage. Puppeteer itself does not provide a PDF price; measure your workload in its deployment environment before choosing worker limits.
Troubleshooting
“Puppeteer is not defined” or browser launch fails
Cause: Puppeteer is being imported in client code, Chromium is unavailable, or the host lacks required libraries. Fix: keep imports in the server runtime, install the browser build or configure executablePath, and follow your host’s sandbox and dependency requirements.
The button does nothing
Cause: the handler was called during render, the button is disabled forever, or the request is blocked by CORS. Fix: use onClick={handleDownload}, inspect the browser network panel, and configure CORS for the React origin.
The response downloads as JSON or HTML
Cause: the server returned an error page or middleware handled the route first. Fix: check response.ok, inspect the status and body, and ensure successful responses set Content-Type: application/pdf.
The PDF is blank
Cause: the page was printed before client data arrived, a protected route redirected to login, or content is hidden in print CSS. Fix: wait for a report-ready selector, authenticate the Puppeteer page, and inspect the page with page.content() or a diagnostic screenshot.
Colors or backgrounds are missing
Cause: print backgrounds are disabled or print color adjustment changes colors. Fix: set printBackground: true and add print color adjustment CSS where exact colors are required.
Fonts or icons are wrong
Cause: font requests failed, the font was not loaded before printing, or a relative asset URL is incorrect. Fix: use absolute reachable asset URLs, wait for document.fonts.ready, and retain waitForFonts: true.
Navigation times out
Cause: a long-polling request prevents network idle, the page is slow, or the URL is unreachable from the server. Fix: use domcontentloaded plus a readiness selector, abort unnecessary resources, verify server-side DNS and authentication, and set a deliberate timeout.
The download works only sometimes
Cause: object URL cleanup happens before the browser consumes it, or multiple clicks create overlapping requests. Fix: revoke after the download begins, disable the button while busy, and handle cancellation and retry states.
Or skip the browser setup
ScreenshotNeo provides a website screenshot and PDF API when you do not want to operate Puppeteer and Chromium yourself. One request returns a PDF or image; its capture flow can accept consent banners and remove more than 60 known consent platforms, newsletter popups, and chat widgets before the shot. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers identify the page verdict and billing status. Its MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients.
See the ScreenshotNeo API documentation for PDF parameters and authentication.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
ScreenshotNeo includes 1,000 screenshots per month free with no card; paid plans start at $5 for 3,000. Create a free ScreenshotNeo account.
FAQ
Can Puppeteer run inside a React component?
No. Run it in a server-side Node.js process and call that process from React.
Should I use GET or POST?
GET is convenient for a fixed report route. Use POST when the request contains filters, a large payload, or sensitive parameters that should not appear in a URL.
Does page.pdf() print screen styles?
Print media is the default. Call page.emulateMediaType('screen') when you need screen styles.
Why use a Blob instead of navigating directly to the API URL?
A Blob lets the React handler control authentication, error handling, and the client filename before starting the download.
When should I use an asynchronous job?
Use one when reports are slow, large, requested in batches, or likely to exceed normal HTTP timeouts. Return a job ID, expose status, and provide the finished file when ready.


