Module Not Found in Vite but Fine in Webpack
Same code, same package.json, different bundler, different result. The difference is resolution conditions and how strict each tool is.
The short answer
[vite]: Rollup failed to resolve import "some-lib/utils" from "src/app.ts"
Vite and Webpack resolve modules differently in four ways:
- Vite respects the
exportsmap strictly. Webpack 4 ignores it, Webpack 5 is more permissive about fallbacks. - Vite prefers the
browserandmoduleconditions. Webpack's default order differs. - Vite does not polyfill Node builtins. Webpack 4 did automatically.
- Vite requires explicit extensions for some paths where Webpack guesses.
Diagnose with:
node -e "console.log(require.resolve('some-lib/utils'))"
cat node_modules/some-lib/package.json | jq '{main, module, browser, exports}'
Tested on Vite 6.0 and Webpack 5.97.
The exports map is the usual answer
Modern packages declare their public API:
{
"name": "some-lib",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"./styles.css": "./dist/styles.css"
}
}
This is an allowlist. Anything not listed is unreachable, even if the file exists on disk.
So import { helper } from "some-lib/utils" fails, because ./utils is not in the map. Webpack 4 ignored exports entirely and resolved the file path directly, which is why the same import worked before.
The package author made a deliberate decision about their public surface. The correct fix is to use the public entry:
import { helper } from "some-lib";
If the export genuinely should be public, open an issue. If you need it now, alias it:
// vite.config.js
export default {
resolve: {
alias: {
"some-lib/utils": path.resolve(__dirname, "node_modules/some-lib/dist/utils.js"),
},
},
};
That works and it reaches past a boundary the author drew, so it will break on their next release. Leave a comment saying why.
Conditions and the wrong build
The exports map can have several conditions and the bundler picks by priority:
{
"exports": {
".": {
"browser": "./dist/browser.js",
"node": "./dist/node.js",
"default": "./dist/index.js"
}
}
}
Vite in client mode resolves browser. In SSR mode it resolves node. Webpack's target setting controls the same thing.
The symptom when this goes wrong is not a resolution error, it is a runtime error about a missing API, because you got the Node build in the browser or the reverse. process is not defined in the browser is the classic.
Force it if you need to:
// vite.config.js
export default {
resolve: {
conditions: ["browser", "import", "default"],
},
ssr: {
resolve: {
conditions: ["node", "import", "default"],
},
},
};
Order matters. First match wins.
Node builtins are not polyfilled
Webpack 4 automatically shimmed buffer, crypto, stream, path, and others for the browser. Webpack 5 stopped, and Vite never did.
Module "buffer" has been externalized for browser compatibility.
Cannot access "buffer.Buffer" in client code.
Three options, in order of preference.
Do not use the package in browser code. Most of the time a dependency pulling in crypto in a browser bundle is a sign you are shipping server code to the client. Check why it is there.
Use a web equivalent. crypto.randomUUID() exists natively. TextEncoder replaces most Buffer usage. fetch replaces http.
Polyfill explicitly if you genuinely need it:
import { nodePolyfills } from "vite-plugin-node-polyfills";
export default {
plugins: [nodePolyfills({ include: ["buffer", "process"] })],
};
Include only what you need. Polyfilling everything adds a lot of bytes for something that is usually a mistake upstream.
CommonJS dependencies
Vite pre-bundles dependencies with esbuild and serves your source as native ES modules. A CommonJS dependency has to be converted, and the conversion is not always clean.
SyntaxError: The requested module '/node_modules/.vite/deps/some-lib.js'
does not provide an export named 'default'
Usually a package using conditional exports or dynamic module.exports assignment that static analysis cannot follow.
// vite.config.js
export default {
optimizeDeps: {
include: ["some-lib"], // force pre-bundling
},
build: {
commonjsOptions: {
transformMixedEsModules: true, // handle files mixing require and import
},
},
};
optimizeDeps.include is the one to reach for when a dependency works in production build and fails in dev, or the reverse. Vite pre-bundles some things and not others, and forcing it either way frequently resolves the inconsistency.
Related to the whole ESM and CommonJS boundary problem in Node, which is the same underlying tension appearing in a different tool.
Path aliases have to be declared twice
TypeScript path mapping does not affect the bundler.
// tsconfig.json
{ "compilerOptions": { "paths": { "@/*": ["./src/*"] } } }
That satisfies the type checker. Vite still needs its own:
// vite.config.js
export default {
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},
};
Symptom: types resolve in your editor, build fails. Two sources of truth is the problem, and vite-tsconfig-paths removes it by reading the tsconfig:
import tsconfigPaths from "vite-tsconfig-paths";
export default { plugins: [tsconfigPaths()] };
Case sensitivity
Works on macOS, fails in CI on Linux.
import Button from "./components/button"; // file is Button.tsx
macOS and Windows have case insensitive filesystems by default. Linux does not. This is a whole category of works locally, fails in CI that has nothing to do with the bundler and shows up as a resolution error.
Catch it with eslint-plugin-import and its no-unresolved rule, with caseSensitive: true.
Debugging resolution
Ask Node what it resolves:
node -e "console.log(require.resolve('some-lib/utils'))"
node --experimental-import-meta-resolve -e "import.meta.resolve('some-lib/utils')"
If Node cannot resolve it either, the exports map is the answer and Webpack was being lenient.
Read the package manifest:
cat node_modules/some-lib/package.json | jq '{main, module, browser, exports, type}'
Turn on Vite's debug output:
DEBUG=vite:resolve vite build
Verbose, and it shows every resolution attempt and which condition matched. When the failure is subtle this is the fastest path to the answer.
Check what actually got bundled:
vite build --mode production
npx vite-bundle-visualizer
Sometimes the resolution succeeded and picked the wrong file, which is worse than failing, and the only way to see it is to look at the output.
The underlying reason
Webpack grew up before exports maps existed and accumulated a lot of leniency for compatibility. Vite started after and implements the current specification more strictly.
So a Vite failure is frequently Vite being correct about something Webpack let you get away with. The instinct is to configure around it, and it is worth first checking whether the import was always reaching into a package's internals.
That framing has saved me time: when a stricter tool rejects something a looser tool accepted, assume the stricter tool is right until proven otherwise.