HTML to PDF in React in 2026: libraries, code, and troubleshooting

25 June 2026

The fastest way to convert HTML to PDF in React in 2026 is react-to-pdf for a single styled page you can export from the client, @react-pdf/renderer when you are designing the PDF as React components from scratch, and a server-side Chrome engine when the page has charts, JavaScript, custom fonts, or has to look identical across browsers. Pick the path that matches where the PDF needs to live, paste the code below, and ship.

Key Takeaways

- The first decision in React HTML-to-PDF is where the PDF is rendered, not which library. Client-side works for "export this one page." Server-side is the production answer for dashboards, charts, and multi-user fidelity.
- react-to-pdf and html2canvas-based libraries produce a screenshot, not a vector PDF. Text inside is not selectable or searchable, and zooming pixelates. The library's own README says so.
- @react-pdf/renderer is not an HTML-to-PDF converter. It is a "describe your PDF in React components" library. Treating it as a drop-in for react-to-pdf costs developers a week of wrong-tool pain.
- Tailwind v4's oklch() colors break html2canvas with the error Attempting to parse an unsupported color function 'oklch'. Fix it by overriding colors at PDF-render time, or by rendering server-side where a real browser handles it natively.
- For React Native, react-native-html-to-pdf is the canonical package, but it has slow maintenance, image-base64 requirements, and inconsistent iOS and Android margins. For complex layouts, render server-side and return the URL to the device.

React renders in a browser, and PDFs need to render the same way on every browser. Those two facts pull every React HTML-to-PDF tutorial in opposite directions, and most of them never tell you which way to go. You land on a Stack Overflow answer, paste in jsPDF plus html2canvas, and the demo works. Then your designer ships an update that uses oklch() colors, Recharts gets added to the dashboard, and the export starts producing blurry rectangles where the line charts used to be.

This guide is the version of that lesson I wish I had the first time I shipped a PDF export feature in a React app. The plan: name the architectural decision up front (client or server), give runnable code for every library that is worth using, show the Next.js production path nobody else writes, and call out the gotchas before you hit them. We will cover regular React on the web, then React Native at the end because the path there is different enough to deserve its own section.

How to convert HTML to PDF in React (the short answer)

For the searcher who wants the verdict in 30 seconds, here are the five paths worth knowing and what each one is for.

  • react-to-pdf: useRef on a DOM node, screenshot it, drop it into a PDF. Fastest to ship. Output is a rasterized image of the page, not selectable text.
  • @react-pdf/renderer: Describe the PDF as React components. Vector output, full control, real selectable text. Not an HTML converter, you rebuild the layout in PDF primitives.
  • jsPDF plus html2canvas: The manual version of react-to-pdf. Same screenshot model, more verbose, more control over pagination.
  • html2pdf.js: The same screenshot model wrapped in a friendlier API, with better page-break handling out of the box.
  • Server-side Chrome (Puppeteer, or a hosted engine like Transformy): A real browser renders the page on a server. Charts, JavaScript, oklch(), custom fonts, and identical output for every user. The production answer.

A quick comparison.

