Running headless Chromium on Vercel sounds like it should be straightforward. There is a well-known package - @sparticuz/chromium - built specifically for AWS Lambda and Vercel. There are guides. There are GitHub issues with accepted answers. And yet, when you stack Next.js 16, Turbopack, and Vercel's serverless runtime together, you hit a sequence of failures that none of those guides quite explain.
This post is the full story of how we debugged and fixed Puppeteer on Vercel for GenMockups - a URL-to-mockup generator built on Next.js 16 App Router. Every error, every dead-end attempt, and the exact configuration that finally works.
The setup#
What we were building: an API route (/api/capture) that accepts a URL, launches a headless Chromium browser, navigates to the page, and returns a JPEG screenshot. Standard Puppeteer stuff.
The stack:
- Next.js 16.2.9 with App Router
- Turbopack (enabled by default in Next.js 15+)
puppeteer-core@25.2.1@sparticuz/chromium@149.0.0- Deployed to Vercel Hobby plan
The initial route looked like this:
And we also had puppeteer (the full package, not just puppeteer-core) in dependencies.
Error 1: "Failed to launch the screenshot browser"#
The first error in production was vague:
No stack trace in the response. We had to look at Vercel function logs to go deeper:
What this actually means#
/var/task is the Lambda root on Vercel. The package IS in node_modules/@sparticuz/chromium (that part resolves correctly), but the bin/ subdirectory - which contains the Chromium binary and shared libraries - does not exist in the deployed function.
Vercel's file tracer (@vercel/nft) builds the function bundle by following JavaScript imports. It traces require() and import() calls, resolves them, and includes the relevant JS files. But it does not include non-JS assets - binary executables, .br files, .so shared libraries - unless explicitly told to.
The bin/ directory in @sparticuz/chromium@149 contains:
chromium.br- the Chromium binary, Brotli-compressed (~65 MB)al2023.tar.br- Amazon Linux 2023 shared libraries (libnss3.soand friends), Brotli-compressedfonts.tar.br- fontsswiftshader.tar.br- software WebGL renderer
None of these are JS files. The tracer ignores them entirely. The package arrives in the Lambda without its own binary.
What we tried first (and why it didn't work)#
Attempt: includeFiles in vercel.json
This had no effect. includeFiles in vercel.json is designed for Vercel's older serverless function format (Pages Router, standalone functions). For Next.js App Router builds processed by the Next.js build pipeline, the relevant config lives in next.config.mjs, not vercel.json.
Attempt: Moving puppeteer to devDependencies
We also had puppeteer (the full package, ~300 MB with a bundled Chromium) in regular dependencies. This was a problem independent of the binary issue: Vercel's tracer follows the dynamic import('puppeteer') in the else branch of launchBrowser, traces the package, and includes its bundled Chromium binary in the function - which blows past Vercel's 250 MB compressed function size limit.
Fixing this: remove the import('puppeteer') path entirely, move puppeteer to devDependencies or remove it, and use only puppeteer-core for both local dev and production (using the system Chrome for local dev).
This fix was correct and necessary, but it didn't solve the missing bin/ directory.
Error 2: Turbopack bundles the package despite serverExternalPackages#
When we removed the puppeteer import and looked at the error more carefully, we noticed something in the stack trace:
That chunk path is Turbopack's output format. The @sparticuz/chromium code was being bundled into a Turbopack chunk - despite having it listed in serverExternalPackages:
Why serverExternalPackages sometimes isn't enough#
serverExternalPackages tells the bundler to keep the package as an external require() rather than inlining it. When it works, the output contains require('@sparticuz/chromium') and Node.js loads the package from node_modules at runtime - with the correct __dirname, meaning path.join(__dirname, 'bin') resolves to the right place.
In our case with Turbopack in Next.js 16, something in the dynamic import chain caused the package code to end up inlined in the chunk anyway. When inlined, __dirname points to the chunk's directory instead of the package directory, and the bin/ lookup fails even if the bin/ directory were somehow present.
This led us to try a sidetrack.
Sidetrack: @sparticuz/chromium-min#
Searching for a workaround, we found @sparticuz/chromium-min - a version of the package with no bundled binary. Instead, you call executablePath(url) and it downloads the binary from a GitHub Releases URL to /tmp at runtime.
Since there is no binary to bundle, the Turbopack issue becomes irrelevant: bundle the JS all you want, nothing breaks.
The binary downloaded successfully - /tmp/chromium existed. But:
Why chromium-min fails with missing shared libraries#
The GitHub release pack (.tar) that @sparticuz/chromium-min downloads contains only the Chromium binary - not the shared libraries (libnss3.so, libnssutil3.so, etc.) that Chromium needs at runtime.
The full @sparticuz/chromium npm package bundles those libraries separately as al2023.tar.br. When executablePath() runs from the full package, it decompresses both the binary and the shared libs to /tmp/ and sets LDLIBRARYPATH=/tmp. The -min runtime download skips all of this.
Vercel's Lambda OS (Amazon Linux 2023) does not ship libnss3 in its default runtime environment. So the binary has no way to find it.
Adding LDLIBRARYPATH: '/tmp' to Puppeteer's env launch option did nothing, because there was nothing in /tmp to find - the .so files were never there to begin with.
@sparticuz/chromium-min is a dead end for this specific Vercel setup unless you are willing to separately download and extract the shared libraries yourself.
The actual fix: outputFileTracingIncludes#
Back to the full @sparticuz/chromium package. The problem was never that serverExternalPackages was broken - it was that the bin/ directory was not being deployed. The fix is to tell Next.js explicitly to include it.
Next.js has a top-level config option specifically for this:
This tells Next.js's output file tracing step to include all files matching the glob for the /api/capture function - including binary assets, .br files, everything - regardless of whether the tracer followed a JS import to them.
Important: this must be at the top level of nextConfig, not under experimental.
In Next.js 15 this option lived under experimental.outputFileTracingIncludes. It was promoted to a stable, top-level option in Next.js 15/16. Putting it under experimental in Next.js 16 produces a build warning and the option is silently ignored:
We made this mistake and deployed a build where the fix looked applied but wasn't. After moving it to the top level and redeploying, the bin/ directory was present in the Lambda, executablePath() could find and decompress the files, and Chromium launched successfully.
The complete working configuration#
Here is every file that needed to change, in full.
package.json#
puppeteer (full package with bundled Chromium) stays in devDependencies only. It is used for local development. It must never appear in dependencies - it will bloat the Vercel function past the 250 MB limit.
next.config.mjs#
vercel.json#
maxDuration: 60 is important. The default is 10 seconds on Hobby plan for most functions, but with Vercel's newer "Active CPU billing" model, the limit is higher. Even so, Chromium decompression + browser launch + page navigation can easily take 8β15 seconds on a cold start. Give it room.
app/api/capture/route.js#
A map of every failure#
For anyone who hits one of these errors and lands here from a search:
| Error | Root cause | Fix |
|---|---|---|
Failed to launch the screenshot browser | Catch-all for any launch crash | Check Vercel function logs for the real error |
The input directory "/var/task/node_modules/@sparticuz/chromium/bin" does not exist | bin/ dir not deployed - file tracer skips binary assets | outputFileTracingIncludes in next.config.mjs at the top level |
Binary launches but immediately crashes - libnss3.so: No such file or directory | Using @sparticuz/chromium-min, which downloads only the binary, not the shared libs | Switch to full @sparticuz/chromium; its executablePath() extracts the libs too |
outputFileTracingIncludes config silently ignored | Option was under experimental in Next.js 15, promoted to top-level in 16 | Move it out of experimental |
| Function size limit exceeded | puppeteer (full package with bundled ~300 MB Chromium) in dependencies | Move puppeteer to devDependencies; use only puppeteer-core in dependencies |
| Timeout on cold starts | maxDuration: 10 (default) is not enough for Chromium decompression + launch | Set maxDuration: 60 in both vercel.json and the route file |
Why this is so hard to Google#
The @sparticuz/chromium README covers the Lambda use case well. The Next.js docs cover outputFileTracingIncludes. The Turbopack/serverExternalPackages interaction is documented somewhere. But none of them tell you what happens when all three combine - and the failures cascade in ways that look like completely separate problems.
The key insight, if you take nothing else from this post: Vercel's file tracer deploys your function's dependencies by following JS imports. Binary assets - executables, compressed archives, .so files - are invisible to it. outputFileTracingIncludes is how you make them visible.
Everything else - the serverExternalPackages, LDLIBRARYPATH, the Turbopack bundling behavior - is noise around that central fact.
Built at SevenLabs. GenMockups is free to use at mockups.sevenlabs.site.
