ScreenshotNeo

BlogHow-to

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.

By the ScreenshotNeo team1 October 20269 min read

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, and height select paper dimensions. Use one model consistently.
  • printBackground: true preserves background colors and images.
  • preferCSSPageSize: true lets @page rules control size.
  • landscape: true rotates the paper.
  • margin sets PDF margins; CSS margins still affect content.
  • pageRanges limits output to ranges such as 1-3.
  • displayHeaderFooter, headerTemplate, and footerTemplate add 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.ready and for important images to finish or fail.
  • Use print media rules and explicit @page dimensions.
  • 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.