Home › JavaScript › Webpack Module Not Found? Fix Can't Resolve
Intermediate 6 min · September 23, 2026

Webpack Module Not Found? Fix Can't Resolve

Fix webpack Module not found errors by correcting import paths, registering extensions, syncing aliases, and matching case..

N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Written from production experience, not tutorials.

Follow
✓ Production
production tested
September 23, 2026
last updated
1,942
articles · all by Naren
Before you start⏱ 13 min
  • ✓Basic webpack config familiarity
  • ✓Comfort with import paths and aliases
  • ✓A JS project with a bundler config
 ● Production Incident 🔎 Debug Guide
⚡Quick Answer
  • Read the resolver trace: the Details line names the exact file and extensions webpack tried
  • Fix the import path first — wrong depth (../ vs ./) and moved files cause most failures
  • Add missing extensions to resolve.extensions and mirror tsconfig path aliases in webpack config
  • Match filename case exactly since Linux builds fail where Mac builds pass
  • Reproduce with a clean build since warm caches can mask the moved files that fail CI
✦ Definition~90s read
What is Webpack Module Not Found Fix?

Webpack's resolver (enhanced-resolve) translates each import specifier into an absolute file path before bundling. For relative imports it starts at the importing file's directory and walks the specifier; for bare imports it climbs node_modules exactly like Node; for aliased imports (@/components/...) it substitutes the configured prefix first.

★
Picture a mail carrier with a slightly wrong address: right street, wrong house number.

At each candidate it tries resolve.extensions in order (.js, .jsx, .ts, ...), checks for directory index files, and applies loader rules. Can't resolve means every candidate missed — the resolver exhausted its search space without a hit.

The failure modes map to search-space gaps. Wrong relative depth (../utils vs ./utils after a file move) points the search at a directory that never contained the target. Missing extensions (.tsx absent from resolve.extensions while components use it) make the resolver skip files sitting in plain sight.

