Guide
Export a React component to PDF
The first thing most people try is screenshotting the DOM with html2canvas and stuffing the image into jsPDF. The result is a blurry raster page with no selectable text, wrong fonts, and tables that get sliced in half. There is a cleaner path: render your component to HTML and let a real print engine draw the PDF.
Why client-side export disappoints
html2canvas paints a bitmap of what the browser already rendered, so the output is a flat image: text is not selectable or searchable, it pixelates when zoomed, and anything off-screen or lazy-loaded is missing. jsPDF on its own makes you position every line by hand. And @react-pdf/renderer is real PDF, but it forces you to rebuild the document in its own <View> and <Text> primitives. Your existing CSS, your design system, your Tailwind classes: none of it carries over.
Render the component you already have
Your component is just a function that returns markup. Render it to an HTML string with renderToStaticMarkup and you have a complete document you can hand to a render API. No rewrite, no primitives, the same JSX you ship to the screen.
// Invoice.tsx, an ordinary React component, no special primitives
export function Invoice({ order }: { order: Order }) {
return (
<main style={{ fontFamily: "Inter, sans-serif", padding: "40px" }}>
<h1>Invoice {order.id}</h1>
<table>
<tbody>
{order.lines.map((l) => (
<tr key={l.sku} style={{ breakInside: "avoid" }}>
<td>{l.name}</td>
<td>{l.total}</td>
</tr>
))}
</tbody>
</table>
<p>Total due: {order.total}</p>
</main>
);
}One route handler does the conversion
Move the heavy part off the client. A route handler renders the component to HTML, posts it to the rendering API, and streams the PDF straight back. Nothing browser-specific runs in the user's tab.
// app/api/invoice/route.tsx
import { renderToStaticMarkup } from "react-dom/server";
import { Invoice } from "@/components/Invoice";
export async function POST(req: Request) {
const { order } = await req.json();
// Render the SAME component you ship in the UI to an HTML string.
const html =
"<!doctype html>" + renderToStaticMarkup(<Invoice order={order} />);
const res = await fetch("https://api.pdfpipe.xyz/v1/pdf", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PDFPIPE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ html, options: { format: "A4" } }),
});
if (!res.ok) {
const { detail } = await res.json();
return new Response(detail, { status: 502 });
}
return new Response(res.body, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="invoice-${order.id}.pdf"`,
},
});
}The render waits for web fonts and images before drawing, so your typography matches the UI, and it honors break-inside: avoid so a long line-item table does not split mid-row across pages. The text in the PDF is real vector text: selectable, searchable, and crisp at any zoom.
Trigger it from the UI
function DownloadButton({ order }: { order: Order }) {
async function download() {
const res = await fetch("/api/invoice", {
method: "POST",
body: JSON.stringify({ order }),
});
const blob = await res.blob();
const url = URL.createObjectURL(blob);
window.open(url);
}
return <button onClick={download}>Download PDF</button>;
}Plain React without Next.js
No App Router required. Anywhere you run Node, import renderToStaticMarkup from react-dom/server, build the HTML string, and make the same POST. An Express or Fastify handler works identically. Keep your API key on the server: never call the render API directly from the browser.
Why this scales
Your app ships no Chromium and no canvas hacks, so the bundle stays small and the user's device does no rendering work. The PDF is generated on infrastructure built for it, with isolation against malicious HTML if any template data comes from your users. You get a real document instead of a screenshot, and you pay per document instead of per server you keep warm.
PDFPipe is the API used above. Flat pricing, 500 free documents a month, and a live playground you can try without signing up.
See pricing →