Rebounder Tech Blog

Written by the people who actually run these systems in production.

Only Our Vercel API Functions Threw Cannot Find Module

Published About 3 min readBy the Rebounder engineering team — the people who operate these systems

This article may contain affiliate links. Its content is not affected by advertising.

In short

Under moduleResolution: Bundler, tsc and Vite let extensionless imports through, but vercel/node runs api/ unbundled as plain Node ESM, so only production throws Cannot find module.

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.

Frequently asked questions

Q1Why didn't the build commands catch this mismatch?

tsc --noEmit type-checks extensionless relative imports as valid under tsconfig.json's moduleResolution: Bundler, and vite build only bundles the frontend (src/) — the Vercel Functions under api/ are out of its scope. Neither step can see the extension being omitted.

Q2Did every file under api/ have the same problem?

Yes. All five files — ad.ts, cleanup.ts, host.ts, rooms.ts, sponsor.ts — had a relative import from ./_lib near the top of the file, and all five omitted the same extension.

Q3Is the fix just adding the extension?

Yes. The source files stay .ts; only the import specifier changes to the post-build extension, e.g. ./_lib.js. This is valid under moduleResolution: Bundler and NodeNext alike.

Environment verified

  • @vercel/node ^5.0.0, package.json "type": "module" (kyoshu, as of 2026-10-08)
  • tsconfig.json moduleResolution: "Bundler" (kyoshu, as of 2026-10-08)

What this article is based on

  • JSON file lines 4-4commit ae05340
  • JSON file lines 6-6commit ae05340
  • JSON file lines 1-4commit ae05340
  • TypeScript file lines 3-3commit 09f157d
  • TypeScript file lines 3-3commit 09f157d
  • TypeScript file lines 3-3commit 09f157d
  • TypeScript file lines 3-3commit 09f157d
  • TypeScript file lines 3-3commit 09f157d

Every claim in this article comes from the records above. The repositories we operate are private so we cannot link to them, but which file, which lines, and at which commit we read them is recorded for every article. Nothing here is written from guesswork.