Puppeteer PDFs on Vercel

Tested: Deployed to Vercel on 2026-11-01 with Next.js 16.4.3, puppeteer-core 25.11.0, @sparticuz/chromium 153.0.0 and Node 24. Relative font URLs and waitForFonts: false tested locally in Chrome 159. @sparticuz/chromium-min not tested.

The normal puppeteer package downloads a full Chrome, which won't fit in a Vercel Function. The standard approach is puppeteer-core plus @sparticuz/chromium, a Chromium build packaged for AWS Lambda, which Vercel Functions run on.

The setup

npm install puppeteer-core @sparticuz/chromium

Pin both to exact versions. The @sparticuz/chromium major version has to match the Chromium version your puppeteer-core release supports (see Puppeteer's supported-browsers page). For example, @sparticuz/chromium 153.0.0 pairs with puppeteer-core 25.11.0, the Puppeteer release for Chrome 153. The package doesn't follow semver, and its README warns that breaking changes can land in patch releases. That version also needs Node 22.17 or later.

// app/api/pdf/route.js
import chromium from '@sparticuz/chromium';
import puppeteer from 'puppeteer-core';

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

export async function POST(req) {
  const { html } = await req.json();
  const browser = await puppeteer.launch({
    args: await puppeteer.defaultArgs({ args: chromium.args, headless: 'shell' }),
    executablePath: process.env.VERCEL
      ? await chromium.executablePath()
      : process.env.LOCAL_CHROME_PATH, // the Lambda build won't run on your laptop
    headless: 'shell',
  });
  try {
    const page = await browser.newPage();
    await page.setContent(html, { waitUntil: 'load' });
    const pdf = await page.pdf({ format: 'A4', printBackground: true });
    return new Response(pdf, { headers: { 'Content-Type': 'application/pdf' } });
  } finally {
    await browser.close();
  }
}

Fix the missing bin folder error

This code alone builds fine, then every request returns a 500:

Error: The input directory "/var/task/node_modules/@sparticuz/chromium/bin" does not exist.

Chromium lives in the package's bin folder, and that folder doesn't reach the function by default. Tell Next.js to include it:

// next.config.mjs
export default {
  outputFileTracingIncludes: {
    '/api/pdf': ['./node_modules/@sparticuz/chromium/bin/**'],
  },
};

The key is the route's path, so add one entry for each route that launches Chromium.

The error message tells you to externalize the package, and older tutorials do that with serverExternalPackages. We deployed each option separately on Next.js 16.4.3:

next.config.mjs Result
Empty 500: bin does not exist
serverExternalPackages: ['@sparticuz/chromium', 'puppeteer-core'] 500: same error
outputFileTracingIncludes, as above PDF returned

The limits that bite

The limits below are from Vercel's docs and the @sparticuz/chromium README, checked November 2026. The notes on the body limits are what we measured.

Limit Value What it means for PDFs
Function size 250 MB uncompressed (large functions up to 5 GB, in beta) @sparticuz/chromium is about 70 MB unpacked, because Chromium stays compressed until a cold start. It fits, but watch everything else you bundle.
Memory 2 GB / 1 vCPU default. Hobby max is 2 GB, Pro max is 4 GB @sparticuz/chromium asks for at least 512 MB and recommends 1,600 MB or more. The default is fine. Don't lower it.
Request body 4.5 MB A 5 MB POST came back as 413 FUNCTION_PAYLOAD_TOO_LARGE. If you POST your HTML, embedded images and base64 fonts all count.
Response body 4.5 MB, but streaming responses are exempt A 26.5 MB PDF came back fine from this route. Vercel's docs don't say whether it counts as streaming, so don't rely on that. For large files, upload to storage (Vercel Blob, S3) and return a link, which is what Vercel recommends.
Duration 300 s default Rarely the problem for one document. It matters for batches.

Cold starts. On a cold start, @sparticuz/chromium decompresses Chromium into /tmp before it can launch. We timed a one-page PDF on a real deployment:

Request Time
Cold start 3.3 to 4.5 s
Warm, launching Chromium each time about 0.45 s
Warm, reusing the browser about 0.25 s

Instances don't stay warm for long. After 4 minutes idle ours was still warm, but after 10 minutes it had gone cold. Bursts bring cold starts back too: we sent six requests at once to a warm function, and all six worked, but three took over 4 s.

On a warm instance you can skip the launch by reusing the browser:

let browserPromise;
function getBrowser() {
  browserPromise ??= (async () => {
    const browser = await puppeteer.launch({ /* same options as above */ });
    browser.on('disconnected', () => { browserPromise = undefined; });
    return browser;
  })();
  return browserPromise;
}
// in the handler: const page = await (await getBrowser()).newPage(); ... finally { await page.close(); }

This saves about 0.2 s a request and does nothing for cold starts. It was stable in our tests: the reused browser still worked after 4 minutes idle, and after 10 and 15 minutes a new one launched without errors.

If your host has a tighter size limit, @sparticuz/chromium-min ships without the binary and downloads it from a URL you host on cold start. That keeps the bundle small, but it makes cold starts slower still.

Why your fonts change in production

This is the most common "works on my machine" problem, and it has two causes.

1. The server has no fonts. The Lambda environment ships with no font files. @sparticuz/chromium adds one family, Open Sans, which covers Latin, Greek and Cyrillic. We rendered lines in Arial, Helvetica, Inter, Segoe UI, Times New Roman, Georgia and monospace on Vercel, and every one came out in Open Sans. That includes your code blocks, which lose their fixed-width alignment.

Emoji and Chinese, Japanese or Korean text is worse: it disappears. The PDF shows blank space where the text should be, with no error.

2. Relative URLs go nowhere with setContent. HTML loaded with page.setContent() has no base URL, so url(/fonts/brand.woff2) can't resolve. We tested it: with a relative @font-face URL, the PDF fell back to Times New Roman. With an absolute URL, or a <base href="https://your-site.com/"> tag, the real font was embedded.

The fix that works everywhere: embed your fonts in the HTML as base64.

@font-face {
  font-family: "Brand";
  src: url(data:font/woff2;base64,AAAA...) format("woff2");
}
body { font-family: "Brand", sans-serif; }

There's no network fetch at render time and nothing depends on what's installed on the server.

For emoji or CJK, declare a Noto font the same way and list it after your main font:

body { font-family: "Brand", "Noto Sans JP", "Noto Color Emoji", sans-serif; }

Two things to watch:

About waiting for fonts. setContent doesn't wait for them. Even with waitUntil: 'load', it can return while fonts are still loading. What makes them appear is page.pdf(), which waits for document.fonts.ready by default through its waitForFonts option. With that option off, a font still loading at print time was missing from the PDF. So you don't need to add your own wait, and if fonts are wrong, check the two causes above first.

Don't want to run Chromium in a serverless function? We're building a small HTML-to-PDF API: send HTML, get a PDF back, with fonts, page numbers and table page breaks handled.

We'll email you when the API is ready to try. Privacy

More guides