Library Where it runs Output type Charts and SVG JS execution Custom fonts Best for
react-to-pdf Browser Screenshot (raster) Blurry Yes (it's a browser) Inherited from page "Export this one page"
@react-pdf/renderer Browser or Node Vector PDF Manual rebuild Not applicable Font.register PDF as a designed component
jsPDF + html2canvas Browser Screenshot (raster) Blurry Yes (it's a browser) Inherited from page Manual pagination control
html2pdf.js Browser Screenshot (raster) Blurry Yes (it's a browser) Inherited from page Multi-page client export
Server-side Chrome Server Vector PDF Crisp Yes (real browser) Loaded server-side Production, multi-user, charts

Use this table as the decision shortcut. The next section explains why "where it runs" is the cell that matters most.

Where should the PDF be rendered, client or server?

This is the question every other React HTML-to-PDF tutorial skips, and skipping it is why so many PDF features ship, work for the developer, and break the moment a customer uses them on a slower machine.

Render client-side when

  • The PDF is "export this one page" for the user who is looking at it.
  • The HTML is purely styled. No charts, no SVG, no third-party JS widgets.
  • The output volume is small. One PDF per user click, not 10,000 a night.
  • You do not need selectable, searchable text inside the PDF.
  • The user has the bandwidth and CPU to spare.

A profile page export, a quick "save this article as PDF" button, a printable order confirmation. These are fine on the client. The user clicks, their browser does the work, the file lands in their Downloads folder.

Render server-side when

  • The HTML has charts (Recharts, Chart.js, D3) or SVG you need crisp.
  • The page uses oklch() colors, container queries, or modern color functions that html2canvas does not understand.
  • The PDF has to be byte-identical across users. Invoices, statements, legal documents.
  • You are exporting an authenticated dashboard. The server can forward the session.
  • You are rendering dozens or thousands of PDFs and need a queue plus workers.
  • You need selectable, searchable text in the output for search, accessibility, or compliance.

A useful rule of thumb

If a user clicks "Download my profile as PDF," client-side is fine. If the system emails monthly statements, server-side every time. If you cannot decide, render server-side. It scales the harder direction.

Want to skip the Chromium setup entirely? A hosted HTML-to-PDF API handles the server-side path for you. We will get to the Transformy version near the end of this guide.

Export a React component to PDF with react-to-pdf

react-to-pdf is the most direct path from "I have a styled component" to "the user has a PDF." It uses useRef to grab a DOM node, snapshots it with html2canvas under the hood, and hands it to jsPDF.

Install

npm install react-to-pdf

Minimal example (button to PDF)

import { useRef } from 'react';
import generatePDF, { Resolution, Margin } from 'react-to-pdf';

export default function InvoiceExport() {
  const targetRef = useRef<HTMLDivElement>(null);

  const options = {
    method: 'save',
    resolution: Resolution.HIGH,
    page: {
      margin: Margin.MEDIUM,
      format: 'letter',
      orientation: 'portrait',
    },
    canvas: { mimeType: 'image/png', qualityRatio: 1 },
  } as const;

  return (
    <div>
      <button onClick={() => generatePDF(targetRef, options)}>
        Download as PDF
      </button>
      <div ref={targetRef} className="invoice">
        <h1>Invoice #1041</h1>
        <p>Total: $4,800.00</p>
        {/* ...your styled invoice... */}
      </div>
    </div>
  );
}

That is the whole pattern. Twenty lines, button to PDF, ship it.

Gotcha 1: It is a screenshot, not a real PDF

react-to-pdf is honest about this in its own README: the output is "not vectorized, created from a screenshot." Practically, that means three things. Text inside the PDF is not selectable. The user cannot search for a word in their PDF reader. Zooming pixelates the file. The output is also larger than a vector render of the same page, sometimes by an order of magnitude.

For a one-click profile export, none of that matters. For an invoice your accounting team will reference for the next seven years, it matters a lot.

Gotcha 2: oklch colors blow up html2canvas (the Tailwind v4 trap)

If you upgraded to Tailwind v4, you inherited oklch() color defaults. html2canvas, which react-to-pdf uses under the hood, does not parse them. You get this in the console:

Error: Attempting to parse an unsupported color function "oklch"

The render either fails silently or produces a blank PDF.

There are two real fixes. The clean one is to render server-side, where a real Chromium handles oklch() natively. The fast one is to scope an override class to the capture container and swap the colors back to rgb() at PDF-render time.

<button
  onClick={async () => {
    document.body.classList.add('pdf-export');
    await generatePDF(targetRef, options);
    document.body.classList.remove('pdf-export');
  }}
>
  Download as PDF
</button>
/* In your global CSS */
.pdf-export .invoice {
  /* Re-declare colors using rgb() for the brief window the snapshot runs. */
  --tw-color-primary: rgb(37, 99, 235);
  --tw-color-text: rgb(17, 24, 39);
  background: rgb(255, 255, 255);
  color: var(--tw-color-text);
}

The fix is ugly, and you will rediscover that the next time Tailwind ships a new color function. That is the moment most teams give up and move to server rendering.

Gotcha 3: Tainted canvas (image CORS)

If your invoice includes a logo or an avatar served from another origin, html2canvas refuses to export the snapshot and throws:

SecurityError: Tainted canvases may not be exported

The cause is the same-origin policy. The browser will not let a canvas containing cross-origin pixels be read back unless the image was served with Access-Control-Allow-Origin headers and loaded with crossOrigin="anonymous". The fix is one of two things.

{/* Tell the browser the image is CORS-safe at load time. */}
<img src="https://cdn.example.com/logo.png" crossOrigin="anonymous" />

Or proxy the image through your own origin so it is no longer cross-origin.

Gotcha 4: Page breaks

react-to-pdf produces a single scaled page by default. For multi-page output, either split the source into multiple refs and call generatePDF per page, or switch to html2pdf.js (covered below), which has real CSS page-break support.

Build a PDF as React components with @react-pdf/renderer

This library is the most common source of "I thought this would convert my HTML" frustration in React PDF work. So let us name it directly: @react-pdf/renderer does not convert HTML. It is a separate renderer that produces vector PDFs from PDF primitives you build in React.

What it actually does

You build your PDF with components like <Document>, <Page>, <Text>, <View>, and <Image>. The library calculates layout in its own engine and emits a real vector PDF. Text is selectable. File sizes are small. Output is identical on every machine.

The price is that you do not get to reuse your HTML. If your invoice exists as a React component built with div and span and Tailwind classes, you will rebuild it in PDF primitives.

Minimal example

import {
  Document,
  Page,
  Text,
  View,
  StyleSheet,
  PDFDownloadLink,
  Font,
} from '@react-pdf/renderer';

// Self-host the font file in /public/fonts/Inter-Regular.ttf
Font.register({ family: 'Inter', src: '/fonts/Inter-Regular.ttf' });

const styles = StyleSheet.create({
  page: { padding: 32, fontFamily: 'Inter' },
  heading: { fontSize: 18, marginBottom: 12 },
  row: { flexDirection: 'row', justifyContent: 'space-between' },
});

const Invoice = () => (
  <Document>
    <Page size="LETTER" style={styles.page}>
      <Text style={styles.heading}>Invoice #1041</Text>
      <View style={styles.row}>
        <Text>Total</Text>
        <Text>$4,800.00</Text>
      </View>
    </Page>
  </Document>
);

export default function DownloadButton() {
  return (
    <PDFDownloadLink document={<Invoice />} fileName="invoice-1041.pdf">
      {({ loading }) => (loading ? 'Building PDF...' : 'Download invoice')}
    </PDFDownloadLink>
  );
}

When to reach for it

The PDF is part of your product. Invoices, tickets, statements, certificates. You want vector output, selectable text, real PDF text search, and small files. You are happy to design the PDF in React primitives rather than reuse the HTML.

Custom fonts

Font.register({ family, src }) is required for anything beyond Helvetica. Self-host the font file. Loading webfonts from a third-party CDN at render time fails intermittently, especially on serverless functions that may not have a warm DNS cache.

When not to use it

You want to convert existing HTML or CSS. Use react-to-pdf for the client path, or render server-side with Chrome.

jsPDF plus html2canvas (the manual recipe) and html2pdf.js

If react-to-pdf is too opinionated for your layout, drop one level and use jsPDF plus html2canvas directly. You get the same screenshot rasterization model with full control over pagination.

import { useRef } from 'react';
import jsPDF from 'jspdf';
import html2canvas from 'html2canvas';

export default function ReportExport() {
  const ref = useRef<HTMLDivElement>(null);

  async function handleDownload() {
    if (!ref.current) return;
    const canvas = await html2canvas(ref.current, { scale: 2, useCORS: true });
    const image = canvas.toDataURL('image/png');
    const pdf = new jsPDF({ unit: 'pt', format: 'letter' });
    const width = pdf.internal.pageSize.getWidth();
    const height = (canvas.height * width) / canvas.width;
    pdf.addImage(image, 'PNG', 0, 0, width, height);
    pdf.save('report.pdf');
  }

  return (
    <>
      <button onClick={handleDownload}>Export report</button>
      <div ref={ref}>{/* ...your report... */}</div>
    </>
  );
}

For multi-page output, html2pdf.js is friendlier. Its pagebreak option understands CSS, so you can mark sections with break-before: page and the library obeys.

import html2pdf from 'html2pdf.js';

html2pdf()
  .from(ref.current)
  .set({
    margin: 0.5,
    filename: 'report.pdf',
    pagebreak: { mode: ['css', 'legacy'] },
  })
  .save();

All three libraries share the same underlying constraints: rasterized output, no oklch(), CORS rules on images, and a tab that can freeze on very large pages. The day you hit any of those is the day to move the render to a server.

Render PDFs server-side from a Next.js API route

This is the part the rest of the search results skip. Most React apps ship on Next.js, and Next gives you a clean place to put server-side PDF generation: an App Router route handler. The render runs in a Node environment, with a real Chrome, and the client just downloads the result.

Why this is the production path

One render environment, your server's headless Chrome, means output is identical for every user. The user's tab does not freeze. Auth-gated dashboards render correctly because the server forwards the session cookie. Charts, oklch(), modern CSS, and JavaScript all work because it is a real browser.

Option A: Puppeteer on Vercel with @sparticuz/chromium

// app/api/pdf/route.ts
import { NextResponse } from 'next/server';
import puppeteer from 'puppeteer-core';
import chromium from '@sparticuz/chromium';

export const runtime = 'nodejs';
export const maxDuration = 30;

export async function POST(req: Request) {
  const { url, sessionCookie } = await req.json();

  const browser = await puppeteer.launch({
    args: chromium.args,
    executablePath: await chromium.executablePath(),
    headless: true,
  });

  try {
    const page = await browser.newPage();
    if (sessionCookie) {
      await page.setCookie({
        name: 'session',
        value: sessionCookie,
        domain: new URL(url).hostname,
        path: '/',
      });
    }
    await page.goto(url, { waitUntil: 'networkidle0', timeout: 25_000 });
    const pdf = await page.pdf({ format: 'Letter', printBackground: true });
    return new NextResponse(pdf, {
      headers: {
        'Content-Type': 'application/pdf',
        'Content-Disposition': 'attachment; filename="dashboard.pdf"',
      },
    });
  } finally {
    await browser.close();
  }
}

Install with npm install puppeteer-core @sparticuz/chromium. The @sparticuz/chromium package ships a Chromium binary trimmed to fit Vercel and AWS Lambda function size limits, which is what makes this path work serverless at all.

There is a real cost. Vercel's serverless function payload limit is currently 50MB compressed, and @sparticuz/chromium sits close to it before you add your own code. Cold starts on first invocation run 3 to 5 seconds while Chromium boots. For low-traffic exports, that is fine. For an interactive feature that gets called every few seconds, it is not.

Option B: A hosted HTML-to-PDF engine

This is where teams reach for a service instead of running Chromium themselves. The serverless function size budget gets blown, the cold start hurts the user flow, and patching the browser becomes someone's Tuesday afternoon. The Transformy version of the same route is below.

Render a React dashboard to PDF with Transformy

Transformy is an HTML-to-PDF API with two engines behind one request: Headless Chrome for modern web pages with charts, JavaScript, and oklch(), and wkhtmltopdf for fast, deterministic invoice-style documents. You choose the engine per request, which means a single integration handles both the dashboard export and the high-volume invoice render.

When to reach for it

You are hitting Vercel's function size limit on @sparticuz/chromium. You need charts, oklch(), custom fonts, and selectable text all at once. You are rendering hundreds or thousands of PDFs and do not want to manage a Chromium pool. Cold-start latency is unacceptable for the user flow.

Pattern: render the page server-side, hand the URL to Transformy

// app/api/pdf/route.ts
import { NextResponse } from 'next/server';

export async function POST(req: Request) {
  const { dashboardUrl, sessionCookie } = await req.json();
  const domain = new URL(dashboardUrl).hostname;

  const response = await fetch('https://api.transformy.io/v1/render', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.TRANSFORMY_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      url: dashboardUrl,
      engine: 'chrome',
      wait_for: '.dashboard-loaded',
      cookies: [
        { name: 'session', value: sessionCookie, domain, path: '/' },
      ],
      page: { format: 'Letter', margin: '0.5in' },
    }),
  });

  const { pdf_url } = await response.json();
  return NextResponse.json({ pdf_url });
}

