Fixing ERR_PACKAGE_PATH_NOT_EXPORTED
ERR_PACKAGE_PATH_NOT_EXPORTED means Node.js found the package you asked for, read its exports map, and refused the subpath you requested because the author never listed it. The package is installed correctly and the file may even exist on disk — the error is an encapsulation boundary doing its job. This guide shows how to read the error, decide whether the consumer or the publisher owns the fix, and ship an exports map that exposes exactly what you intend.
Exact symptoms and error messages
The error appears at module resolution time, before any code in the target file runs. In a CommonJS caller it is thrown synchronously by require(); in an ES module it rejects the import during linking, so the whole module graph fails to start.
Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './lib/utils' is not defined by "exports" in /app/node_modules/some-lib/package.json
at exportsNotFound (node:internal/modules/esm/resolve:304:10)
at packageExportsResolve (node:internal/modules/esm/resolve:651:9)
at resolveExports (node:internal/modules/cjs/loader:591:36)
Two variants are worth recognising. The first names a deep import such as ./lib/utils — the caller reached into the package's internals. The second names the root:
Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: No "exports" main defined in /app/node_modules/esm-only-lib/package.json
That second form means the package does define exports, but no condition in it matches the way you are loading it. The classic case is a CommonJS require() against a package whose root entry only declares an import condition. Bundlers report the same condition with their own wording: webpack prints Module not found: Error: Package path ./lib/utils is not exported from package, and Vite or esbuild print Missing "./lib/utils" specifier in "some-lib" package.
Root cause analysis
Once a package declares an exports field, Node.js stops treating the package directory as an open filesystem. Only the keys in the map are reachable, and each key resolves through its conditions in object order. The mechanics are covered in depth in Understanding package.json Fields; the short version is that resolution walks two questions — is the subpath listed, and does any condition under it match the current loader?
Three situations produce almost every report:
- A deep import into internals. Code written before the package adopted
exportsimportedsome-lib/lib/utils. When the maintainer added anexportsmap in a minor release, that path disappeared. Addingexportsis technically a breaking change for exactly this reason, but many packages ship it in a minor. - A condition gap. The map lists
"."with only animportcondition, sorequire('some-lib')has nothing to match. On Node.js versions withoutrequire(esm)support the resolver cannot fall back, and it throws the "No exports main" variant. - A tool asking for
package.jsonitself. Build tools and framework plugins often callrequire.resolve('some-lib/package.json')to find a package's root. If the map does not export./package.json, that lookup fails even though every runtime import works.
Resolution and configuration patch
Decide first which side owns the fix. If you consume the package, you cannot change its exports map in place (a patch is a stopgap, not a fix). If you publish it, the map is yours to correct.
If you are the consumer
- Replace the deep import with the package's public entry. Most libraries re-export their utilities from the root, so
import { debounce } from 'some-lib'replacesimport debounce from 'some-lib/lib/debounce'. - If the symbol is genuinely not public, pin the last version that allowed the deep import and open an issue asking for a documented subpath. Treat the pin as temporary — record it in your dependency update policy so it gets revisited.
- As a last resort, apply a local patch with
pnpm patch some-liborpatch-packagethat adds the subpath to the map. The patch is re-applied on every install and survives until the upstream fix lands.
If you are the publisher
List every entry point consumers are allowed to use, give each one both module formats if you ship dual builds, and export package.json for tooling:
{
"name": "some-lib",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./utils": {
"types": "./dist/utils.d.ts",
"import": "./dist/utils.js",
"require": "./dist/utils.cjs"
},
"./package.json": "./package.json"
},
"main": "./dist/index.cjs",
"types": "./dist/index.d.ts"
}
Keep main and types alongside exports. Node.js ignores main whenever exports exists, but older bundlers and TypeScript's legacy node10 resolution still read it. The full dual-format layout is walked through in How to Configure package.json for Dual Modules.
If you really do want to expose a directory of files, use a subpath pattern rather than a bare folder mapping. Patterns keep the file extension explicit, so consumers cannot reach sources or test fixtures by accident:
{
"exports": {
"./icons/*": {
"import": "./dist/icons/*.js",
"require": "./dist/icons/*.cjs"
},
"./internal/*": null
}
}
The null target is an explicit block: even if a broader pattern would match ./internal/secret, the more specific null entry wins and the resolver throws. Use it to fence off folders you ship for your own use.
Migrating an existing package to exports without breaking consumers
Most occurrences of this error are self-inflicted by publishers adding exports to a package that has been open for years. The safe path is to discover what consumers actually import before you close anything.
- Inventory real-world deep imports. Search your organisation's code and public dependents for
from 'some-lib/andrequire('some-lib/. Every distinct subpath you find is a de facto public API, whether you meant it to be or not. - Ship a permissive first map. Export the root, every documented subpath, and a compatibility pattern such as
"./lib/*": "./lib/*"that keeps old deep imports working. This release adds encapsulation without removing anything, so it can go out as a minor. - Warn, then remove. Log a one-time deprecation from the compatibility files or document the change in the changelog, then drop the
./lib/*pattern in the next major. Consumers get a full release cycle to move to public entry points. - Pin the contract with a test. Keep a fixture that resolves every public subpath with both loaders; when a future refactor moves a file, the test fails in your CI rather than in a consumer's.
Condition order matters as much as the key list. Node.js walks the conditions of a matched key from top to bottom and takes the first one it recognises, so a default placed above import swallows every lookup and makes the later conditions dead code. TypeScript follows the same rule, which is why types must come first inside each entry. Bundlers add their own conditions — webpack and Vite recognise browser, module and development/production — so a map that works in Node.js can still resolve to a different file in a front-end build. When a report says the error appears only in the browser bundle, print the resolved path with the bundler's own resolver (for example, run Vite with --debug resolve) rather than assuming it matches Node.js.
Finally, remember that exports is resolved per package name, not per file. Self-referencing a package by its own name (import { x } from 'some-lib/utils' inside some-lib) goes through the same map, so a missing key breaks the package's own tests too. That is a useful early warning: write internal tests against the public names and the map gets exercised on every test run.
CLI validation and debug commands
Reproduce the resolution outside your application so framework caches and bundler aliases do not hide the result:
# CommonJS resolution of the root and a subpath
node -e "console.log(require.resolve('some-lib'))"
node -e "console.log(require.resolve('some-lib/utils'))"
# ESM resolution (import.meta.resolve is synchronous on Node 20+)
node --input-type=module -e "console.log(import.meta.resolve('some-lib/utils'))"
# Tooling lookups that need package.json exported
node -e "console.log(require.resolve('some-lib/package.json'))"
# Print the exports map that is actually installed
node -p "JSON.stringify(require('./node_modules/some-lib/package.json').exports, null, 2)"
For publishers, validate the map before it reaches the registry. npx publint flags missing conditions and wrong ordering, and npx @arethetypeswrong/cli --pack . checks that TypeScript sees the same entry points Node.js does. Both are covered in Testing and Validating Packages Before Publishing. A tarball-level smoke test catches the rest:
npm pack
mkdir -p /tmp/consumer && cd /tmp/consumer && npm init -y >/dev/null
npm install /path/to/some-lib-1.4.0.tgz
node -e "require('some-lib'); require('some-lib/utils'); console.log('cjs ok')"
node --input-type=module -e "await import('some-lib/utils'); console.log('esm ok')"
Prevention and CI/CD guardrails
- Treat adding or narrowing
exportsas a breaking change. Ship it in a major, or first export every path that consumers are known to use and remove them later behind a deprecation. - Keep a list of supported entry points in a test. A small script that resolves each documented subpath with both
require.resolveandimport.meta.resolvefails CI the moment a key goes missing. - Run
publintand Are the Types Wrong in the release job, beforenpm publish, so a condition gap never reaches the registry. - Always export
./package.json. It costs nothing and prevents a whole class of tooling failures in frameworks and bundler plugins. - Consumers: ban deep imports with lint rules. The
import/no-internal-modulesESLint rule stops new code from reaching into package internals that a futureexportsmap will close.
Frequently Asked Questions
Why does the file exist in node_modules but still fail to import?
Because exports replaces filesystem lookup with an allowlist. Node.js does not check whether the file exists on disk; it only checks whether the subpath is a key in the map, so an unlisted file is unreachable even though it is there.
Can I bypass the exports map with an absolute path?
You can require('/app/node_modules/some-lib/lib/utils.js') because absolute paths skip package resolution, but it ties your code to one install layout and breaks under pnpm's symlinked store or a hoisting change. Use it only in a throwaway script.
Does TypeScript respect the same exports map?
Only with moduleResolution set to node16, nodenext or bundler. Under the legacy node10 setting TypeScript ignores exports entirely, so types can resolve while the runtime import fails — or the reverse.
Why did a minor version upgrade trigger this error?
The maintainer most likely added an exports field for the first time. Every path not listed became private in that release, which is breaking in practice even when the version number says otherwise.
Related
- Understanding package.json Fields explains how
exports,main,typesand conditions fit together. - How to Configure package.json for Dual Modules builds a complete import/require map for dual-format packages.
- Using Subpath Imports with the imports Field covers the private counterpart to
exportsfor internal aliases. - Validating Package Exports with publint catches missing conditions before you publish.
- Resolving 'Named Export Not Found' in ESM handles the related failure once an entry resolves but its exports do not match.