Get started
How to convert a React component to PDF

How to convert a React component to PDF

Two ways to turn React components into PDFs: server-render JSX to HTML and print with Chromium, or use @react-pdf/renderer. Includes Tailwind, MUI, and benchmarks.

benoitded14 min read

There are two philosophies for turning React components into PDFs, and they pull in opposite directions. One path treats React as a templating engine: render JSX to HTML, then print the HTML with a headless browser. You get every CSS feature your browser supports, including Tailwind, CSS grid, custom fonts, and @media print rules. The other path treats React as a declarative DSL for the PDF format itself: write JSX components that map directly to PDF primitives, with no browser involved. You get a tiny dependency, fast cold starts, and a constrained but reliable subset of CSS. This guide covers both, plus a third hybrid path that ships the HTML to a managed API instead of running Chromium yourself.

Which path should you pick?

If your component already exists in your web app and uses Tailwind, MUI, or any modern CSS, render it to HTML and print with Chromium. If you are building PDFs from scratch and you want predictable output without bundling a browser, use @react-pdf/renderer. If you do not want to manage a Chromium pool in production, render to HTML in your app and POST to a PDF API.

CriterionRender-to-HTML + Chromium@react-pdf/rendererRender-to-HTML + PDF API
CSS3 (transforms, gradients)Full supportPartial (no transforms)Full support
@media print rulesYesNo (no concept of media)Yes
CSS gridYesNoYes
FlexboxYes (browser)Yes (Yoga)Yes (browser)
Custom web fontsAny (via @font-face)TTF or WOFF only, declared via Font.registerAny
EmojiNative (color font)Requires Twemoji shimNative
Tailwind / MUIYes (compile CSS first)NoYes
Pagination controlCSS break-* rulesFirst-class Page componentCSS break-* rules
Cold start800-1500 msNoneNone (managed pool)
Bundle size~300 MB (Chromium)~3 MB (Yoga + parser)0 (HTTP only)

The decision usually comes down to two questions. First: do you already have a React UI you want to print as-is? Then go HTML plus Chromium (or HTML plus API). Second: is your PDF a paginated document built from data, like a 30-page report or a multi-page invoice? Then @react-pdf/renderer gives you cleaner pagination control and a smaller dependency.

Approach A: render to HTML, then print with Chromium

This path treats React as a template engine. You call renderToStaticMarkup from react-dom/server, wrap the resulting HTML in a minimal document, and pass it to Playwright. Anything Chrome can display, the PDF will contain. CSS grid, custom fonts, Tailwind, MUI, gradients, transforms, even @media print rules: all of it works.

Install:

npm install react react-dom playwright
npx playwright install chromium

Build a sample component:

// Invoice.tsx
import React from "react";
 
interface Item {
  description: string;
  quantity: number;
  price: number;
}
 
export function Invoice({
  number,
  customer,
  items,
}: {
  number: string;
  customer: string;
  items: Item[];
}) {
  const total = items.reduce((sum, i) => sum + i.quantity * i.price, 0);
  return (
    <div className="invoice">
      <header>
        <h1>Invoice {number}</h1>
        <p>Billed to: {customer}</p>
      </header>
      <table>
        <thead>
          <tr>
            <th>Description</th>
            <th>Qty</th>
            <th>Price</th>
            <th>Total</th>
          </tr>
        </thead>
        <tbody>
          {items.map((item, i) => (
            <tr key={i}>
              <td>{item.description}</td>
              <td>{item.quantity}</td>
              <td>${item.price.toFixed(2)}</td>
              <td>${(item.quantity * item.price).toFixed(2)}</td>
            </tr>
          ))}
        </tbody>
      </table>
      <footer>
        <strong>Total: ${total.toFixed(2)}</strong>
      </footer>
    </div>
  );
}

Render it to a PDF:

import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { chromium } from "playwright";
import { writeFileSync } from "node:fs";
import { Invoice } from "./Invoice";
 
const css = `
  body { font-family: Inter, system-ui, sans-serif; color: #111827; padding: 40px; }
  .invoice header { border-bottom: 2px solid #111827; padding-bottom: 16px; margin-bottom: 24px; }
  h1 { font-size: 28px; margin: 0 0 4px; }
  table { width: 100%; border-collapse: collapse; }
  th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid #e5e7eb; }
  th { background: #f3f4f6; font-weight: 600; }
  footer { margin-top: 24px; text-align: right; font-size: 18px; }
`;
 
