·5 min read

How to generate a PDF from a React component

Render the component to a plain HTML string with ReactDOMServer.renderToStaticMarkup(), inline or embed its CSS in a <head> block (client bundler styling doesn't travel with the string), then POST that HTML to a PDF API's source: { html } endpoint. The API runs a headless Chromium instance against the string and returns a PDF — no browser automation code, no puppeteer/playwright dependency in your app.

The core idea: your component already knows how to become HTML

A React component's whole job is producing markup. renderToStaticMarkup (or renderToString if you need React's data attributes, which you don't for a PDF) turns a component tree into a plain HTML string with no client-side JS, no hydration markers, nothing React-specific left in it. That string is exactly the input a `generate_pdf` or POST /pdf HTML-to-PDF endpoint wants. You're not rendering to the DOM — you're rendering to a string, on the server, and handing that string to a service that runs its own headless Chromium against it.

This pattern is popular for invoices, reports, and certificates built with the same React components (and often the same design system) as the app's UI — see invoices, reports, and certificates for concrete layouts people reuse this way.

Step 1: render the component to an HTML string

import { renderToStaticMarkup } from 'react-dom/server';
import { InvoiceDocument } from './InvoiceDocument';

const bodyHtml = renderToStaticMarkup(
  <InvoiceDocument invoice={invoiceData} />
);

// bodyHtml is now a plain string, e.g.
// '<div class="invoice"><h1>Invoice #1042</h1>...</div>'

InvoiceDocument can be an ordinary React component — no special "print" library required. The only rule: no useEffect, no client-only state, no window references. It has to render fully synchronously on the server, because there's no browser DOM in this step to run effects against.

Step 2: wrap it in a real document with your CSS inlined

This is the step people skip and then wonder why their PDF comes back unstyled. renderToStaticMarkup only serializes the JSX tree — it does not bring your Tailwind config, CSS modules, or styled-components runtime with it. The rendering engine on the other end opens a Chromium instance against a bare HTML string with no bundler, no dev server, no <link> to your app's compiled CSS file (unless that file is reachable at a public URL). You have to hand it a complete, self-contained document.

const css = `
  body { font-family: Georgia, serif; margin: 0; padding: 40px; }
  .invoice h1 { font-size: 24px; }
  .invoice table { width: 100%; border-collapse: collapse; }
  .invoice td { padding: 8px; border-bottom: 1px solid #ddd; }
`;

const html = `<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <style>${css}</style>
  </head>
  <body>${bodyHtml}</body>
</html>`;

A few styling caveats that actually cause support tickets:

  • Tailwind: don't ship the JIT runtime. Either pre-compile the classes you use into a static stylesheet and inline it, or write plain CSS for the print document — it's a small, stable layout, not your whole app.
  • CSS-in-JS (styled-components, Emotion): extract the critical CSS on the server (most of these libraries have a renderStatic/extractCriticalToChunks API) and inline the resulting <style> tag rather than relying on the runtime <style> injection that only exists in a live DOM.
  • Images and fonts: use absolute URLs or base64 data URIs. Relative paths like /logo.png resolve against nothing in a standalone HTML string — there's no origin to relate them to.
  • Web fonts: @font-face with a public font URL works fine; a font loaded via your bundler's asset pipeline does not travel with the string.

Step 3: POST the HTML to the API

const res = await fetch('https://docweave.dev/api/v1/pdf', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer dw_live_your_key',
    'Content-Type': 'application/json',
    Accept: 'application/pdf',
  },
  body: JSON.stringify({
    source: { html },
    idempotencyKey: `invoice-${invoiceData.id}-${invoiceData.updatedAt}`,
  }),
});

if (!res.ok) {
  const err = await res.json();
  throw new Error(`PDF generation failed: ${err.error?.message ?? res.status}`);
}

const pdfBuffer = Buffer.from(await res.arrayBuffer());
await fs.writeFile(`invoice-${invoiceData.id}.pdf`, pdfBuffer);

Pass an idempotencyKey derived from something stable (record id + updated timestamp works well) so a retried request — a flaky network, a double-click — doesn't render and bill twice. See Node for the full request/response shape and error handling outside the React-specific parts.

Doing it inside a Next.js route handler

