The error names a file that plainly exists:

Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './utils' is not defined by
"exports" in /app/node_modules/@scope/my-library/package.json
    at exportsNotFound (node:internal/modules/esm/resolve:295:10)

ls node_modules/@scope/my-library/dist/utils.mjs confirms the file is there. It is unreachable anyway, because an exports map replaces file existence with an explicit allowlist, and this specifier is not on it.


Root Cause

Before a package has an exports field, Node resolves subpaths as filesystem paths. Once the field exists, the resolver consults only the map: it finds the longest matching key, applies the conditions, and resolves the target. A specifier with no matching key does not fall back to the filesystem — it raises this error.

Four situations produce it, and they need different fixes. The subpath is genuinely missing from the map; the map uses a removed syntax such as a trailing-slash directory entry; the specifier reaches into a build directory that was never intended to be public; or the requested path is package.json itself, which many tools read at runtime.


Minimal Reproduction

{
  "name": "@scope/my-library",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "default": "./dist/index.mjs"
    }
  },
  "files": ["dist"]
}
// consumer.ts
import { formatBytes } from "@scope/my-library/utils";   // ERR_PACKAGE_PATH_NOT_EXPORTED
dist/index.mjs
dist/utils.mjs        ← shipped, and unreachable

Which of the Four Cases You Have

Four causes, four fixes A missing subpath key needs an entry added. A trailing-slash directory entry needs replacing with a star pattern. A deep path into a build directory needs a public subpath instead. A request for package.json needs an explicit entry for it. The specifier in the error tells you which one "./utils" — a real feature, no key fix: add the subpath entry, types first "./plugins/csv" — map has "./plugins/" fix: replace the trailing slash with /* "./dist/internal/fmt.js" — internal path fix: expose a public subpath, not that path "./package.json" — read by tooling fix: add an explicit entry for it

Step-by-Step Fix

1. Print the map and compare it with the failing specifier

node -p "JSON.stringify(require('@scope/my-library/package.json').exports, null, 2)" 2>/dev/null \
  || node -p "JSON.stringify(require('./node_modules/@scope/my-library/package.json').exports, null, 2)"

If the first command itself fails with the same error, you already have case four — the manifest is not exported.

2. Add the missing subpath with a full condition object

   "exports": {
     ".": {
       "types": "./dist/index.d.ts",
       "import": "./dist/index.mjs",
       "default": "./dist/index.mjs"
-    }
+    },
+    "./utils": {
+      "types": "./dist/utils.d.ts",
+      "import": "./dist/utils.mjs",
+      "require": "./dist/utils.cjs",
+      "default": "./dist/utils.mjs"
+    },
+    "./package.json": "./package.json"
   }
node --input-type=module -e "
  const { formatBytes } = await import('@scope/my-library/utils');
  console.log('resolved:', typeof formatBytes);
"

Expected output: resolved: function.

3. Replace removed directory syntax with a pattern

-    "./plugins/": "./dist/plugins/"
+    "./plugins/*": {
+      "types": "./dist/plugins/*.d.ts",
+      "import": "./dist/plugins/*.mjs",
+      "default": "./dist/plugins/*.mjs"
+    }

Trailing-slash entries were removed from Node.js and match nothing in current versions, which is why every path under that directory produces this error while the map appears to cover them.

4. Verify the target files actually ship

An entry pointing at a file excluded from the tarball produces a different error for the consumer — ERR_MODULE_NOT_FOUND — but the same support thread, so check both together:

npm pack --dry-run --json | node -e '
  const files = new Set(JSON.parse(require("fs").readFileSync(0,"utf8"))[0].files.map(f => f.path));
  const refs = [...new Set(JSON.stringify(require("./package.json").exports).match(/\.\/[\w./-]+/g) ?? [])];
  const missing = refs.map(r => r.slice(2)).filter(p => !files.has(p) && !p.includes("*"));
  console.log(missing.length ? "will not ship: " + missing.join(", ") : "every exported path ships");
'
Two adjacent errors A specifier absent from the exports map raises ERR_PACKAGE_PATH_NOT_EXPORTED. A specifier present in the map whose target file was not published raises ERR_MODULE_NOT_FOUND. Both need checking together. Two different errors, one release checklist not in the map ERR_PACKAGE_PATH_NOT_EXPORTED the manifest is the problem fix: add the entry in the map, not shipped ERR_MODULE_NOT_FOUND the files array is the problem fix: publish the target

HAZARD PREVENTION

Symptom: A consumer’s build fails with this error naming ./package.json, in a stack trace that points at a bundler plugin rather than their own code.

Root cause: A build tool read your manifest at runtime to discover the version or a field, and the map does not expose it.

Fix: Add "./package.json": "./package.json" to the map. There is no downside — the file is already in the tarball and readable — and it prevents an entire genre of confusing third-party failure.


Verification

# every documented entry point resolves in a real install
d=$(mktemp -d); npm pack --silent | xargs -I{} mv {} "$d/"
cd "$d" && npm init -y >/dev/null && npm install ./*.tgz --silent

for p in "" "/utils" "/package.json"; do
  node --input-type=module -e "await import('@scope/my-library$p')" \
    && echo "ok: @scope/my-library$p" || echo "FAIL: @scope/my-library$p"
done

npx publint --strict

Expected: an ok: line per entry point and a clean publint run.


Edge Cases / Gotchas

  • The error can come from a dependency of a dependency. The specifier named in the message may belong to a package you do not control; the fix is then an issue upstream, or a temporary patch on the installed manifest.
  • Bundlers report it differently. Vite and webpack surface their own message for the same condition, so a consumer’s wording may not match the Node.js text even though the cause is identical.
  • Adding an entry is a minor release; removing one is major. Treat the map as public API — every key you add, you support.
  • Case sensitivity bites on macOS. A map key differing only in case from the requested specifier resolves on a case-insensitive filesystem and fails in CI.
  • A types-only entry does not fix a runtime failure. If the consumer’s error is at runtime, adding a types condition changes nothing; the runtime conditions are what must exist.

One habit prevents most recurrences: whenever a subpath is added to the map, add it to the verification loop in the same commit. The list of entry points a package claims to support and the list it is tested against should be the same list, and keeping them in one place — a small array in the release script — means a new subpath cannot be documented without also being exercised. Packages accumulate this error precisely because the two lists drift, usually when a subpath is added to satisfy one consumer and never enters the release checklist at all.


Frequently Asked Questions

Why does the file exist but not resolve?

Once a package has an exports field, file existence is no longer the criterion. The resolver consults only the map, so an unlisted path is unreachable by design.

How do I find which subpath a consumer needs?

The error names the exact specifier. Compare it with the map’s keys; if the specifier is a deep path into a build directory, the answer is a public subpath rather than an entry for that path.

Can a consumer work around a missing export?

Yes, by patching the installed manifest or importing a different public entry point — both stopgaps, since the patch needs maintaining and the alternative entry may pull in much more code.

Why do trailing-slash directory exports not work?

They were an early form of directory export that Node.js removed. Such an entry matches nothing, so every path under it produces this error.

Should package.json itself be exported?

Yes. Many build tools read a dependency’s manifest at runtime, and omitting it produces this error from inside somebody else’s tooling.



↑ Back to Mastering the package.json Exports Field