The wait_for selector is the trick. Render the dashboard normally, append a hidden element with class dashboard-loaded once all your charts have mounted, and Transformy holds the snapshot until the page tells it the render is done. That is the cleanest fix for "the PDF came out empty because the chart had not loaded yet."

Why two engines matter for React teams

Set engine: "chrome" for dashboards, charts, anything with JS or oklch(). Set engine: "wkhtmltopdf" for the stable invoice template that ships thousands a day, where you want the faster, lighter render. Same auth, same request shape, no second integration to maintain. Start on the free tier, ship the dashboard export, and you only think about us again when the render volume does.

HTML to PDF in React Native (react-native-html-to-pdf)

React Native sits in a different lane. The dominant package is react-native-html-to-pdf by christopherdro, and it is the answer for most projects, with caveats.

Install and minimal example

npm install react-native-html-to-pdf
# iOS only
cd ios && pod install
import RNHTMLtoPDF from 'react-native-html-to-pdf';

async function createPDF() {
  const options = {
    html: `
      <h1>Receipt #4711</h1>
      <p>Thanks for your order.</p>
    `,
    fileName: 'receipt-4711',
    directory: 'Documents',
  };
  const file = await RNHTMLtoPDF.convert(options);
  return file.filePath;
}

Gotchas the tutorials skip

  • Write-permission errors on Android. Older Android targets need WRITE_EXTERNAL_STORAGE requested at runtime. Newer Android wants you to write to app-private storage instead. If you get a permission error, the fix is almost always one of those two.
  • Images must be base64 or absolute URLs. Relative paths fail silently. Encode logos and small images inline as data URIs.
  • Inline CSS only. External stylesheets are not fetched. Either inline the CSS in a <style> tag inside the html string or compute it as inline style="..." attributes.
  • iOS and Android render with different default margins and fonts. Test both. Setting an explicit <style> block with body { font-family: -apple-system, Roboto, sans-serif; margin: 0; } neutralizes most of the variance.