async function reactToPdf() {
  const body = renderToStaticMarkup(
    <Invoice
      number="INV-001"
      customer="Acme Corp"
      items={[
        { description: "Web design", quantity: 1, price: 2500 },
        { description: "Hosting (annual)", quantity: 1, price: 240 },
      ]}
    />,
  );
 
  const html = `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap">
  <style>${css}</style>
</head>
<body>${body}</body>
</html>`;
 
  const browser = await chromium.launch();
  const page = await browser.newPage();
  await page.setContent(html, { waitUntil: "networkidle" });
  await page.evaluate(() => document.fonts.ready);
 
  const pdf = await page.pdf({
    format: "A4",
    margin: { top: "20mm", bottom: "20mm", left: "15mm", right: "15mm" },
    printBackground: true,
  });
 
  writeFileSync("invoice.pdf", pdf);
  await browser.close();
}
 
reactToPdf();

Two non-obvious bits. Use renderToStaticMarkup, not renderToString: the static variant skips React's data-reactroot attribute and other hydration markers, which keeps the HTML clean. And always call document.fonts.ready before exporting, so any Google Font you load via <link> has actually arrived before the snapshot.

How do I use Tailwind in the Chromium path?

Tailwind is a build-time CSS framework: it scans your source files for class names and emits a CSS file that contains only the classes you actually used. The Chromium path can use that compiled file directly. Three steps:

First, configure the Tailwind CLI to scan your React components:

// tailwind.config.js
module.exports = {
  content: ["./src/**/*.{tsx,ts}"],
  theme: { extend: {} },
  plugins: [],
};

Second, compile to a CSS string at startup:

npx tailwindcss -i ./src/input.css -o ./dist/tailwind.css --minify

Third, inline the compiled CSS into the HTML wrapper:

import { readFileSync } from "node:fs";
const tailwindCss = readFileSync("./dist/tailwind.css", "utf-8");
 
const html = `<!DOCTYPE html>
<html><head><meta charset="utf-8"><style>${tailwindCss}</style></head>
<body>${body}</body></html>`;