Unsynced aliases (tsconfig paths declaring @/* while webpack resolve.alias lacks it) pass type-checking and fail bundling — tsc and webpack resolve independently. Case mismatches (./Button vs ./button) pass on Mac and fail on Linux CI. Missing loaders for new extensions (.svg, .graphql) fail one step later with a parse error, but the investigation starts at the same resolver trace.

Context imports add a final twist: require(variable) or dynamic import with expressions makes webpack bundle a directory context instead of one file, and files outside the context regex vanish at runtime. The Details block in the error output is the primary evidence — it lists every probed path. Read it before touching config; it usually names the gap outright.

Plain-English First

Picture a mail carrier with a slightly wrong address: right street, wrong house number. They knock on every nearby door, find nothing, and return the letter marked can't deliver. That's webpack's resolver — it takes your import address, searches the configured neighborhoods (folders, extensions, aliases), and reports back when no door matches. The letter's contents are fine; the address needs one corrected digit. Reading the carrier's route log shows exactly which doors they tried.

Your build fails with Module not found: Error: Can't resolve './components/Button' in '/app/src/pages'. The file exists — you can see it. Imports worked yesterday. You try deleting node_modules, clearing caches, even reinstalling webpack, and the same red block returns in 40 seconds. The resolver isn't broken; it's following your wrong address precisely.

Can't resolve is webpack saying it searched everywhere configured and found nothing matching. The search covers specific places: relative lookup from the importing file, configured extensions appended in order, alias substitutions, and loader-resolved requests. Each misconfiguration produces the same terse error with different fingerprints hidden in the Details section most developers never expand.

This guide teaches resolver literacy: reading the trace, fixing depth and extension gaps, syncing aliases with TypeScript, and killing case bugs. The same skills transfer to Vite and Jest, whose resolvers rhyme with webpack's. The Details trace usually names the gap outright for anyone who expands it.

Reading the Resolver Trace: Details Names the Gap

Every Can't resolve error ships with a Details section listing each probed path in order. It shows the substituted alias, every extension attempted, each parent directory climbed for bare imports, and the final miss. Developers who read only the first line see a riddle; developers who expand Details see an itemized search log that usually identifies the gap directly — wrong directory, untried extension, stale alias substitution.

Train the reflex: scroll past the headline to the trace first. Check whether the base directory matches the importing file's real location (moves break this), whether your file's extension appears in the tried list (gaps here mean resolve.extensions), and whether aliased paths substituted to the location you expect (stale aliases show plainly). Reproduce with --bail and --stats-error-details for the full trace. Ten seconds of reading replaces thirty minutes of config thrash.

Stats verbosity settings control how much evidence you get. The default error output truncates Details; rerun with --stats-error-details for the full probed-path list, and add --bail to stop at the first failure instead of drowning in 200 follow-on errors from one moved barrel. For persistent mysteries, enable resolve logging (infrastructureLogging with enhanced-resolve debug) to watch each substitution and extension trial in real time — noisy, but definitive when the trace and config disagree. Save the full trace into the bug ticket rather than paraphrasing it; resolver issues get misdiagnosed in retelling, and the raw probed list keeps everyone honest. Build the team habit with a runbook snippet: the three commands (bail build, grep Details, realpath the importer) pasted into every Can't resolve ticket before any theory is allowed. Evidence first, config second, cache theories never.

BASH
1
2
3
npx webpack --bail 2>&1 | grep -B2 -A 12 "Can't resolve" | head -40
realpath src/pages/Dashboard.jsx
ls src/components/ | grep -i button
📊 Production Insight
A 200-error pileup traced to one stale alias visible in the first Details block. The team lost 4 hours to cache theories before reading the trace that named the answer.
🎯 Key Takeaway
Expand Details first. The probed-path list usually names the wrong directory, missing extension, or stale alias outright.

Relative Depth: ../ vs ./ After Every Move

Moving a file changes every relative import inside it, and editors don't always rewrite them. A component moved one folder deeper needs an extra ../ on each import; a barrel re-export pointing at the old depth breaks all 200 consumers at once. The resolver reports each as an independent Can't resolve, which disguises the single moved file behind a wall of errors sharing one importing directory.

Fix structurally: prefer alias imports (@/components/Button) for cross-directory references so moves don't rewrite depth, and reserve relative imports for siblings. After any move, rebuild clean immediately — cached resolutions can mask the breakage locally while CI fails. When the error list shares one source directory, suspect the moved file first and diff its location against the probed base in Details.

Barrel files concentrate depth risk in one place. A barrel re-exporting 50 modules means one moved barrel breaks 200 consumers with identical errors — spectacular blast radius from a single rename. Mitigate by preferring direct aliased imports for hot paths (faster resolution, clearer traces) while keeping barrels for public package APIs where the indirection is the contract. When a barrel must move, codemod all importers atomically in the same commit and rebuild clean immediately; staged moves (barrel now, consumers later) guarantee a red window. Lint against circular barrel imports too — cycles resolve but bundle unpredictably, and the resulting runtime errors masquerade as resolver failures. Depth discipline plus barrel hygiene turns refactors from build roulette into routine commits the pipeline absorbs without comment.

webpack.config.js (excerpt)JAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
const path = require('path');

module.exports = {
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src'),
      '@shared': path.resolve(__dirname, 'src/shared/ui'),
    },
    extensions: ['.tsx', '.ts', '.jsx', '.js', '.json'],
  },
};
Try it live
📊 Production Insight
Replacing 40 deep relative imports with alias imports ended a quarterly cycle where every refactor broke builds. Moves now touch zero consumer paths.
🎯 Key Takeaway
Aliases survive moves; relative depth doesn't. Use @/ for cross-directory imports and rebuild clean after refactors.

resolve.extensions: Teach Webpack Your File Types

Webpack only tries the extensions listed in resolve.extensions, in listed order. A project adding TypeScript without registering .ts/.tsx, or adopting .jsx without listing it, produces Can't resolve for files sitting exactly where the import points. Order matters too: .tsx before .ts before .js ensures the most specific match wins when sibling basenames collide across languages.

Audit extensions against reality: list actual source extensions with find and compare to the config. Include .json and any custom types (.svg handled by loaders still needs no extension entry for JS resolution, but .graphql imports might). Keep the list tight — overly long lists slow resolution measurably on large trees. After editing, clear persistent cache once; extension changes don't always invalidate cached resolutions.

Extension order bugs are subtler than missing entries. When Button.js and Button.tsx coexist (migration in progress), resolve order decides which one bundles — and the wrong pick ships stale logic with a green build. List the newest-source extension first (.tsx, .ts, then .jsx, .js) so migrations converge instead of shadowing. Framework-specific types need entries too: .vue, .svelte, .astro each require registration plus their loader, and forgetting either half produces errors that blame the wrong layer. Audit programmatically: compare find output of real extensions against the config list in a CI check that fails on unregistered types — new file kinds then announce themselves in PRs instead of production. Keep the list minimal otherwise; every extra extension multiplies filesystem probes across the whole graph, and large trees feel the slowdown in cold builds.

BASH
1
2
3
grep -n -A 6 'resolve:' webpack.config.js | head -20
find src -type f | sed 's/.*\.//' | sort | uniq -c | sort -rn | head
rm -rf node_modules/.cache && npx webpack --bail 2>&1 | tail -5
📊 Production Insight
A TypeScript migration added 300 .tsx files without registering the extension. Every one failed until a single config line — '.tsx' first in the list — fixed all 300.
🎯 Key Takeaway
Mirror resolve.extensions to your real file types, most-specific first. Audit with find whenever a new extension appears.

Aliases in Sync: tsconfig paths ≠ webpack alias

TypeScript paths satisfy the type-checker and editors; webpack resolve.alias satisfies the bundler. They share syntax but no state — updating one without the other yields the signature symptom of this incident: tsc green, webpack red, editors happy, CI broken. Frameworks add layers (craco, next.config, vite aliases) that each need the same mapping.

Centralize the mapping. Define path constants in one module imported by both tsconfig (via extends or codegen) and webpack config, with comments cross-linking them. Add a CI job running tsc --noEmit and the bundler back-to-back so divergence fails the same PR that introduces it. When aliases span packages in a monorepo, verify each app's config — shared libraries resolve aliases relative to the compiling app, not the library source.

Monorepos multiply the alias-sync problem across packages. Each app needs the shared library's paths mirrored in its own bundler config, since resolution runs relative to the compiling app — a library's internal @/ alias means nothing to the app building it. Centralize path maps in a shared config package (or workspace root file) that every app's webpack, vite, jest, and tsconfig extends or imports, with a test asserting all four agree. For Craco, Next.js, and Vite coexisting in one repo, write one aliases module with per-tool adapters rather than four hand-maintained copies. Verify with a cross-tool CI job: type-check, bundle, and test in sequence, failing when any resolver diverges. One source of path truth ends the era where editors, compilers, and bundlers each believed a different map of the same codebase.

tsconfig.json (excerpt)JSON
1
2
3
4
5
6
7
8
9
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@shared/*": ["src/shared/ui/*"]
    }
  }
}
⚠ Two Resolvers, One Mapping
Every tsconfig paths entry needs a matching webpack resolve.alias entry. Change them as a pair in one commit, verified by running tsc and webpack together in CI.
📊 Production Insight
Colocating alias definitions plus a dual tsc-and-webpack CI step ended 4-hour divergence incidents. Alias drift now fails the introducing PR in minutes.
🎯 Key Takeaway
Mirror every tsconfig path in webpack alias. Verify both resolvers in the same CI job.

Case Sensitivity: The Linux-Only Failure

Importing ./Button when the file is button.jsx passes on macOS and Windows, fails on Linux. Refactors that rename casing (button to Button) are worse: git on case-insensitive systems may not even record the rename, leaving the repo and disk disagreeing. CI on Linux then fails while every laptop stays green — the classic can't-resolve that only exists in the pipeline.

Enforce lowercase-with-hyphens filenames so casing has one valid spelling, and run CI on Linux so violations fail in PRs. Detect existing drift with case-insensitive duplicate scans. When git ignores a case-only rename, force it with git mv to stage the change explicitly. The resolver trace helps here too: compare the probed filename's case against ls output byte-for-byte.

Git configuration can hide case drift for months. core.ignorecase=true (auto-set on Mac/Windows clones) makes git blind to case-only changes — the rename never stages, never commits, and Linux CI fails on code every laptop insists is fine. Set core.ignorecase=false in repos with Linux deployments to force explicit renames, and audit history for phantom renames with log --diff-filter=R plus case-sensitive comparisons. Enforce naming conventions mechanically: a lint rule rejecting uppercase in new filenames (with an escape hatch for framework-mandated patterns like Component.jsx where the team standard requires it). When conventions allow capitals, require imports to match exactly and test on Linux — partial discipline (convention without CI) is just documentation. Case bugs are pipeline problems wearing a spelling disguise; fix the pipeline and the spelling follows.

BASH
1
2
3
find src -name '*.jsx' | sort -f | uniq -di
git mv src/components/button.jsx src/components/Button.jsx
ls -la src/components/ | grep -i button
📊 Production Insight
A case-only rename unrecorded by git broke Linux CI for 2 days while 8 Mac laptops stayed green. git mv plus Linux CI closed the category permanently.
🎯 Key Takeaway
Standardize lowercase filenames, commit case renames with git mv, and run CI on Linux.

Bare Imports and the exports Map

Bare imports (lodash, @acme/utils) resolve through node_modules with the same walk-up as Node, then honor the package's exports field when present. Exports restrict deep access: require('pkg/internal') fails even when the file exists if the map doesn't expose ./internal. Version upgrades that add an exports map convert previously working deep imports into Can't resolve overnight — the files didn't move, the permission did.

Diagnose with npm ls for installation state and by reading the exports map for allowed subpaths. Prefer documented entry points over deep paths; when deep access is necessary, confirm the subpath is exported before importing. For monorepo workspace packages, verify each workspace's main/exports plus the bundler's symlinks handling (resolve.symlinks default true usually suffices). Missing packages need npm install; blocked subpaths need an import rewrite, not a reinstall.

Workspace protocol imports add local failure modes to the bare-import story. npm/yarn/pnpm workspace: links resolve through symlinks, and a missing workspace entry (typo'd name, forgotten workspace definition) fails like an uninstalled package with a local flavor. Verify workspace wiring with your manager's list command (pnpm m ls, npm query) before debugging resolution. Version mismatches between workspace siblings produce dual-copy bugs: two versions of a shared singleton (React, state managers) break identity checks exactly like duplicate installs. Align sibling versions with workspace:* ranges or explicit peer contracts, and assert single copies in CI. For deep imports into workspaces, the same exports-map discipline applies — expose public entry points from each package rather than letting apps reach into sibling internals. Local packages deserve the same resolution hygiene as published ones.

BASH
1
2
3
npm ls lodash --depth=0
node -p "JSON.stringify(require('lodash/package.json').main)"
node -p "JSON.stringify(require('@acme/utils/package.json').exports, null, 2)" | head -20
📊 Production Insight
A minor bump added an exports map that blocked 12 deep imports. Reading the map took 5 minutes; the preceding reinstall attempts took 2 hours.
🎯 Key Takeaway
Check installation with npm ls, then the exports map for deep paths. Blocked subpaths need import rewrites, not reinstalls.
● Production incidentPOST-MORTEMseverity: high

A Barrel-File Move Broke 200 Imports for 4 Hours

Symptom
Monday's refactor moved src/shared/index.js to src/shared/ui/index.js and updated tsconfig paths. Developers' editors resolved everything; tsc passed. But CI builds failed with 200 Module not found errors, all Can't resolve '@/shared' variants. The team reverted the refactor, re-landed it with barrel re-exports, and failed again — 4 hours of red pipelines, 30 blocked PRs, and a growing suspicion that the build cache was haunted. Local webpack builds passed for half the team (stale cache) and failed for the rest.
Assumption
The team assumed tsconfig paths governed webpack resolution, so updating one config looked complete. They then assumed a poisoned CI cache and burned an hour on cache purges that changed nothing. A third theory blamed case sensitivity (all misses, no hits), prompting a filename audit that found zero case bugs. The actual gap — webpack's separate resolve.alias — went unread because nobody expanded the Details block showing the old substituted path.
Root cause
tsconfig paths and webpack resolve.alias are independent systems that must mirror each other manually. The refactor updated tsconfig (so editors and tsc resolved) but left webpack's alias @ pointing at src/shared instead of the new location. Webpack substituted the stale prefix, probed a directory whose barrel had moved, and reported Can't resolve for every consumer. Local passes came from persistent filesystem caches holding the old layout; CI built clean and failed honestly.
Fix
The alias was updated to the new path in webpack.config.js plus the craco override used in one app, and tsconfig plus webpack aliases were colocated in a shared config module with a comment cross-linking them. A CI step now runs tsc --noEmit and webpack --bail together so the two resolvers can't diverge silently again. Local caches were purged once via a documented cache-bust command. Builds went green in 12 minutes; a follow-up codemod replaced 40 deep imports with the corrected alias.
Key lesson
  • TypeScript paths and webpack aliases are two resolvers, not one. Any path change must update both, verified by running tsc and webpack in the same CI job.
  • Expand the Details block before theorizing. It printed the stale substituted path from the first failure — 4 hours of cache and case theories never needed to happen.
  • Stale local caches lie. When half the team passes and CI fails, trust the clean build and purge local caches before forming theories.
Production debug guideFive resolver checks in evidence order — the trace names the gap before you guess.5 entries
Symptom · 01
Can't resolve a relative import like './components/Button'
→
Fix
Verify depth and spelling: run ls src/components/ | grep -i button and count ../ segments against the importing file's directory with realpath src/pages/Dashboard.jsx. Fix the segment count or filename, then confirm with npx webpack --bail 2>&1 | grep -A 8 Details to see the probed paths.
Symptom · 02
Can't resolve an extension-less import of .tsx/.ts/.vue files
→
Fix
Check the extension list: run grep -n 'extensions' webpack.config. and compare against find src -name '.tsx' | head -3. Add missing entries to resolve.extensions (order matters — .tsx before .ts before .js) and rebuild clean.
Symptom · 03
Editors resolve @/ aliases but webpack fails
→
Fix
Diff the two systems: run node -p "JSON.stringify(require('./tsconfig.json').compilerOptions.paths)" and grep -n 'alias' webpack.config.* side by side. Mirror every tsconfig path entry in resolve.alias exactly, then verify both with tsc --noEmit && npx webpack --bail.
Symptom · 04
Build passes on Mac, fails on Linux CI with identical code
→
Fix
Hunt case drift: run find src -name '*.jsx' | sort -f | uniq -di and ls -la on the disputed directory. Rename files to match imports exactly (lowercase convention), and move CI to Linux if it isn't already so mismatches fail in PRs.
Symptom · 05
Bare package import fails right after install or refactor
→
Fix
Confirm installation and entry points: run npm ls <pkg> --depth=0, ls node_modules/<pkg>/package.json, and node -p "JSON.stringify(require('<pkg>/package.json').exports ?? require('<pkg>/package.json').main)". Reinstall if missing; fix deep imports blocked by the exports map if present.
Webpack Can't Resolve — Causes Compared
Root CauseHow to ConfirmFixPrevention
Wrong relative depthProbed base misses target dirFix ../ count or use aliasAlias imports cross-directory
Extension not registeredExtension absent from tried listAdd to resolve.extensionsAudit extensions on new types
Alias diverged from tsconfigSubstituted path is staleMirror alias to new pathDual tsc+webpack CI step
Case mismatch Mac vs LinuxFails only on Linux CIMatch case; lowercase ruleLinux CI plus git mv renames
Blocked by exports mapDeep path missing from mapUse public entry pointAvoid deep imports
⚙ Quick Reference
6 commands from this guide
FileCommand / CodePurpose
npx webpack --bail 2>&1 | grep -B2 -A 12 "Can't resolve" | head -40Reading the Resolver Trace
webpack.config.js (excerpt)const path = require('path');Relative Depth
grep -n -A 6 'resolve:' webpack.config.js | head -20resolve.extensions
tsconfig.json (excerpt){Aliases in Sync
find src -name '*.jsx' | sort -f | uniq -diCase Sensitivity
npm ls lodash --depth=0Bare Imports and the exports Map

Key takeaways

1
Read the Details trace before touching config or caches.
2
Fix relative depth or switch cross-directory imports to aliases.
3
Register every real extension in resolve.extensions, specific first.
4
Mirror tsconfig paths and webpack alias as one paired change.
5
Enforce lowercase names with Linux CI for case bugs.
6
Respect the exports map; use public entry points.

Common mistakes to avoid

6 patterns
×

Deleting node_modules before reading Details

Symptom
Ten-minute reinstall returns the identical error; the search-space gap is untouched.
Fix
Expand the resolver trace first — it names the gap in seconds.
×

Updating tsconfig paths without webpack alias

Symptom
Editors and tsc pass; bundler fails on every aliased import.
Fix
Change both resolvers as a pair in one commit with dual CI verification.
×

Using deep relative imports across the tree

Symptom
Every file move breaks dozens of consumers with depth errors.
Fix
Use @/ aliases cross-directory; reserve ./ for true siblings.
×

Adding file types without registering extensions

Symptom
Hundreds of Can't resolve errors for files sitting in plain sight.
Fix
Register each new extension in resolve.extensions, most-specific first.
×

Trusting warm local caches over clean CI

Symptom
Half the team green, CI red; theories multiply while the stale layout hides.
Fix
Purge local caches and trust the clean build's verdict.
×

Deep-importing past the exports map

Symptom
Upgrades convert working imports into Can't resolve with files unmoved.
Fix
Import public entry points; verify subpaths against the exports map.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What's the first thing you read in a Can't resolve error?
Q02SENIOR
tsc passes but webpack fails on @/ imports. Why?
Q03SENIOR
Builds pass on Mac but fail on Linux CI. Diagnosis?
Q04SENIOR
A minor upgrade broke deep imports with files unmoved. What happened?
Q05SENIOR
How do you make resolver config refactor-proof?
Q01 of 05JUNIOR

What's the first thing you read in a Can't resolve error?

ANSWER
The Details trace — probed paths, tried extensions, substituted aliases. It usually names the gap directly: wrong directory, missing extension, or stale alias.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Should I eject CRA to fix aliases?
02
Does order in resolve.extensions matter?
03
Why do dynamic imports fail while static ones work?
04
Can symlinks break resolution in monorepos?
05
How do Vite and Jest differ here?
06
When is deleting node_modules actually right?
N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Written from production experience, not tutorials.

Follow
✓ Verified
production tested
September 23, 2026
last updated
1,942
articles · all by Naren
🔥

That's Bundlers. Mark it forged?

6 min read · try the examples if you haven't

←
Previous
npm Engine Node Incompatible Fix
1 / 2 · Bundlers
Next
Webpack Module Parse Failed Fix
→