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 smallcreateRequirebanner supplies it. - importing a Bun-specific adapter evaluated a module that reads the global
Bunobject 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.
