You want a "Download PDF" button for an invoice, receipt or report in a Next.js app, and you'd like to design it as a React component rather than draw it with a PDF library. The approach: render the component to an HTML string in a route handler, send that HTML to a PDF renderer, and stream the PDF back. Everything below was built and run on Next.js 16.4 and React 19.3, with next build, next start and next dev.
The obvious first line, import { renderToStaticMarkup } from "react-dom/server", fails the build in the App Router:
Error: You're importing a component that imports react-dom/server. To fix it, render or return the content directly as a Server Component instead for perf and security.
Next.js checks for static imports of react-dom/server in the app directory. A dynamic import inside the handler builds and runs fine (we checked in both next build and next dev): const { renderToStaticMarkup } = await import("react-dom/server").
Plain JSX with a <style> tag, so the HTML is self-contained. Any CSS works, because a real Chromium renders it. Tailwind users: read why Tailwind layouts collapse on A4 first.
// app/components/Invoice.jsx
export default function Invoice({ number, customer, items }) {
const total = items.reduce((s, i) => s + i.qty * i.price, 0);
return (
<html>
<head>
<meta charSet="utf-8" />
<style>{`
body { font-family: system-ui, sans-serif; color: #111; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 8px; border-bottom: 1px solid #ddd; text-align: left; }
.head { background: #4f46e5; color: #fff; padding: 16px; }
`}</style>
</head>
<body>
<div className="head"><h1>Invoice {number}</h1></div>
<p>Billed to: {customer}</p>
<table>
<thead><tr><th>Item</th><th>Qty</th><th>Price</th></tr></thead>
<tbody>
{items.map(i => (
<tr key={i.name}><td>{i.name}</td><td>{i.qty}</td><td>€{i.price.toFixed(2)}</td></tr>
))}
</tbody>
</table>
<h2>Total: €{total.toFixed(2)}</h2>
</body>
</html>
);
}
Put your key in .env.local as SHOTMINT_KEY=... (no NEXT_PUBLIC_ prefix, so it never reaches the browser). The handler renders the component, posts the HTML to /v1/pdf and streams the PDF straight through, so nothing is buffered in your function.
// app/api/invoice/route.js
import Invoice from "../../components/Invoice";
export async function GET(req) {
const number = new URL(req.url).searchParams.get("number") || "2026-001";
// TODO: load the real invoice from your database and check the user may see it.
const items = [{ name: "Hosting (October)", qty: 1, price: 5 }, { name: "Support hours", qty: 3, price: 40 }];
// A static import of react-dom/server fails the Next.js build; a dynamic one works.
const { renderToStaticMarkup } = await import("react-dom/server");
const html = "<!doctype html>" + renderToStaticMarkup(
<Invoice number={number} customer="Acme Ltd" items={items} />
);
const r = await fetch("https://shotmint.creasework.com/v1/pdf", {
method: "POST",
headers: { "X-API-Key": process.env.SHOTMINT_KEY, "Content-Type": "application/json" },
body: JSON.stringify({ html, format: "A4", margin: "15mm" }),
});
if (!r.ok) return new Response(await r.text(), { status: r.status });
return new Response(r.body, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="invoice-${number}.pdf"`,
},
});
}
// app/page.js
export default function Page() {
return <a href="/api/invoice?number=2026-001" download>Download invoice (PDF)</a>;
}
Result from the live API: a 1-page A4 PDF named invoice-2026-042.pdf, with the indigo header printed (backgrounds are on by default), selectable text, and 1 credit used.
If the document already exists as a page in your app (say /reports/q3/print), skip React rendering and send its URL. The renderer fetches it like a browser, so the page must be reachable from the internet: for private data, put a short-lived signed token in the URL rather than relying on a login cookie.
// app/api/report/route.js
export async function GET() {
const r = await fetch("https://shotmint.creasework.com/v1/pdf", {
method: "POST",
headers: { "X-API-Key": process.env.SHOTMINT_KEY, "Content-Type": "application/json" },
body: JSON.stringify({
url: "https://example.com/", // a public page, e.g. https://yourapp.com/reports/q3/print
format: "Letter",
landscape: true,
margin: "0.5in",
}),
});
if (!r.ok) return new Response(await r.text(), { status: r.status });
return new Response(r.body, { headers: { "Content-Type": "application/pdf" } });
}
Tested: US Letter landscape with 0.5 in margins. format accepts A3, A4, A5, Letter, Legal and Tabloid, and margin needs a unit (15mm, 0.5in): a bare 0 falls back to 10mm.
It works on your own server, but on Vercel and other serverless hosts Chromium runs into function size, memory and cold-start limits; the serverless guide covers what goes wrong and the workarounds. Calling an API keeps your route a plain fetch. If you'd rather self-host, see HTML to PDF in Node.js for the Puppeteer version.
break-inside: avoid on table rows, break-before: page for new sections.@font-face with a data URL or an absolute https URL; relative paths don't resolve in an HTML string.Related: HTML to PDF invoices, free HTML to PDF converter, API reference.
One PDF = one credit, however many pages it has. €5 buys 2,000 renders, credits never expire and there's no subscription.
No signup, no card. Get a key in one click, then buy credits only if you need more (from €5 for 2,000, never expire). From a script, an AI agent, Make or Zapier, POST /v1/keys/free (no body) returns a key as JSON (openapi.json, no-code recipes).
All guides: guides · integrations · comparisons