Maintenance reality

react-native-html-to-pdf's release pace has slowed. For new RN projects with complex layouts, an honest alternative is to render server-side and return the URL to the device. The device downloads a finished PDF, you skip the native module entirely, and the render works the same on iOS, Android, and the web build of your app if you have one.

FAQ: HTML to PDF in React

How do I convert HTML to PDF in React?

Use react-to-pdf for a quick client-side export, @react-pdf/renderer when you want to design the PDF as React components, or render server-side with Puppeteer (or a hosted engine like Transformy) when the page has charts, custom fonts, or needs identical output across users.

What is the best React library for HTML to PDF?

There is no single best. react-to-pdf wins on speed-to-ship for one-page exports. @react-pdf/renderer wins for production invoices where you want vector output. Server-side Chrome wins anything that involves charts, modern CSS, or auth-gated dashboards. Pick the one that matches where the PDF lives.

Should I render the PDF on the client or the server?

Client-side is fine for "the user clicked Export and wants a one-off PDF." Server-side is the right answer the moment the PDF has to look identical for every user, contains charts or oklch() colors, or has to render at volume. If you cannot decide, server-side scales the harder direction.

Why does react-to-pdf produce a blurry or unsearchable PDF?

Because it is a screenshot, not a vector PDF. react-to-pdf uses html2canvas under the hood to take a raster snapshot of the DOM, then pastes that image into a jsPDF document. Text is not selectable because there is no text in the file, only pixels. The library's own README says so. For selectable, vector output, use @react-pdf/renderer or render server-side.

