HTML to PDF
in Next.js.
Running Puppeteer on Vercel means a special Chromium build, a bundle close to the size limit, and cold starts you can feel. A route handler that posts HTML somewhere else is a few lines and deploys anywhere.
The call
Post your markup with an API key. The PDF comes back in the same response, base64 encoded by default. Nothing is written to disk on our side.
import { renderToStaticMarkup } from "react-dom/server"
import { InvoiceSheet } from "@/components/invoice-sheet"
import { getInvoice } from "@/lib/invoices"
export async function GET(_: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const invoice = await getInvoice(id)
const html = renderToStaticMarkup(<InvoiceSheet invoice={invoice} />)
const res = await fetch("https://api.html4pdf.com/generate-pdf", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.HTML4PDF_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ html, responseFormat: "binary" }),
})
if (!res.ok) return new Response("Could not render", { status: 502 })
return new Response(res.body, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `inline; filename="${invoice.number}.pdf"`,
},
})
}As a server action
If the PDF is produced by a form submission, a server action keeps the key on the server and returns base64 you can hand to the client.
"use server"
export async function generateInvoicePdf(id: string) {
const res = await fetch("https://api.html4pdf.com/generate-pdf", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.HTML4PDF_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ html: await invoiceHtml(id) }),
cache: "no-store",
})
const { pdf } = await res.json()
return pdf // base64, hand it to a Blob on the client
}What comes back
The default response is JSON with the document base64 encoded, the credits you have left, and how long the render took. Send responseFormat as binary instead and the body is the PDF itself, with a Content-Type of application/pdf.
{
"success": true,
"pdf": "JVBERi0xLjQKJeLjz9MKMyAwIG9...",
"creditsRemaining": 9,
"metadata": { "size": 42813, "processingTime": 2841 }
}A 402 means you are out of credits, a 401 means the key is wrong, and a 422 means the markup could not be rendered. Everything else is worth retrying once.
The usual Next.js libraries
Worth knowing before you add a dependency, because for a lot of documents one of these is the right answer and costs nothing.
| Library | What it is | The trade-off |
|---|---|---|
| puppeteer-core with @sparticuz/chromium | The usual way to run headless Chrome in a serverless function. | Free, and it works. You are managing a stripped Chromium build, watching the 50MB function limit, and paying several seconds of cold start on the first request. |
| @react-pdf/renderer on the server | Renders PDF primitives from React components inside your route. | No external call and no browser. You maintain a separate document tree rather than reusing HTML. |
| A separate rendering service you host | Your own container running Chrome, called from Next.js. | Full control and no per-document cost, in exchange for a service to keep alive, patched and scaled. |
Next.js: common questions
How do I generate a PDF in Next.js?
Build the HTML on the server, either from a React component with renderToStaticMarkup or from a template, post it to a rendering API inside a route handler, and return the response body with a PDF content type.
Can I run Puppeteer on Vercel?
Yes, with puppeteer-core and a serverless Chromium build, but you are close to the function size limit and cold starts are slow. An HTTP call has neither problem.
Should the fetch happen in a client component?
No. Keep it in a route handler or a server action so HTML4PDF_KEY stays in server-only environment variables.
Does it work on the edge runtime?
Yes. It is a plain fetch with no Node APIs, as long as you build the HTML without Node-only modules.
How do I get Tailwind styles into the PDF?
The renderer loads a fresh page, so your bundled CSS is not there. Inline a compiled stylesheet in a style tag, or link one with an absolute URL.