If your React component already lives in a Next.js app, the natural place to do this is a Route Handler that renders the component and calls the API in one request — the full pattern, including streaming the PDF back as the response body, is covered in generate PDF in Next.js. The short version:

// app/api/invoices/[id]/pdf/route.ts
import { renderToStaticMarkup } from 'react-dom/server';
import { InvoiceDocument } from '@/components/InvoiceDocument';

export async function GET(_req: Request, { params }: { params: { id: string } }) {
  const invoice = await getInvoice(params.id);
  const bodyHtml = renderToStaticMarkup(<InvoiceDocument invoice={invoice} />);
  const html = wrapWithPrintStyles(bodyHtml);

  const pdfRes = await fetch('https://docweave.dev/api/v1/pdf', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.DOCWEAVE_API_KEY}`,
      'Content-Type': 'application/json',
      Accept: 'application/pdf',
    },
    body: JSON.stringify({
      source: { html },
      idempotencyKey: `invoice-${invoice.id}-${invoice.updatedAt}`,
    }),
  });

  const pdf = Buffer.from(await pdfRes.arrayBuffer());
  return new Response(pdf, {
    headers: {
      'Content-Type': 'application/pdf',
      'Content-Disposition': `attachment; filename="invoice-${invoice.id}.pdf"`,
    },
  });
}

The MCP path: same idea, no HTTP client code

If an AI agent (or a script driving one) is the thing producing the invoice — pulling line items from a database, formatting them, and generating the document — the open-source MCP server does the same render, minus the fetch boilerplate:

generate_pdf({
  source: { html },
  outputPath: './invoice-1042.pdf',
});

This is worth knowing about even for a human-built app, because it means the exact same html string you constructed from your React component is portable between a REST call, an n8n/Zapier step, and an agent tool call — see PDF generation for AI agents if that's the direction you're headed.

When HTML-from-React isn't the right source

If the component is already rendered and live at a URL — say, a print-friendly /invoices/1042/print page your app serves — it's often simpler to pass source: { url } and let the renderer navigate there directly, skipping the renderToStaticMarkup step entirely. That trades one problem (styling in an isolated string) for another (the URL must be publicly reachable or reachable from wherever the render happens, and you take on the SSRF considerations that come with rendering arbitrary URLs). For static, self-contained documents like invoices and receipts, the HTML-string approach is usually less to maintain.

Testing before you wire it up

Before writing any fetch code, it's worth confirming the HTML string itself renders the way you expect:

curl https://docweave.dev/api/v1/pdf \
  -H "Authorization: Bearer dw_live_your_key" \
  -H "Content-Type: application/json" \
  -H "Accept: application/pdf" \
  -d '{"source":{"html":"<html><body><h1>Test</h1></body></html>"}}' \
  --output test.pdf

Open test.pdf and check fonts, page breaks, and margins before you drop in the real component — it's much faster to iterate on a hardcoded HTML string in curl than to redeploy an app just to see if a <style> block took effect. Full request/response reference is in the docs; pricing is per document regardless of how many pages the render produces.

FAQ

Do I need renderToString or renderToStaticMarkup?

Use renderToStaticMarkup. renderToString adds React-specific attributes (like data-reactroot) meant for client-side hydration — completely unnecessary for a document that will never run React in the browser, and it just adds noise to the HTML you're sending.

Why did my Tailwind classes not apply in the PDF?

Tailwind's JIT compiler generates CSS at build time based on your app's bundler pipeline; a raw HTML string sent to a rendering API has no bundler and no build step. Pre-compile the specific classes you use into a static stylesheet (or write plain CSS for the print layout) and inline it in the <head> of the HTML you send.

Can I use client-side React hooks in the component I'm rendering?

No — renderToStaticMarkup runs on the server with no DOM, so useEffect, browser APIs, and anything that depends on a mounted component won't run. Pass all data in as props and keep the component a pure render of that data.

Is this the same approach for other frameworks, like Vue or Svelte?

Conceptually yes — any framework with a server-side render-to-string function (Vue's renderToString, Svelte's compiled SSR output) can produce the same kind of standalone HTML string. The steps here — render to a string, inline the CSS, POST to source: { html } — aren't React-specific once you get past step 1.

Related

Generate your first PDF in minutes.

Get an API key