Five Failures Between a Bun App and a Working Vercel Function

Five Failures Between a Bun App and a Working Vercel Function

Published July 27, 2026 · 4 min read

Five runtime boundaries that can break a Bun app on Vercel even after local type checks, tests and builds pass—and how to diagnose each one.

This guide distils five failure modes from moving a Bun web application to a Vercel Function. The examples are intentionally generic: the useful part is the runtime boundary, not the private product that exposed it.

Locally, the typecheck passed, the verified test groups were green, and the production client built cleanly. The first deployment still failed before it served a useful request because Bun and Vercel's Node runtime treat several boundaries differently.

1. Node loaded the function as CommonJS

The first error was:

Cannot use import statement outside a module

The emitted function used ESM, but package.json did not declare "type": "module". Bun had always understood the source; Node did exactly what the package metadata told it to do and loaded the function as CommonJS.

This was the smallest fix and the first reminder that “works in Bun” says nothing about how another runtime will classify the same output.

2. Per-file transpilation was not enough

Once Node treated the function as ESM, every extensionless relative import failed with ERR_MODULE_NOT_FOUND.

Vercel's Node builder transpiled the files individually. Raw Node ESM then looked for explicit .js extensions that the Bun/Vite codebase did not use. Pre-bundling the API with esbuild removed that resolution boundary, but bundling surfaced three more:

  • a database client selected a native binding that could not be packaged for the function. The production bundle used its pure HTTP driver instead.
  • bundled CommonJS dependencies expected require, which does not exist in ESM scope. A small createRequire banner supplies it.
  • importing a Bun-specific adapter evaluated a module that reads the global Bun object at import time. That import must be lazy and guarded by the runtime.

That last one is especially easy to miss. The failing helper was not called on the request path. Merely importing its module was enough to crash Node.

3. import.meta.dir did not exist

Bun provides import.meta.dir; Node does not. The missing value turned the migrations path into:

undefined/../../../drizzle

The replacement derives a directory from import.meta.url and resolves runtime files from the project root. The distinction matters after bundling because the bundle does not live beside the original server modules.

Vercel also needed an explicit includeFiles: "drizzle/**" entry. Its file tracing follows imports, while the migration runner discovers SQL files with filesystem reads. Without the include rule, the function deployed without the files it needed to initialize the database.

4. The function returned a Response that nobody used

The next deployment did not crash. Every request simply hung until the five-minute function timeout.

The entry point exported a default handler that accepted a Web Request and returned a Web Response. Vercel interpreted a default export as the Node (req, res) => void form instead. The application received an IncomingMessage, failed when it tried to call headers.get, and the returned Response was discarded.

The working entry is deliberately tiny:

export function fetch(request: Request): Response | Promise<Response> { return app.fetch(request); }

Exporting fetch selects the Web-standard contract the application actually implements.

5. The deployed SPA could read but not write

With the function finally answering, every browser POST returned 403.

The application's origin allowlist was seeded for localhost. Adding each preview deployment URL would have treated the symptom and created permanent configuration churn. The correct rule was already implied by the threat model: the guard exists to stop cross-origin credentialed mutations.

Requests whose Origin host matches their own Host header now pass automatically. Explicit allowlisting still applies to genuinely cross-origin requests.

The test suite was not wrong

None of these failures invalidate the local checks. The tests exercised application behavior; the failures lived at package classification, module resolution, import-time side effects, filesystem tracing, function signatures, and deployment-origin policy.

The useful lesson is narrower: a runtime migration needs a runtime smoke test. A typecheck cannot prove that a serverless platform will choose the export contract you intended, trace files loaded through fs, or survive a dependency that touches a runtime global during module evaluation.

Once package classification, bundling, traced files, the request contract, and origin policy agree, the final adapter is tiny. These failures are worth documenting because the small working result does not explain why each line needs to be there.