When you run npm run build or deploy your Next.js app, you might encounter this error: "Module not found: Can't resolve". Unlike the Node.js runtime error, this is a build-time issue—your bundler (webpack, Vite, Next.js, etc.) can't find a module during compilation, before your app ever runs. The bundler halts the entire build, and your deployment stalls. Understanding how bundlers resolve modules, and why they fail, is the fastest way to unblock yourself and keep your error tracking dashboard clear.
Bundlers don't use Node's module resolution directly. They have their own rules: they walk file paths differently, respect configuration aliases, honor package.json exports maps, and refuse to polyfill Node built-ins in browser bundles by default. A module that resolves fine in Node.js might not resolve in the bundler—and vice versa. Knowing which rule applies to your error saves hours of debugging.
How Bundlers Resolve Modules
Bundlers follow a multi-step process to find a module. When you write import { Component } from '@mui/material', the bundler performs these checks in order:
- Bare specifier resolution: Look for
@mui/materialinnode_modules. Then check the package'spackage.jsonexportsmap (if present), then fall back tomainormodulefields. - Relative path resolution: For
import { helper } from './utils/helpers', resolve the path literally (relative to the current file). Check for.js,.ts,.tsx,.jsxdepending onresolve.extensions. - Alias resolution: Check for configured aliases (e.g.,
@→src/). These live intsconfig.jsonpaths, but the bundler doesn't read tsconfig directly—you must mirror the alias in the bundler config. - Fallback checks: If none of the above work, check
node_modulesparent directories (monorepo hoisting), then fail with "Can't resolve."
Each bundler implements this slightly differently, but the core logic is the same. Misconfiguration or typos at any step can break the build.
Typo or Wrong Relative Path (Case Sensitivity)
This is one of the most common causes. Your code works on macOS (case-insensitive filesystem) but fails in CI, Docker, or Linux (case-sensitive). You write import { helper } from './Utils/helpers' locally, it works, but the actual folder is ./utils/helpers (lowercase).
Fix: Always use lowercase folder names and double-check capitalization in imports.
// ❌ Wrong: folder is ./utils, not ./Utils
import { helper } from './Utils/helpers';
// ✓ Correct
import { helper } from './utils/helpers';
This is especially dangerous because it works locally on macOS or Windows but breaks silently in CI/Linux environments or Docker. Use a linter rule (eslint-plugin-import with case-sensitivity checks) to catch these before they ship.
Package Not Installed or Lockfile Out of Sync
The bundler can't find the module because it's not in node_modules. This happens if:
- You forgot to run
npm installafter cloning or switching branches. - Your
package.jsonlists the dependency, butnpm installhasn't run. - In a monorepo, a nested package wasn't hoisted or installed properly.
Fix: Run a clean install:
rm -rf node_modules package-lock.json
npm install
Or for CI/CD (faster and deterministic):
npm ci
If you're in a monorepo (npm Workspaces, Yarn, Lerna), ensure the dependency is listed at the root package.json or in the specific workspace. Bundlers can't resolve transitive dependencies that weren't installed:
{
"workspaces": [
"packages/*"
],
"dependencies": {
"shared-lib": "workspace:*"
}
}
Path Alias in tsconfig.json but Not in the Bundler
You configure a path alias in tsconfig.json:
{
"compilerOptions": {
"paths": {
"@/*": ["src/*"]
}
}
}
Then you import using the alias: import { Button } from '@/components/Button'. TypeScript is happy, but the bundler fails: "Can't resolve '@/components/Button'".
Why: tsconfig.json teaches TypeScript, not the bundler. Webpack, Vite, and Next.js each read bundler config, not TypeScript config.
Fix: Mirror the alias in bundler config.
For webpack (learn more in our webpack source maps guide):
module.exports = {
resolve: {
alias: {
'@': path.resolve(__dirname, 'src/'),
},
},
};
For Vite (see also Vite source maps):
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import path from 'path';
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});
For Next.js (covered in depth in our Next.js error tracking guide), next.config.js reads tsconfig.json automatically, but it's worth verifying that your alias is in the right place and that you're using version 13+:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}
Use a plugin like vite-tsconfig-paths to automatically sync Vite with tsconfig.json paths, eliminating the manual mirror.
Node Built-ins in Browser or Edge Bundles
You import a Node.js core module in a browser or edge-runtime bundle: import fs from 'fs'. The bundler fails: "Can't resolve 'fs'".
Why: Webpack 5 (and Vite) removed automatic polyfilling of Node core modules. They assume you won't use fs, crypto, worker_threads, or path in the browser. If you try, the bundler refuses, because there's no sensible polyfill.
Fix: Move Node code to server-only files or routes.
For Next.js App Router, mark server-only functions with 'use server':
// lib/fileOps.ts
'use server';
import fs from 'fs';
export async function readConfig() {
return fs.readFileSync('/etc/config.json', 'utf-8');
}
Then call it from a client component:
'use client';
import { readConfig } from '@/lib/fileOps';
export default function Page() {
const config = await readConfig();
return <div>{config}</div>;
}
If you need Node APIs in a browser bundle (rare), explicitly add a fallback:
// webpack.config.js
module.exports = {
resolve: {
fallback: {
fs: false, // explicitly don't polyfill
crypto: require.resolve('crypto-browserify'), // or use a polyfill
},
},
};
Server-Only Package Imported into a Client Component
In Next.js App Router, you import a server-only package (e.g., a database ORM) into a client component:
'use client';
import { db } from '@/lib/database'; // server-only
The bundler fails because it tries to bundle the database package for the browser, which doesn't make sense.
Fix: Move the import to a server action or move the component logic to the server.
// lib/actions.ts
'use server';
import { db } from '@/lib/database';
export async function fetchUser(id: string) {
return db.query('users').where('id', id).first();
}
Then call from the client:
'use client';
import { fetchUser } from '@/lib/actions';
export default async function Profile({ userId }: { userId: string }) {
const user = await fetchUser(userId);
return <div>{user.name}</div>;
}
exports Map Blocks a Deep Import
A package's package.json defines an exports map that explicitly restricts which files are importable:
{
"name": "my-lib",
"exports": {
".": "./dist/index.js",
"./components": "./dist/components/index.js"
}
}
You try to import a deep path: import { Button } from 'my-lib/components/Button'. The bundler fails because the exports map doesn't allow it.
Fix: Only import allowed entry points:
// ❌ Not allowed
import { Button } from 'my-lib/components/Button';
// ✓ Allowed
import { Button } from 'my-lib/components';
If you need a deep import, check the package's docs or propose an addition to the exports map upstream.
ESM/CommonJS Mismatch and Missing File Extensions
In ESM modules, imports must include file extensions. The bundler can't guess that import { util } from './utils' means ./utils.js or ./utils/index.js.
// ❌ ESM: fails
import { util } from './utils';
// ✓ ESM: correct
import { util } from './utils.js';
CommonJS (Node.js) resolves without extensions, but bundlers often enforce ESM rules even for CommonJS packages.
Fix: Always include .js extensions in ESM imports, or configure the bundler to add them automatically. For Vite:
export default {
resolve: {
extensions: ['.js', '.ts', '.tsx', '.jsx', '.json'],
},
};
Diagnostic Checklist
Run through these steps in order:
- Does the module exist in
node_modules? Runnpm ls <package-name>or check the folder directly. - Is the path spelled correctly? Check capitalization, typos, and relative-path syntax. Test on Linux (case-sensitive).
- Is the alias configured in both
tsconfig.jsonand bundler config? Check webpack.config.js, vite.config.js, or next.config.js. - Is this a Node built-in or server-only package? Check if it's being imported in a client bundle. Move to a server action or
'use server'function. - Does the package's
exportsmap allow this import? Checkpackage.jsonto see which entry points are public. - Are file extensions included in ESM imports? In ESM, add
.jsor.tsextensions. - Are you using the right build tool config? Webpack ≠ Vite ≠ Next.js; each reads different config files.
Once the build succeeds, the remaining challenge is spotting errors in production. See our guide on how to debug production errors for techniques that catch issues earlier.
Start tracking errors in minutes
When bundler errors like "Module not found" happen in production—perhaps during a deploy or from a customer's report—error tracking helps you spot the error immediately and trace it to the build or version that introduced it. Start free with LightTrace to catch bundler and JavaScript errors side by side in one dashboard.