LEARN · DEBUGGING GUIDE

Debugging Monorepo Package Import Resolution Failures

When `import` from a sibling monorepo package throws MODULE_NOT_FOUND, the problem is almost never what you think. Here's the exact sequence to isolate the broken link.

IntermediateBuild tools6 min read

What this usually means

This failure pattern indicates a mismatch between the import specifier and the actual location Node or the bundler uses to resolve it. In monorepos using npm/yarn/pnpm workspaces or Bazel, each package has its own `package.json` with a `main` or `exports` field. The resolver follows Node's module resolution algorithm: it looks at `node_modules`, symlinks from workspace roots, and `package.json` exports. When a package fails to resolve, it's usually because the `main` file is missing, the `exports` map is misconfigured, the package isn't built before import, or the workspace protocol (e.g., `workspace:*`) isn't honored by the package manager. Another common cause is that the importing package's `node_modules` contains a stale copy instead of a symlink to the workspace package.

( 01 )Fast diagnosis

The first ten minutes — establish facts before touching code.

  • 1Run `node -e "require('your-package')"` from the consumer's root to see the exact error stack trace
  • 2Check if the package is built: `ls node_modules/your-package/dist/index.js` (or whatever `main` points to)
  • 3Verify the workspace symlink: `ls -la node_modules/your-package` — it should show a symlink to `../../packages/your-package`
  • 4Inspect the package's `package.json`: `cat packages/your-package/package.json | jq '.main, .exports'`
  • 5Confirm workspace version resolution: `npm ls your-package` or `yarn why your-package` (look for `resolved: workspace:`)
( 02 )Where to look

The specific files, logs, configs, and dashboards that usually own this bug.

  • search`packages/your-package/package.json` — `main`, `exports`, `types` fields
  • search`node_modules/your-package` — check if it's a symlink or real directory
  • search`packages/your-package/dist/` — ensure the build output exists and matches `main`
  • search`root/package.json` — `workspaces` array must list the package's directory
  • search`tsconfig.json` — `paths` and `baseUrl` configuration for TypeScript resolution
  • search`node_modules/.modules.yaml` (pnpm) — check if the package is hoisted
  • searchBuild logs — look for `Resolved` lines in webpack/Vite output
( 03 )Common root causes

Practical causes, not theory. These are the things you will actually find.

  • warningPackage not built before import — `main` points to `dist/index.js` but `dist/` is empty
  • warning`exports` map in `package.json` restricts subpath imports and the consumer uses an unlisted subpath
  • warningPackage manager hoisting conflicts — npm/yarn classic installs a stale version instead of symlink
  • warningWorkspace glob pattern missing the package — e.g., `packages/*` doesn't match `packages/some-package`
  • warningTypeScript `paths` config misaligned with actual module resolution
  • warningPackage `main` field points to a `.ts` file that Node can't run directly
  • warningConflicting `node_modules` in the package itself (e.g., from running `npm install` inside the package)
( 04 )Fix patterns

Concrete fix directions. Pick the one that matches your root cause.

  • buildEnsure the package is built: add `"prepare": "npm run build"` or run build in CI before dependent packages
  • buildConfigure `exports` properly: use conditional exports like `{ ".": "./dist/index.js" }`
  • buildForce symlink recreation: delete `node_modules` and re-run `npm install` (workspace protocol)
  • buildUse the `workspace:` protocol explicitly: `"dependencies": { "shared": "workspace:*" }`
  • buildAlign TypeScript paths with actual resolution: set `baseUrl` to `.` and `paths` to match workspace structure
  • buildRun `npm dedupe` or `yarn dedupe` to remove duplicate versions that break resolution
( 05 )How to verify

A fix you cannot prove is a guess. Close the loop.

  • verifiedRun `node -e "require('your-package')"` — should return the module exports
  • verifiedTypeScript build passes with `tsc --noEmit`
  • verifiedBundle build succeeds (webpack/vite) with no resolution errors
  • verifiedRun `npm ls your-package` — should show `resolved: workspace:...`
  • verifiedCheck symlink: `readlink node_modules/your-package` should point to the local package directory
  • verifiedTest with `node --experimental-specifier-resolution=node` if using ESM
( 06 )Mistakes to avoid

Things that make this bug worse or harder to find.

  • warningDon't manually symlink packages — let the package manager manage workspace links
  • warningDon't ignore the `exports` field: it can block access even if the file exists
  • warningDon't assume `npm install` in a workspace package fixes root resolution — it creates a local `node_modules`
  • warningDon't use relative imports across packages in production — they bypass the resolution algorithm
  • warningDon't skip building dependencies in CI — order the build steps correctly
( 07 )War story

Shared Utils Import Fails After Monorepo Restructure

Backend EngineerNode.js 18, TypeScript 5, pnpm workspaces, NestJS

Timeline

  1. 09:15Moved shared package from `packages/shared` to `packages/libs/shared-utils`
  2. 09:20Updated root `pnpm-workspace.yaml` to include `packages/libs/*`
  3. 09:30Ran `pnpm install` — no errors
  4. 09:45Started dev server — `Cannot find module '@myorg/shared-utils'`
  5. 10:00Checked `node_modules/@myorg/shared-utils` — it's a symlink pointing correctly
  6. 10:10Ran `node -e "require('@myorg/shared-utils')"` — got `ERR_MODULE_NOT_FOUND`
  7. 10:15Inspected `shared-utils/package.json` — `main` points to `dist/index.js` but `dist/` is empty
  8. 10:20Built shared-utils with `pnpm --filter @myorg/shared-utils run build`
  9. 10:25Dev server started successfully

