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.

app/invoices/[id]/pdf/route.ts
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.

app/actions.ts
"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.

200 OK
{
  "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.

LibraryWhat it isThe trade-off
puppeteer-core with @sparticuz/chromiumThe 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 serverRenders 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 hostYour 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.