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..
20+ years shipping production JavaScript and front-end systems at scale. Written from production experience, not tutorials.
- ✓Basic webpack config familiarity
- ✓Comfort with import paths and aliases
- ✓A JS project with a bundler config
- 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
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.
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.
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.
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.
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.
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.
A Barrel-File Move Broke 200 Imports for 4 Hours
- 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.
| File | Command / Code | Purpose |
|---|---|---|
| npx webpack --bail 2>&1 | grep -B2 -A 12 "Can't resolve" | head -40 | Reading the Resolver Trace | |
| webpack.config.js (excerpt) | const path = require('path'); | Relative Depth |
| grep -n -A 6 'resolve:' webpack.config.js | head -20 | resolve.extensions | |
| tsconfig.json (excerpt) | { | Aliases in Sync |
| find src -name '*.jsx' | sort -f | uniq -di | Case Sensitivity | |
| npm ls lodash --depth=0 | Bare Imports and the exports Map |
Key takeaways
Common mistakes to avoid
6 patternsDeleting node_modules before reading Details
Updating tsconfig paths without webpack alias
Using deep relative imports across the tree
Adding file types without registering extensions
Trusting warm local caches over clean CI
Deep-importing past the exports map
Interview Questions on This Topic
What's the first thing you read in a Can't resolve error?
Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Written from production experience, not tutorials.
That's Bundlers. Mark it forged?
6 min read · try the examples if you haven't