Now every Tailwind class in your JSX renders correctly inside the PDF. The trick is making sure your build pipeline regenerates tailwind.css whenever a component changes (in dev, the Tailwind CLI's --watch flag handles this; in CI, run it in your build step).

How do I handle Material UI in the Chromium path?

MUI uses Emotion under the hood, which generates dynamic CSS at render time. To capture that CSS during server-side rendering, wrap your tree in a CacheProvider with a fresh emotion cache, render the React tree, and extract the collected styles before injecting them into the HTML wrapper.

import createCache from "@emotion/cache";
import { CacheProvider } from "@emotion/react";
import createEmotionServer from "@emotion/server/create-instance";
import { ThemeProvider, createTheme } from "@mui/material/styles";
import { renderToStaticMarkup } from "react-dom/server";
 
function renderMuiToHtml(node: React.ReactElement) {
  const cache = createCache({ key: "css" });
  const { extractCriticalToChunks, constructStyleTagsFromChunks } =
    createEmotionServer(cache);
  const theme = createTheme({ palette: { mode: "light" } });
 
  const body = renderToStaticMarkup(
    <CacheProvider value={cache}>
      <ThemeProvider theme={theme}>{node}</ThemeProvider>
    </CacheProvider>,
  );
 
  const chunks = extractCriticalToChunks(body);
  const styles = constructStyleTagsFromChunks(chunks);
 
  return `<!DOCTYPE html><html><head>${styles}</head><body>${body}</body></html>`;
}

Pass the resulting HTML to Playwright exactly as before. Every MUI component in the tree gets its emotion CSS captured and inlined.

Approach B: @react-pdf/renderer

@react-pdf/renderer ships its own renderer that maps a small set of React components (<Document>, <Page>, <View>, <Text>, <Image>, <Link>) directly onto the PDF format. There is no browser, no HTML, no DOM. The library uses Yoga (the flexbox engine from React Native) for layout and PDFKit under the hood to write the PDF bytes. Cold start is non-existent and the dependency is roughly three megabytes total.

npm install @react-pdf/renderer
import {
  Document,
  Page,
  Text,
  View,
  StyleSheet,
  Font,
  renderToFile,
} from "@react-pdf/renderer";
import React from "react";
 
// Custom font: register from a TTF file or URL
Font.register({
  family: "Inter",
  src: "https://rsms.me/inter/font-files/Inter-Regular.woff",
});
 
const styles = StyleSheet.create({
  page: { padding: 40, fontFamily: "Inter", fontSize: 12 },
  header: { borderBottom: "2 solid #111827", paddingBottom: 16, marginBottom: 24 },
  title: { fontSize: 24, fontWeight: 600 },
  row: { flexDirection: "row", borderBottom: "1 solid #e5e7eb", paddingVertical: 6 },
  col: { flex: 1 },
  total: { marginTop: 16, textAlign: "right", fontSize: 16, fontWeight: 600 },
});
 
interface Item {
  description: string;
  quantity: number;
  price: number;
}
 
function InvoicePdf({ items }: { items: Item[] }) {
  const total = items.reduce((s, i) => s + i.quantity * i.price, 0);
  return (
    <Document>
      <Page size="A4" style={styles.page}>
        <View style={styles.header}>
          <Text style={styles.title}>Invoice INV-001</Text>
          <Text>Billed to: Acme Corp</Text>
        </View>
        <View style={styles.row}>
          <Text style={styles.col}>Description</Text>
          <Text style={styles.col}>Qty</Text>
          <Text style={styles.col}>Price</Text>
          <Text style={styles.col}>Total</Text>
        </View>
        {items.map((item, i) => (
          <View key={i} style={styles.row}>
            <Text style={styles.col}>{item.description}</Text>
            <Text style={styles.col}>{item.quantity}</Text>
            <Text style={styles.col}>${item.price.toFixed(2)}</Text>
            <Text style={styles.col}>${(item.quantity * item.price).toFixed(2)}</Text>
          </View>
        ))}
        <Text style={styles.total}>Total: ${total.toFixed(2)}</Text>
      </Page>
    </Document>
  );
}
 
await renderToFile(
  <InvoicePdf
    items={[
      { description: "Web design", quantity: 1, price: 2500 },
      { description: "Hosting", quantity: 1, price: 240 },
    ]}
  />,
  "invoice.pdf",
);

StyleSheet.create looks like React Native because it is: the library borrows the same flexbox model, the same numeric values, and the same camelCase property names. Borders are written as a single string ("2 solid #111827"), not as separate borderWidth, borderStyle, borderColor keys.

The killer feature for documents is the <Page> component. Splitting your invoice across multiple pages is automatic when content overflows, and you can break manually by wrapping content in a new <Page>. CSS grid does not exist here, so any layout that needs a grid has to be rebuilt with nested flexboxes.

Approach C: render to HTML, send to a PDF API

If you do not want to install Playwright and a Chromium binary on your production servers (or pay for the RAM that an idle browser pool eats), you can keep the React-to-HTML half locally and ship the rendering half to a managed API. Same JSX, same CSS, no browser to maintain.

import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { Invoice } from "./Invoice";
 
async function reactToPdfApi(items: any[]) {
  const body = renderToStaticMarkup(
    <Invoice number="INV-001" customer="Acme Corp" items={items} />,
  );
 
  const html = `<!DOCTYPE html><html><head><meta charset="utf-8">
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap">
  <style>
    body { font-family: Inter, sans-serif; color: #111827; padding: 40px; }
    table { width: 100%; border-collapse: collapse; }
    th, td { padding: 8px 12px; border-bottom: 1px solid #e5e7eb; }
    th { background: #f3f4f6; }
  </style>
  </head><body>${body}</body></html>`;
 
  const res = await fetch("https://pdf4.dev/api/v1/render", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${process.env.PDF4_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      html,
      format: {
        preset: "a4",
        margins: { top: "20mm", bottom: "20mm", left: "15mm", right: "15mm" },
      },
    }),
  });
 
  if (!res.ok) throw new Error(`Render failed: ${res.status}`);
  return Buffer.from(await res.arrayBuffer());
}

This works inside any Node.js runtime, including serverless functions (Vercel, Netlify, Cloudflare Workers with Node compat). The API holds a warm Chromium pool, so cold start is removed entirely, and you do not pay for a 300 MB Chromium binary in your deployment artifact.

When should you pick which approach?

