# 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. ```ts // 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: `

Invoice ${id}

`, 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. ```bash 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. ```ts // lib/invoice-html.ts type Line = { name: string; qty: number; price: number }; const esc = (s: string) => s.replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]!); export function invoiceHtml(inv: { number: string; customer: string; lines: Line[] }) { const rows = inv.lines.map(l => `${esc(l.name)}${l.qty}$${(l.qty * l.price).toFixed(2)}`).join(""); const total = inv.lines.reduce((s, l) => s + l.qty * l.price, 0).toFixed(2); return `

Invoice ${esc(inv.number)}

Billed to ${esc(inv.customer)}

${rows}

Total $${total}

`; } ``` Escape anything that comes from users. If you already use Tailwind, compile it and inline the CSS (see [Tailwind CSS to PDF](https://html-pdf.com/tailwind-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 ``. ```ts // 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: '
/
', }), }); 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. ## 4. Or get a link from a Server Action 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. ```ts // app/actions.ts "use server"; import { invoiceHtml } from "@/lib/invoice-html"; export async function createInvoicePdf(inv: Parameters[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. ```ts 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](https://html-pdf.com/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](https://html-pdf.com/login) · [API reference](https://html-pdf.com/docs) · [HTML to PDF in React](https://html-pdf.com/html-to-pdf-react) · [Invoice template](https://html-pdf.com/templates/invoice) ## 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 `` and ``. Chrome fills them in on every page.