GenMockupsBlogFAQ
SevenLabs

SevenLabs

sevenlabs.site

Let's build something together.

Have a project in mind? We're a small team that ships fast. Pick the channel that works best for you.

Book a 30-min call

Jump on a quick call to discuss your project, timeline, and goals.

Schedule on Calendly

Send us a message

Prefer async? Use our contact form and we'll reply within one business day.

Visit sevenlabs.site

Or email us directly at sevenlabsolutions@gmail.com

← Back to blog
Engineering

How we fixed Puppeteer + Chromium on Vercel with Next.js 16 and Turbopack

Every error, every dead end, and the exact fix that finally made headless Chromium run reliably on Vercel's serverless functions - including the Turbopack bundling trap, the missing binary, and the libnss3.so shared library problem.

S
SevenLabs
June 30, 202612 min read

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:

JavaScript
// app/api/capture/route.js
import { NextResponse } from 'next/server'

export const maxDuration = 10

async function launchBrowser() {
  const isProduction = process.env.VERCEL_ENV !== undefined

  if (isProduction) {
    const [{ default: puppeteerCore }, chromium] = await Promise.all([
      import('puppeteer-core'),
      import('@sparticuz/chromium'),
    ])
    const executablePath = await chromium.default.executablePath()
    return puppeteerCore.launch({
      args: chromium.default.args,
      defaultViewport: chromium.default.defaultViewport,
      executablePath,
      headless: chromium.default.headless,
    })
  } else {
    const { default: puppeteer } = await import('puppeteer')
    return puppeteer.launch({ headless: true })
  }
}

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:

JSON
{"error":"Failed to launch the screenshot browser. Please try again."}

No stack trace in the response. We had to look at Vercel function logs to go deeper:

Browser launch failed: Error: The input directory 
"/var/task/node_modules/@sparticuz/chromium/bin" does not exist.
If you are using a bundler (esbuild, webpack, etc.), you must externalize 
@sparticuz/chromium so it is not relocated.
See: https://github.com/Sparticuz/chromium#bundler-configuration

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.so and friends), Brotli-compressed
  • fonts.tar.br - fonts
  • swiftshader.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

JSON
{
  "functions": {
    "app/api/capture/route.js": {
      "maxDuration": 60,
      "includeFiles": "node_modules/@sparticuz/chromium/**"
    }
  }
}

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:

at f (.next/server/chunks/[root-of-the-server]__0k1faix._.js:1:1441)

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:

JavaScript
// next.config.mjs
serverExternalPackages: [
  'puppeteer-core',
  '@sparticuz/chromium',
  // ...
]

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.

JavaScript
const chromium = await import('@sparticuz/chromium-min')
const executablePath = await chromium.default.executablePath(
  'https://github.com/Sparticuz/chromium/releases/download/v131.0.1/chromium-v131.0.1-pack.tar'
)

The binary downloaded successfully - /tmp/chromium existed. But:

/tmp/chromium: error while loading shared libraries: libnss3.so: 
cannot open shared object file: No such file or directory

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:

JavaScript
// next.config.mjs
outputFileTracingIncludes: {
  '/api/capture': ['./node_modules/@sparticuz/chromium/bin/**'],
}

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:

⚠ `experimental.outputFileTracingIncludes` has been moved to 
`outputFileTracingIncludes`. Please update your next.config.mjs.

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#

JSON
{
  "dependencies": {
    "@sparticuz/chromium": "^149.0.0",
    "puppeteer-core": "^25.2.1"
  },
  "devDependencies": {
    "puppeteer": "^25.2.1"
  }
}

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#

JavaScript
const nextConfig = {
  // Keep these packages as external requires - do not bundle them
  serverExternalPackages: [
    'puppeteer-core',
    '@sparticuz/chromium',
    'sharp',
  ],

  // Force-include the Chromium binary and shared libs in the /api/capture function.
  // Vercel's file tracer only follows JS imports; it skips binary assets without this.
  outputFileTracingIncludes: {
    '/api/capture': ['./node_modules/@sparticuz/chromium/bin/**'],
  },
}

vercel.json#

JSON
{
  "functions": {
    "app/api/capture/route.js": {
      "maxDuration": 60
    }
  }
}

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#

JavaScript
import { NextResponse } from 'next/server'

export const maxDuration = 60
export const dynamic = 'force-dynamic'

function localChromePath() {
  if (process.platform === 'win32') {
    const fs = require('fs')
    const candidates = [
      'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
      'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
      `${process.env.LOCALAPPDATA}\\Google\\Chrome\\Application\\chrome.exe`,
    ]
    for (const p of candidates) {
      try { if (fs.existsSync(p)) return p } catch {}
    }
  }
  if (process.platform === 'darwin') {
    return '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
  }
  return '/usr/bin/google-chrome'
}

async function launchBrowser() {
  const { default: puppeteerCore } = await import('puppeteer-core')

  const isProduction = process.env.VERCEL_ENV !== undefined

  if (isProduction) {
    const chromium = await import('@sparticuz/chromium')

    // executablePath() decompresses chromium.br and al2023.tar.br to /tmp/
    // al2023.tar.br contains libnss3.so and other shared libs
    const executablePath = await chromium.default.executablePath()

    return puppeteerCore.launch({
      args: [
        ...chromium.default.args,
        '--disable-gpu',
        '--disable-dev-shm-usage',
        '--no-first-run',
        '--no-zygote',
        '--single-process',
        '--no-sandbox',
        '--disable-setuid-sandbox',
      ],
      executablePath,
      headless: chromium.default.headless ?? true,
      ignoreHTTPSErrors: true,
      env: {
        ...process.env,
        // /tmp is where @sparticuz/chromium extracts the .so libs
        // Pass it explicitly - Puppeteer's child_process spawn can miss
        // process.env mutations made just before launch in some contexts
        LD_LIBRARY_PATH: ['/tmp', process.env.LD_LIBRARY_PATH]
          .filter(Boolean)
          .join(':'),
      },
    })
  }

  // Local dev: use the system Chrome
  return puppeteerCore.launch({
    executablePath: localChromePath(),
    headless: true,
    args: ['--no-sandbox', '--disable-setuid-sandbox'],
  })
}

A map of every failure#

For anyone who hits one of these errors and lands here from a search:

ErrorRoot causeFix
Failed to launch the screenshot browserCatch-all for any launch crashCheck Vercel function logs for the real error
The input directory "/var/task/node_modules/@sparticuz/chromium/bin" does not existbin/ dir not deployed - file tracer skips binary assetsoutputFileTracingIncludes in next.config.mjs at the top level
Binary launches but immediately crashes - libnss3.so: No such file or directoryUsing @sparticuz/chromium-min, which downloads only the binary, not the shared libsSwitch to full @sparticuz/chromium; its executablePath() extracts the libs too
outputFileTracingIncludes config silently ignoredOption was under experimental in Next.js 15, promoted to top-level in 16Move it out of experimental
Function size limit exceededpuppeteer (full package with bundled ~300 MB Chromium) in dependenciesMove puppeteer to devDependencies; use only puppeteer-core in dependencies
Timeout on cold startsmaxDuration: 10 (default) is not enough for Chromium decompression + launchSet 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.