The choice is rarely close. Two questions usually settle it.

Does the component already exist in your web app? If yes, render it to HTML and use the Chromium path (or the API path). Reusing the same JSX and the same CSS that your users already see in the browser keeps your styling pipeline single-sourced. The PDF inherits brand colors, typography, and layout for free.

Is the PDF a paginated document built from data? A 30-page report, a multi-page invoice with line items spilling onto page 3, a contract with chapter breaks. Then @react-pdf/renderer is a better fit: pagination is first-class, you have a <Page> component, and you can control breaks with wrap and break props. Trying to do the same with CSS break-before rules in the Chromium path works but feels fragile.

A common production pattern is to use both. The marketing site renders product specs and brochures with the Chromium path because they reuse the existing Tailwind design system. The billing system renders invoices with @react-pdf/renderer because invoices are stamped from data and never share CSS with the marketing site.

Common gotchas

renderToString versus renderToStaticMarkup: always pick the static variant for PDF rendering. renderToString adds React-specific data attributes meant for client-side hydration, which add noise to the HTML without changing how it looks.

Hooks do not work in @react-pdf/renderer: the library runs JSX through its own renderer that does not implement React's hook dispatcher. Pass everything as props. If you need state, compute it before rendering.

Web fonts in @react-pdf/renderer: register fonts before rendering with Font.register({ family, src }). The src can be a URL or a local file path. You cannot use Google Fonts CSS imports because there is no CSS pipeline.

Next.js API routes: @react-pdf/renderer works in API routes, but the Chromium path needs a real Node runtime (not Edge) and a Chromium binary that fits in your serverless bundle. On Vercel, use @sparticuz/chromium for Lambda or move to a long-running container.

FAQ

What is the best way to convert a React component to PDF?

Two paths. Render React to HTML with renderToStaticMarkup and convert the HTML to PDF with headless Chromium for full CSS support including Tailwind, grid, and custom fonts. Or use @react-pdf/renderer to declare PDFs directly in JSX without a browser. The first wins on CSS fidelity, the second wins on speed and bundle size.

Can I use Tailwind classes in a React-to-PDF pipeline?

Yes, in the Chromium path. Compile Tailwind to a single CSS file at build time with the Tailwind CLI, inject the compiled CSS into the HTML wrapper as a <style> block, then render. @react-pdf/renderer does not understand Tailwind classes because it runs entirely outside a browser.

Does @react-pdf/renderer support flexbox and CSS grid?

It supports flexbox via the Yoga engine, which is the same layout engine React Native uses. It does not support CSS grid. If your design needs a grid, switch to the Chromium path or rebuild the layout as nested flexboxes.

How fast is React PDF generation?

@react-pdf/renderer typically renders a one-page PDF in 100 to 300 milliseconds with no cold start. The Chromium path costs roughly 800 to 1500 milliseconds cold (browser launch) and 200 to 500 milliseconds warm. A managed PDF API removes the cold-start penalty by keeping a warm browser pool.

Can I use React hooks inside a PDF component?

In the Chromium path, you render React on the server with renderToStaticMarkup, which only runs functional components, not hooks that depend on a browser environment. In @react-pdf/renderer, the JSX runs inside a custom renderer that does not implement hooks at all. Pass data through props in both cases.

How do I handle Material UI in a React-to-PDF pipeline?

Wrap the tree in a CacheProvider with a fresh emotion cache, render with renderToStaticMarkup, and extract the collected styles via createEmotionServer. Inject the resulting style tags into the HTML wrapper before passing it to the browser. The MUI server-side rendering documentation has the full boilerplate.

Can I generate React PDFs in a Next.js API route?

Yes for @react-pdf/renderer: it works inside any Node runtime. The Chromium path requires installing Playwright and a Chromium binary, which is too large for typical serverless dynos. On Vercel, use a managed PDF API or move the route to a long-running container.

Wrapping up

If your component already exists in your app, render it to HTML and print with Chromium. If you want a small dependency and clean pagination, use @react-pdf/renderer. If you want the first option without the operational cost of a browser pool, render to HTML in your code and POST it to a managed renderer.

Try the same JSX-to-HTML output through our managed PDF service:

Html To PdfTry it free

Free tools mentioned:

Html To PdfTry it free

Start generating PDFs

Build PDF templates with a visual editor. Render them via API from any language in ~300ms.