Conclusion
Local type-checking and the build don't know that api/ runs unbundled in production. tsconfig.json's moduleResolution: "Bundler" type-checks an extensionless relative import (from "./_lib") as valid, and vite build only bundles the frontend (src/). Both pass — yet the api/ functions, which @vercel/node deploys and runs individually, crashed in production with Cannot find module.
The fix: add the post-build .js extension to every relative import under api/.
Symptom
During an end-to-end check of Kyoshu (a free sponsor-funded polling/Q&A board, its first shipped version) in production, all five API Functions were down: /api/ad (counts sponsor-card views/clicks), /api/cleanup (closes a room), /api/host (host actions), /api/rooms (joining a room), and /api/sponsor (sponsor display). The same code worked fine under local vite dev.
Cause
package.json has "type": "module", so Node.js interprets the code as ESM at runtime.
tsconfig.json sets moduleResolution to "Bundler":
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
...
}
}
This mode relaxes type-checking on the assumption that some bundler will resolve the import in the end, so an extensionless relative import (from "./_lib") type-checks as fine. For the frontend (src/), that assumption holds — Vite really does bundle everything at build time.
But api/ is deployed differently. Per vercel.json, @vercel/node deploys each file under api/ as its own serverless function:
{
"regions": ["hnd1"],
"crons": [{ "path": "/api/cleanup", "schedule": "0 18 * * *" }],
...
}
This path skips Vite's bundling. The TypeScript is transpiled close to 1:1, and the plain Node.js ESM loader resolves the relative import at runtime — and that loader does not allow omitting the extension. Line 3 of ad.ts read:
// before
import { db, isRoomId, today } from "./_lib";
Neither tsc --noEmit nor vite build can catch this line. The former judges it type-valid under moduleResolution: Bundler; the latter never looks at api/ in the first place. The build stays green every time — the failure only surfaces when /api/* is actually hit in production.
Fix
All five files with a relative import from ./_lib — ad.ts, cleanup.ts, host.ts, rooms.ts, sponsor.ts — got the .js extension added to the import:
// after (api/ad.ts)
import { db, isRoomId, today } from "./_lib.js";
The source file stays .ts. TypeScript allows writing the post-build extension (.js) in a relative import under both moduleResolution: Bundler and NodeNext. Since what actually runs in production is the compiled .js, that's the only extension the Node.js ESM loader can resolve _lib with.
Why it went unnoticed
moduleResolution: "Bundler" is written for the frontend build and local development (vite dev) — both of which really do get bundled by Vite, so the extension never matters there.
But api/ is deployed separately, one function at a time, by @vercel/node, bypassing Vite's bundling entirely. tsconfig.json's include does list api, but moduleResolution's own behavior has no concept of "api/ alone runs on a different runtime." Both type-checking and the build stayed green; the mismatch only showed up as a production runtime error.
Originally published at tech.rebounder.jp.
Top comments (0)