← All guides

How to generate PDFs in Next.js (route handlers on Vercel)

Generating a PDF from a Next.js app sounds simple: add a route handler, launch a headless browser, return the bytes. On Vercel and other serverless platforms, the browser is the problem. This guide shows the working self-hosted approach with @sparticuz/chromium, the limits you will run into, and a simpler version that calls PDFOven with fetch.

Why you cannot just use Puppeteer on Vercel

The regular puppeteer package downloads a full Chrome build that is far larger than the 250 MB uncompressed limit for a Vercel Function bundle. It also expects system libraries that the function runtime does not provide. The Edge runtime cannot run a browser at all. The community workaround is @sparticuz/chromium, a compressed Chromium build designed for Lambda-style environments, paired with puppeteer-core.

Option 1: serverless Chromium

npm install puppeteer-core @sparticuz/chromium

The Chromium and puppeteer-core versions must match; check the @sparticuz/chromium README for the compatible pair.

Tell Next.js not to bundle the Chromium package:

// next.config.mjs
export default {
  serverExternalPackages: ["@sparticuz/chromium"],
};

Then the route handler:

// app/api/invoice/[id]/route.ts
import chromium from "@sparticuz/chromium";
import puppeteer from "puppeteer-core";
import { renderInvoiceHtml } from "@/lib/invoice";

export const runtime = "nodejs";
export const maxDuration = 60;

export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const html = await renderInvoiceHtml(id);

  const browser = await puppeteer.launch({
    args: chromium.args,
    executablePath: await chromium.executablePath(),
    headless: true,
  });

  try {
    const page = await browser.newPage();
    await page.setContent(html, { waitUntil: "networkidle0" });
    const pdf = await page.pdf({ format: "A4", printBackground: true });
    return new Response(Buffer.from(pdf), {
      headers: {
        "Content-Type": "application/pdf",
        "Content-Disposition": 'inline; filename="invoice-' + id + '.pdf"',
      },
    });
  } finally {
    await browser.close();
  }
}

This works, but expect the following:

  • Cold starts. On a fresh instance Chromium is decompressed to /tmp and launched before any rendering happens. The first request after a quiet period is noticeably slow.
  • Memory. Chromium needs a lot of it. Under-provisioned functions crash with unhelpful errors, so you will likely raise the function's memory, which raises cost per invocation.
  • Local development. The Sparticuz binary targets Linux. On macOS or Windows you typically branch on environment and use full puppeteer locally, which means two code paths to keep working.
  • Fonts. The trimmed Chromium ships with minimal fonts. Non-Latin text and emoji need fonts loaded explicitly, for example via chromium.font(url) or web fonts in your HTML.
  • Version drift. Chromium, puppeteer-core, Node and the Sparticuz package need to stay in sync through upgrades.

Rendering React components to HTML

Whichever renderer you use, you need an HTML string. In a route handler you can render a server component tree with react-dom/server:

import { renderToStaticMarkup } from "react-dom/server";
import { InvoiceDocument } from "@/components/invoice-document";

const html = "<!doctype html>" + renderToStaticMarkup(<InvoiceDocument invoice={invoice} />);

Inline your CSS (or pass it separately, see below), because the renderer cannot resolve your app's relative stylesheet URLs. Tailwind users can compile a small stylesheet for the PDF layout and send it as a string.

Option 2: PDFOven with fetch

With PDFOven the route handler is just a proxy. No Chromium in your bundle, no cold-start unpacking, and it even works on the Edge runtime:

// app/api/invoice/[id]/route.ts
import { renderInvoiceHtml, invoiceCss } from "@/lib/invoice";

export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const html = await renderInvoiceHtml(id);

  const res = await fetch("https://pdfoven.com/v1/pdf", {
    method: "POST",
    headers: {
      Authorization: "Bearer " + process.env.PDFOVEN_KEY,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      html,
      css: invoiceCss,
      options: { format: "A4", margin: "0.5in", print_background: true },
    }),
  });

  if (!res.ok) {
    const { error } = await res.json();
    return Response.json({ error }, { status: res.status });
  }

  return new Response(res.body, {
    headers: {
      "Content-Type": "application/pdf",
      "Content-Disposition": 'inline; filename="invoice-' + id + '.pdf"',
    },
  });
}

Passing res.body through streams the PDF to the browser without buffering it in your function.

Rendering a page of your own app

If the document already exists as a page in your app (for example /invoices/123/print), you can send url instead of html. The page must be reachable from the public internet, so protect it with a signed, short-lived token in the query string rather than a session cookie. Combine it with wait_for_selector if the page loads data on the client.

Templates and data

For documents that change often, store a Handlebars template in PDFOven and send just the data from your handler:

body: JSON.stringify({ template_id: "invoice", data: invoice }),

Images for Open Graph cards

The same input can be rendered to an image with POST /v1/image, using options.width, options.height and options.device_scale_factor. PNG is the default; add ?type=jpeg with options.quality for smaller files.

Keep the key on the server

Only call PDFOven from route handlers, server actions or server components. Never expose PDFOVEN_KEY with a NEXT_PUBLIC_ prefix. Set it in your Vercel project's environment variables.

Cost and limits

PDFOven's free plan includes 100 renders per month. Paid plans are $12, $39 and $99 for 2,000, 15,000 and 60,000 renders. When you run out, the API returns 429 with code quota_exceeded, which your handler can pass through or turn into a friendly message. Rendered files are not stored, so if users need to download the same PDF again, save it to your own storage (Vercel Blob or S3) after the first render.

Skip the browser plumbing

PDFOven runs Chromium for you. Send HTML, Markdown, a URL or JSON to a template and get a PDF back. 100 free renders every month.

Get a free API key