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:
- Size. Noto fonts are large, and the 4.5 MB request limit applies if you POST the HTML. The Japanese subset of Noto Sans JP is 1.1 MB as WOFF2 and worked as base64. Noto Color Emoji is 5.5 MB, which is over the limit before base64 adds a third. Load a font that size from an absolute URL instead, which also worked.
- Order. Don't name
"Open Sans"ahead of Noto. Withfont-family: "Open Sans", "Noto Sans JP", the font downloaded but the Japanese stayed blank, in all three runs."Brand", sans-serif, "Noto Sans JP"was fine.
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.