HTML to PDF in Next.js

HTML to PDF in Next.js

Generate PDFs in Next.js from a Route Handler or Server Action, on Vercel or anywhere, without bundling Chromium. Code for the App Router.

Most "PDF in Next.js" tutorials end with Puppeteer, and that works on your laptop. On Vercel it means squeezing a Chromium build into a serverless function, longer cold starts, and a dependency you have to keep patched.

The simpler route is to build the HTML in Next.js and let an API do the rendering.

Quick answer. Add a Route Handler that builds your HTML and posts it to the API with a server-only key. The handler returns the PDF.

// app/api/invoice/[id]/route.ts
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const r = await fetch("https://html-pdf.com/v1/pdf", {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.HTML_PDF_KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify({ html: `<h1>Invoice ${id}</h1>`, format: "A4" }),
  });
  if (!r.ok) return new Response(await r.text(), { status: 502 });
  return new Response(r.body, {
    headers: { "Content-Type": "application/pdf", "Content-Disposition": `inline; filename="invoice-${id}.pdf"` },
  });
}

Options compared

This table shows the usual ways to get a PDF out of a Next.js app and where each one breaks.

Approach Works on Vercel Uses your HTML/CSS Upkeep
window.print() in the browser yes yes none, but the user clicks through a dialog
Puppeteer + @sparticuz/chromium with effort (size limits, cold starts) yes Chromium updates, memory tuning
@react-pdf/renderer yes no, its own components rewrite your layout
Route Handler + html-pdf.com yes yes none

1. Keep the key on the server

This step keeps your API key out of the browser. Put it in .env.local (and in your Vercel project settings) without the NEXT_PUBLIC_ prefix.

HTML_PDF_KEY=hp_live_...

Only code that runs on the server (Route Handlers, Server Actions, getServerSideProps) can read it. Never call the PDF API from a Client Component.

2. Build the HTML

This step turns your data into a full HTML document. A plain template function is the most predictable option, and it runs anywhere.

// lib/invoice-html.ts
type Line = { name: string; qty: number; price: number };

const esc = (s: string) => s.replace(/[&<>"]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]!);

export function invoiceHtml(inv: { number: string; customer: string; lines: Line[] }) {
  const rows = inv.lines.map(l =>
    `<tr><td>${esc(l.name)}</td><td>${l.qty}</td><td>$${(l.qty * l.price).toFixed(2)}</td></tr>`).join("");
  const total = inv.lines.reduce((s, l) => s + l.qty * l.price, 0).toFixed(2);
  return `<!doctype html><html><head><meta charset="utf-8"><style>
    @page { size: A4; margin: 16mm }
    body { font-family: system-ui, sans-serif; color: #111 }
    table { width: 100%; border-collapse: collapse }
    td { padding: 6px 0; border-bottom: 1px solid #ddd }
    tr { break-inside: avoid }
  </style></head><body>
    <h1>Invoice ${esc(inv.number)}</h1><p>Billed to ${esc(inv.customer)}</p>
    <table>${rows}</table><h2>Total $${total}</h2>
  </body></html>`;
}

Escape anything that comes from users. If you already use Tailwind, compile it and inline the CSS (see Tailwind CSS to PDF).

3. Return the PDF from a Route Handler

This handler loads the record, checks access, renders and streams the PDF back. Link to it from any page with a normal <a href>.

// app/api/invoice/[id]/route.ts
import { invoiceHtml } from "@/lib/invoice-html";
import { getInvoiceForCurrentUser } from "@/lib/data";

export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const inv = await getInvoiceForCurrentUser(id); // your auth check
  if (!inv) return new Response("Not found", { status: 404 });

  const r = await fetch("https://html-pdf.com/v1/pdf", {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.HTML_PDF_KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify({
      html: invoiceHtml(inv),
      prefer_css_page_size: true,
      footer_html: '<div style="font-size:9px;width:100%;text-align:center"><span class="pageNumber"></span> / <span class="totalPages"></span></div>',
    }),
  });
  if (!r.ok) return new Response("PDF failed", { status: 502 });

  return new Response(r.body, {
    headers: {
      "Content-Type": "application/pdf",
      "Content-Disposition": `attachment; filename="invoice-${inv.number}.pdf"`,
    },
  });
}

r.body is streamed straight through, so the file never sits in memory. Use inline instead of attachment to open it in the browser tab.

This variant is handy when you want to email the PDF or show a download button after a form submit. Ask for "response": "url" and you get a link valid for 24 hours.

// app/actions.ts
"use server";
import { invoiceHtml } from "@/lib/invoice-html";

export async function createInvoicePdf(inv: Parameters<typeof invoiceHtml>[0]) {
  const r = await fetch("https://html-pdf.com/v1/pdf", {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.HTML_PDF_KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify({ html: invoiceHtml(inv), response: "url" }),
  });
  if (!r.ok) throw new Error(await r.text());
  const { url } = await r.json();
  return url as string;
}

Store the file yourself (S3, R2, Vercel Blob) if you need it for longer than a day.

5. Render an existing page instead

This is the shortcut when the page already exists and is public, like a published report or a blog post. Send its URL and the renderer opens it like a visitor would.

body: JSON.stringify({ url: "https://your-app.com/reports/q3", media: "screen", wait_delay: 1 })

Pages behind your login won't work this way, because the renderer has no session. Build the HTML on the server as in step 3.

Why not Puppeteer on Vercel?

Puppeteer works. The cost is in the details. Chromium has to fit the function size limit, so you need a trimmed build like @sparticuz/chromium, matched to your Puppeteer version.

Cold starts add seconds, memory has to be raised, and every Chromium security release means a redeploy. If PDFs are a side feature of your app, an API call is less to own. More in Puppeteer alternative.

Pricing

You pay only for pages you render. 1 credit = 1 PDF page, and failed renders cost nothing.

You get 100 free pages, then packs from $10 for 1,000 pages down to $0.005 per page. There is no subscription, and purchased credits never expire.

Get a free API key · API reference · HTML to PDF in React · Invoice template

FAQ

How do I generate a PDF in Next.js?

Build the HTML in a Route Handler or Server Action, then POST it to https://html-pdf.com/v1/pdf with a server-only API key. Return r.body with Content-Type: application/pdf to stream the file to the user.

Can I run Puppeteer on Vercel?

Yes, with a trimmed Chromium build like @sparticuz/chromium, but you deal with function size limits, cold starts and version matching. Calling a PDF API from a Route Handler avoids shipping a browser at all.

Should I call the PDF API from a Client Component?

No. That would expose your API key. Call it from a Route Handler or Server Action, and keep the key in an environment variable without the NEXT_PUBLIC_ prefix.

How do I add page numbers to a Next.js PDF?

Pass footer_html with <span class="pageNumber"></span> and <span class="totalPages"></span>. Chrome fills them in on every page.

This page as markdown · text