How do I fix "Attempting to parse an unsupported color function 'oklch'"?

Either swap your oklch() colors back to rgb() inside a CSS class scoped to the capture container (and add that class to the body for the duration of the export), or render server-side where a real Chromium parses oklch() natively. The second fix is permanent. The first is temporary, and you will rediscover it the next time the design system ships a new color function.

How do I convert a React Native component to PDF?

Use react-native-html-to-pdf for simple receipts and statements built from an HTML string. For complex layouts or charts, the cleanest path is to render server-side, return the URL, and let the device download a finished PDF. That sidesteps the native module's iOS and Android quirks entirely.

Conclusion: pick the smallest tool that produces the PDF

The right React HTML-to-PDF choice is the simplest one that reliably ships the file. Use react-to-pdf for a single-page client export. Reach for @react-pdf/renderer when you are designing the PDF as a vector component from scratch. Switch to jsPDF plus html2canvas, or html2pdf.js, when you need more control over the rasterized client path. Move to server-side Chrome the moment your HTML has charts, oklch(), custom fonts, or needs to look identical for every user. For React Native, ship react-native-html-to-pdf for simple jobs and render server-side for anything complex.

A practical path: (1) Decide where the PDF lives, client or server. (2) Try react-to-pdf if it lives on the client. (3) Use @react-pdf/renderer if the PDF is a designed component, not converted HTML. (4) Move the render to a Next.js API route the moment you hit charts, oklch(), or auth-gated dashboards. (5) Reach for a hosted engine when the serverless Chromium budget, cold-start latency, or maintenance load stops being worth it.

Render your first PDF on the Transformy free tier. Point it at your dashboard URL, forward the session cookie, set wait_for to the element that signals "fully painted," and you get the same vector PDF every time, without owning the browser that produced it.