I ship a tool that drives a real browser through a website and records a narrated demo video. Moving that from a CLI to a Next.js API route on Vercel took four separate fixes, and every one of them failed in a way that pointed somewhere else.
Writing them down in case it saves you the week it cost me.
1. process.env.VERCEL is not a runtime signal
Start here, because this is the one that will waste the most of your time.
The obvious way to choose between local Playwright and serverless chromium is to ask whether you are running on Vercel:
const isServerless = !!process.env.VERCEL;
That is wrong the moment anyone runs vercel env pull. It writes VERCEL=1 and VERCEL_ENV=production straight into .env.local, so your laptop now reads as production and takes the serverless path, against a chromium binary that is not there. Nothing about the failure points at your env file.
The variables that only exist inside the deployed function are the Lambda ones:
export type BrowserTarget = 'serverless' | 'local';
export function resolveBrowserTarget(
env: Record<string, string | undefined> = process.env,
): BrowserTarget {
if (env.QUICKPEEK_BROWSER === 'serverless') return 'serverless';
if (env.QUICKPEEK_BROWSER === 'local') return 'local';
// Set by the Lambda runtime Vercel functions execute in. Never present in a
// pulled .env file, which is exactly why it is the one usable signal.
return env.AWS_LAMBDA_FUNCTION_NAME || env.LAMBDA_TASK_ROOT
? 'serverless'
: 'local';
}
Two things worth copying. It is a pure exported function, because getting this wrong is invisible until either production or local dev quietly stops working, and you want a test sitting on it. And the manual override exists so you can force either branch while you are debugging the other one.
2. The file tracer cannot see a dynamic import or an fs read
@sparticuz/chromium ships chromium as a brotli archive that it unpacks into /tmp on first use. You load it through a dynamic import, and it then reads its own payload off disk. Next's file tracer sees neither the require nor the file, so it packs your function without the binary. The route deploys cleanly and throws at launch.
You have to name it by hand, per route, because each API route becomes its own serverless function:
outputFileTracingIncludes: {
'/api/record-video': ['./node_modules/@sparticuz/chromium/bin/**/*'],
'/api/validate-plan': ['./node_modules/@sparticuz/chromium/bin/**/*'],
},
The same trap catches anything read with fs at runtime. Fonts and images used by OG image routes are the usual other victims.
3. Native packages have to be made external twice
serverExternalPackages keeps Node native packages out of the webpack bundle. Playwright pulls in electron and ws internals that break the build when bundled, and @sparticuz/chromium is a 67MB archive webpack must not touch:
serverExternalPackages: [
'@sparticuz/chromium',
'playwright',
'playwright-core',
'sharp',
],
That handles imports resolving inside node_modules. If you also depend on a local package by file: path, its internal imports resolve outside node_modules and slip past the list entirely. You need the escape hatch as well:
webpack: (config, { isServer }) => {
if (isServer) {
config.externals.push('playwright', 'playwright-core', '@sparticuz/chromium');
}
return config;
},
4. A file: dependency can make the build never finish
This one did not error, which is why it took longest. next build sat on "Creating an optimized production build" until I killed it, first at ten minutes, then at fifty five.
The cause: bun resolves a file:.. dependency by copying the whole directory into node_modules, and the files field in package.json does not restrict that copy the way npm pack would. So the copy contained web/, which contained node_modules, which contained another copy, roughly twelve levels deep. Next had also picked the repo root as its workspace because of a stray lockfile sitting up there, and was walking that whole tree.
One line:
outputFileTracingRoot: __dirname,
If a build hangs with no output at all, suspect file tracing walking somewhere enormous before you suspect anything else.
Two smaller ones at launch time
Do not pass headless: true. The args from @sparticuz/chromium already contain --headless='shell', quotes included, and Playwright appends its own switch on top, which leaves chromium holding two conflicting flags. The vendor's own Playwright example omits it for this reason.
const [{ default: serverless }, { chromium }] = await Promise.all([
import('@sparticuz/chromium'),
import('playwright-core'),
]);
const executablePath = await serverless.executablePath();
if (!executablePath) {
throw new Error('Serverless chromium did not unpack, no executable path was returned');
}
return chromium.launch({
...options,
executablePath,
args: [...serverless.args, ...(options.args ?? [])],
});
Think twice before disabling the graphics stack to shave cold start time. Leaving it on costs about 1.6MB of extra unpack per cold start. Turning it off disables WebGL and canvas acceleration, which is the difference between recording a canvas based app and recording a blank rectangle.
What this is all for
The product is QuickPeek, which is mine, so treat that as disclosed: you give it a URL, it drives a real browser through your site, writes and speaks the narration, and cuts the result into a demo video with captions. Everything above is why that runs in a serverless function instead of on a machine I have to keep alive.
If you are putting Playwright behind a Next.js route, the ordering that actually worked was: get the target resolution honest first, then trace the binary in, then fight webpack. Doing it in any other order means debugging three problems wearing the same error message.
Point it at your own URL: quickpeek.co
Jake













