HTML to PDF in Next.js (App Router)

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 catch: react-dom/server in a route handler

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").

1. The document as a React component

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>
  );
}

2. The route handler: React → HTML → PDF

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"`,
    },
  });
}

3. The download link

// 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.

Alternative: turn an existing page into a PDF

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.

Why not Puppeteer inside Next.js?

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.

Checklist

Related: HTML to PDF invoices, free HTML to PDF converter, API reference.

What it costs

One PDF = one credit, however many pages it has. €5 buys 2,000 renders, credits never expire and there's no subscription.

Try it with 25 free renders a month

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).

Get a free API key Full API reference

More guides: HTML to PDF and HTML to image

All guides: guides · integrations · comparisons