I had just restructured our monorepo to group library packages under `packages/libs/`. I updated the workspace config, ran install, and everything seemed fine. But when I started the NestJS dev server, it immediately threw `Cannot find module '@myorg/shared-utils'`. The module existed in node_modules as a symlink, so I was confused.

I tried requiring the module directly with Node.js — same error. I checked the symlink target and it pointed to the right directory. Then I looked at the package's `package.json` and saw `"main": "dist/index.js"`. The `dist/` folder was empty because I hadn't built the package after moving it. The symlink was correct, but the entry point file didn't exist.

I ran the build for that specific package using pnpm's filter, and then everything worked. The lesson: when you move a workspace package, always rebuild it before trying to import. Also, adding a `prepare` script that builds can automate this in CI.

Root cause

The package's `main` pointed to a build artifact that did not exist because the package was moved but not rebuilt.

The fix

Built the shared package using `pnpm --filter @myorg/shared-utils run build` and added `"prepare": "pnpm run build"` to its `package.json`.

The lesson

Always verify that the entry point file exists after moving or restructuring a workspace package. Build artifacts are not automatically regenerated on workspace install.

( 08 )Understanding Module Resolution in Monorepos

Node.js resolves modules by walking up the directory tree looking for `node_modules`. In a monorepo with workspaces, the root `node_modules` contains symlinks to each workspace package. The symlink's target directory must contain the package's `package.json` with a valid `main` or `exports` field that points to an actual file.

Key files: `package.json` `main` field (CommonJS), `exports` field (both CJS and ESM), and `types` field (TypeScript). The resolver first checks `exports`, then falls back to `main`. If `exports` is defined but doesn't include the import specifier, resolution fails even if `main` exists.

( 09 )pnpm Specifics: The Module Store and Hoisting

pnpm uses a content-addressable store and creates hard links or symlinks from `node_modules/.pnpm`. Workspace packages are symlinked from the store. Run `pnpm ls --depth=0` to see the resolved version. If a workspace package is not resolved as `link:`, it may be hoisted incorrectly.

Check `node_modules/.modules.yaml` for the `hoistedDependencies` list. If your package appears there, resolution may use an external version instead of the local one. Use `pnpm rebuild` to recreate workspace links.

( 10 )TypeScript Resolution Nuances

TypeScript has its own module resolution that can diverge from Node's. The `paths` and `baseUrl` in `tsconfig.json` can override resolution. If you have `paths: { "@myorg/*": ["packages/*/src"] }`, TypeScript may resolve to source files while Node resolves to built files. This leads to 'type' errors but not runtime errors, or vice versa.

To align, either use project references (`composite` and `references`) or ensure `paths` targets the same location as `main`/`exports`. Alternatively, set `moduleResolution: "bundler"` in TypeScript 5+ to mimic bundler resolution.

( 11 )Bundler-Specific Resolution (Webpack, Vite, esbuild)

Bundlers have their own resolution algorithms. They respect `exports` and `main` but may also follow `browser` or `module` fields. A common failure is when a package's `module` field points to an ESM build that doesn't exist, or the bundler's `resolve.alias` overrides the workspace symlink.

To debug, enable verbose resolution logging: for webpack add `stats: { logging: 'verbose' }`; for Vite set `resolve: { preserveSymlinks: false }` and check the terminal output for resolution paths.

( 12 )CI/CD Pitfalls: Order and Caching

In CI, packages are often installed from cache (e.g., `npm ci`). If the cache has a stale version of a workspace package, the symlink may point to an old build. Always run the build step for all changed packages before running tests or starting the app.

Use tools like Turborepo, Nx, or Lerna to orchestrate build order. For GitHub Actions, use `actions/cache` with a key that includes the lockfile and workspace files to ensure cache validity.

Frequently asked questions

Why does `require('package')` work in Node but not in TypeScript?

TypeScript uses its own resolution algorithm based on `tsconfig.json`. If `paths` or `baseUrl` are set, TypeScript may resolve to a different location than Node. Node resolves using `node_modules` lookup. To fix, either align `paths` with actual module locations or use project references.

How do I force npm/yarn to always use the workspace version?

Use the `workspace:` protocol: `"dependencies": { "my-package": "workspace:*" }`. This tells the package manager to resolve to the local workspace package regardless of the version in the registry. For npm workspaces, also ensure the package is listed in the root `workspaces` array.

What's the difference between `main` and `exports` in package.json?

`main` is the legacy entry point for CJS. `exports` is the modern field that supports conditional exports (e.g., different entry for `import` vs `require`) and subpath exports. If `exports` is present, Node ignores `main` for that package entirely. Always include both for compatibility.

My production Docker build fails but local works — why?

Common causes: (1) Docker build context doesn't include the workspace packages — ensure your `.dockerignore` doesn't exclude them. (2) Build steps are not ordered — build dependencies first. (3) `npm ci` installs from lockfile which may not have the workspace links — use `--workspaces` flag or copy the whole monorepo.

Can I use relative paths like `../../packages/shared` instead of package name imports?

Technically yes, but it defeats the purpose of a monorepo — you lose versioning, deduplication, and clarity. Also, bundlers may not handle them correctly. Always use the package name and let the workspace symlink resolve it.