Without a correctly configured exports field, Node.js 12+ and modern bundlers ignore your main and module fields entirely and throw ERR_PACKAGE_PATH_NOT_EXPORTED the moment a consumer tries to import a sub-path. Get the condition order or path prefix wrong and you silently ship the wrong format — CJS where ESM was expected — breaking tree-shaking and triggering the dual-package hazard that duplicates singleton state across a dependency graph.

Prerequisites

Before working through the steps below, confirm:


Canonical Configuration Block

The snippet below is a complete, annotated export map for a dual-format package. Every condition key appears in the order Node.js evaluates them — top to bottom, stopping at the first match.

{
  "name": "@acme/sdk",
  "version": "2.0.0",
  // Do NOT declare "main" or "module" alongside exports in Node 14+;
  // they become dead fallback only for very old bundlers.
  "exports": {
    // The dot (".") key is the default entry — what "import '@acme/sdk'" resolves to.
    ".": {
      // TypeScript MUST come first; tsc resolves declarations before runtime paths.
      "types": "./dist/esm/index.d.ts",
      // "import" matches native ESM consumers and bundlers that set type:"module".
      "import": "./dist/esm/index.mjs",
      // "require" matches CommonJS consumers (require(), ts-node default, Jest).
      "require": "./dist/cjs/index.cjs",
      // "default" is the final safety net — always include it.
      "default": "./dist/esm/index.mjs"
    },
    // Named sub-paths must be explicitly listed; no wildcard globs by default.
    "./utils": {
      "types": "./dist/esm/utils.d.ts",
      "import": "./dist/esm/utils.mjs",
      "require": "./dist/cjs/utils.cjs",
      "default": "./dist/esm/utils.mjs"
    }
  }
}

Understanding how ESM and CJS module formats differ at the syntax level is essential before reading the condition keys above — the import and require conditions map directly to the two format boundaries.


Resolution Order Diagram

The diagram below shows how Node.js walks an export map for a single entry point. Condition keys are tested top-to-bottom; the first match wins.

Export map condition resolution order in Node.js Flowchart showing that when a consumer imports a package, Node.js checks the exports field, then walks condition keys in order (types, import, require, default) and returns the first matching file path. If no condition matches, it throws ERR_PACKAGE_PATH_NOT_EXPORTED. Consumer import / require Look up "exports" entry point Entry exists in exports? No ERR_PACKAGE _PATH_NOT _EXPORTED Yes Walk conditions top-to-bottom: types → import → require → default Return matched file path

Step-by-Step Implementation

Step 1 — Replace main with a dot export

Legacy main points to a single file with no format discrimination. Replace it:

- "main": "./dist/index.js",
- "module": "./dist/index.esm.js",
+ "exports": {
+   ".": {
+     "types":   "./dist/esm/index.d.ts",
+     "import":  "./dist/esm/index.mjs",
+     "require": "./dist/cjs/index.cjs",
+     "default": "./dist/esm/index.mjs"
+   }
+ }

Expected result: node -e "require('@acme/sdk')" loads ./dist/cjs/index.cjs; node --input-type=module -e "import '@acme/sdk'" loads ./dist/esm/index.mjs.

HAZARD PREVENTION: Omitting the default fallback Error: Bundlers such as webpack 4 and older Rollup versions that do not understand import/require conditions fall through without a match and throw a resolution error. Fix: Always include "default": "./dist/esm/index.mjs" as the final condition in every branch. It acts as a safety net for any tooling that predates conditional export support.


Step 2 — Add named sub-path exports

Without explicit sub-path entries, any import like import { debounce } from '@acme/sdk/utils' throws ERR_PACKAGE_PATH_NOT_EXPORTED regardless of whether the file exists on disk.

{
  "exports": {
    ".": {
      "types":   "./dist/esm/index.d.ts",
      "import":  "./dist/esm/index.mjs",
      "require": "./dist/cjs/index.cjs",
      "default": "./dist/esm/index.mjs"
    },
    "./utils": {
      "types":   "./dist/esm/utils.d.ts",
      "import":  "./dist/esm/utils.mjs",
      "require": "./dist/cjs/utils.cjs",
      "default": "./dist/esm/utils.mjs"
    },
    "./package.json": "./package.json"
  }
}

Exporting ./package.json explicitly is required by some tooling (e.g. pkg-pr-new, certain bundler plugins) that reads the manifest via the package name.

HAZARD PREVENTION: Path prefix typos Error: Error: Cannot find module '@acme/sdk/util' — a consumer used the wrong sub-path name. Root cause: The exports map is an exact-string lookup; there is no fuzzy matching or extension inference. Fix: Always prefix every path value with ./ (package-relative). Validate that every ./dist/… file listed actually exists after your build step by running publint (see Tooling Validation below).


Step 3 — Map TypeScript declaration files

TypeScript 4.7+ with moduleResolution: "node16" or "bundler" reads exports to find .d.ts files. The types condition must come first in each branch; placing it after import causes tsc to skip it.

{
  "exports": {
    ".": {
      "types":   "./dist/esm/index.d.ts",
      "import":  "./dist/esm/index.mjs",
      "require": "./dist/cjs/index.cjs",
      "default": "./dist/esm/index.mjs"
    }
  }
}

For packages that must serve distinct declaration syntax in each format (.d.mts for ESM, .d.cts for CJS), use a nested types object:

{
  "exports": {
    ".": {
      "types": {
        "import":  "./dist/esm/index.d.mts",
        "require": "./dist/cjs/index.d.cts"
      },
      "import":  "./dist/esm/index.mjs",
      "require": "./dist/cjs/index.cjs",
      "default": "./dist/esm/index.mjs"
    }
  }
}

Pair this with the following tsconfig.json options so local development picks up the correct declarations without a full npm install round-trip:

{
  "compilerOptions": {
    "declaration": true,
    "declarationMap": true,
    "moduleResolution": "bundler",
    "paths": {
      "@acme/sdk":   ["./dist/esm/index.d.mts"],
      "@acme/sdk/*": ["./dist/esm/*"]
    }
  }
}

HAZARD PREVENTION: Wrong moduleResolution in the consumer Error: TypeScript reports Module '@acme/sdk' has no exported member 'Foo' despite the declaration file being present. Root cause: moduleResolution: "node" (the pre-4.7 default) does not read exports at all; it falls back to the bare types top-level field. Fix: Set "moduleResolution": "node16" or "bundler" in the consumer’s tsconfig.json, or add a top-level "types": "./dist/esm/index.d.ts" field as a fallback for older toolchains.


Step 4 — Add environment-specific conditions

Use browser and node conditions to route consumers to platform-optimised builds. Custom condition keys such as development and production require explicit activation via bundler config or Node’s --conditions flag — they are not applied automatically.

{
  "exports": {
    ".": {
      "types": "./dist/esm/index.d.ts",
      "browser": {
        "import":  "./dist/browser/index.mjs",
        "require": "./dist/browser/index.cjs"
      },
      "node": {
        "import":  "./dist/node/index.mjs",
        "require": "./dist/node/index.cjs"
      },
      "default": "./dist/browser/index.mjs"
    }
  }
}

For environment-specific routing between development and production bundles, see Conditional Exports for Development vs Production, which covers development/production condition keys, Vite’s resolve.conditions, and webpack’s resolve.conditionNames.

Activate custom conditions at the CLI:

# Node.js
node --conditions=development app.mjs

# Vite — vite.config.ts
resolve: { conditions: ['development', 'browser', 'module', 'import', 'default'] }

# webpack — webpack.config.js
resolve: { conditionNames: ['development', 'browser', 'import', 'require', 'default'] }

HAZARD PREVENTION: Missing browser field fallback Error: Bundlers that do not negotiate the browser condition (older webpack, unbundled scripts) resolve the node build and ship Node.js-specific APIs to the browser. Fix: Place "default": "./dist/browser/index.mjs" after all platform conditions. Never rely on a browser condition alone without a default safety net.


Tooling Validation

Run these commands after every build, and enforce them in CI before npm publish.

# Static analysis — checks file existence, condition ordering, path format
npx publint --strict

# TypeScript resolution across ESM, CJS, and bundler consumer scenarios
npx attw --pack .

# Zero-install smoke test — tsc type-checks against the published artefacts
npx tsc --noEmit --moduleResolution bundler \
  --traceResolution 2>&1 | grep "@acme/sdk"

Sample publint pass output:

✓ exports["."]["types"] resolves to ./dist/esm/index.d.ts
✓ exports["."]["import"] resolves to ./dist/esm/index.mjs
✓ exports["."]["require"] resolves to ./dist/cjs/index.cjs
✓ No issues found

Sample attw --pack . output:

┌─────────────────────────────────────────────────────────────────┐
│ @acme/sdk                                                       │
├─────────────────┬────────────────────┬──────────────────────────┤
│                 │ "moduleResolution"  │ File                     │
│ Resolution Mode │ node16             │                          │
├─────────────────┼────────────────────┼──────────────────────────┤
│ require         │ ✓ (CJS)            │ dist/cjs/index.cjs       │
│ import          │ ✓ (ESM)            │ dist/esm/index.mjs       │
│ bundler         │ ✓ (ESM)            │ dist/esm/index.mjs       │
└─────────────────┴────────────────────┴──────────────────────────┘

GitHub Actions pipeline

name: Validate Package Exports
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  validate-exports:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm run build
      - run: npx publint --strict
      - run: npx attw --pack . --ignore-rules=cjs-resolves-to-esm
      - name: Smoke test isolated consumer
        run: |
          # Install from a local tarball — replicates real npm install
          npm pack --quiet
          mkdir /tmp/test-consumer && cd /tmp/test-consumer
          npm init -y
          npm install /home/runner/work/my-repo/my-repo/acme-sdk-*.tgz
          node --input-type=module -e "import('@acme/sdk').then(m => console.log(Object.keys(m)))"
          node -e "const m = require('@acme/sdk'); console.log(Object.keys(m))"

HAZARD PREVENTION: Validating inside the source workspace Error: publint reports no issues, but consumers see ERR_PACKAGE_PATH_NOT_EXPORTED in production. Root cause: node_modules symlinks in the source workspace short-circuit normal resolution; the workspace install does not replicate what an external consumer encounters. Fix: Always smoke-test by installing the .tgz produced by npm pack into a fresh, isolated directory as shown above.


Compatibility Matrix

Environment Conditional exports browser condition types condition Custom conditions
Node.js 12.7 Partial (no require) No No No
Node.js 14.x Yes No No Yes (--conditions)
Node.js 16.x Yes No No Yes
Node.js 18+ / 20+ Yes No No Yes
webpack 4 main/module only Yes (via resolve.mainFields) No No
webpack 5+ Yes Yes No Yes (resolve.conditionNames)
Rollup 2 Partial Yes No No
Rollup 3+ Yes Yes No Yes (output.generatedCode)
Vite 3+ Yes Yes No Yes (resolve.conditions)
esbuild 0.14+ Yes Yes No Yes (--conditions)
TypeScript 4.7+ node16 Yes No Yes No
TypeScript 5+ bundler Yes No Yes No

Note: the browser condition is a bundler convention, not a Node.js standard. Node.js never activates it automatically; only bundlers that set it in their conditionNames default list will resolve it. For the full picture of how resolution differs between environments, see Browser vs Node.js Module Resolution.


Condition Matching Is First-Match-Wins

The exports map is not a lookup table. Node.js walks the conditions of an object in the order they are written, and takes the first key whose condition is active in the current environment. That single rule explains nearly every “why is my package resolving to the wrong file?” report, because it means the object’s key order is semantically significant in a format — JSON — where readers instinctively assume it is not.

How the resolver walks conditions The resolver tests each condition key top to bottom. For an ESM consumer, types is skipped by the runtime, import matches and resolution stops, so require and default are never consulted. An ESM consumer resolving "." — the walk stops early 1. "types" TypeScript only — runtime skips it 2. "import" — MATCH resolution stops here 3. "require" — never reached would have matched a CJS consumer 4. "default" — never reached the catch-all, must be last why order is the whole game "default" written first shadows every condition after it "types" written last is invisible to TypeScript's resolver

Two orderings are therefore effectively mandatory. "types" goes first, because TypeScript stops at the first matching condition just as the runtime does, and a types key placed after import is never consulted for an ESM consumer. "default" goes last, because it matches unconditionally — any condition written below it is dead configuration.

Beyond those two, the ordering that matters is between environment conditions. "browser", "node", "worker", and "deno" are all runtime conditions, and a bundler targeting the browser will typically activate both browser and import. Writing browser above import gives the browser build priority; writing it below means a browser bundler that also requests import gets the generic ESM file instead of your browser-specific one. Neither is wrong — but only one matches your intent, and the JSON gives no hint which you chose deliberately.

{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "browser": "./dist/index.browser.mjs",
      "node": {
        "import": "./dist/index.node.mjs",
        "require": "./dist/index.node.cjs"
      },
      "default": "./dist/index.mjs"
    }
  }
}

Nested condition objects, as in the node key above, are resolved the same way: the outer key must match first, then the inner walk begins. This is how a package expresses “for Node.js specifically, split by module system; for everything else, one file”.

Subpath Patterns and Encapsulation

The second job of exports — after routing conditions — is encapsulation. Before the field existed, every file inside a published package was reachable: require("my-lib/dist/internal/secret-helper") worked, and consumers depended on it. Adding an exports map closes the package: only the subpaths you list are importable, and everything else raises ERR_PACKAGE_PATH_NOT_EXPORTED.

That is a feature, and it is also the most common source of upgrade complaints, because adding exports to a package that previously had none is a breaking change for anyone doing a deep import. Two mitigations make the transition survivable. The first is a pattern export, which opens a whole directory in a controlled way:

{
  "exports": {
    ".": "./dist/index.mjs",
    "./plugins/*": "./dist/plugins/*.mjs",
    "./package.json": "./package.json"
  }
}

The * is a literal substitution, not a glob: my-lib/plugins/auth maps to ./dist/plugins/auth.mjs. It matches at any depth, so plugins/oauth/github maps to ./dist/plugins/oauth/github.mjs — which is usually what you want, but is worth knowing before you assume a single path segment.

The ./package.json entry deserves its own mention. A surprising number of tools — bundler plugins, framework build steps, test runners — read a dependency’s manifest at runtime, and an exports map that omits it breaks them with an error that names your package but originates in someone else’s tooling. Exporting it costs nothing and prevents an entire class of confusing bug report.

The second mitigation is a deprecation window. Keep the deep paths working for one major version by listing them explicitly, mark them as deprecated in the changelog, and only then remove them:

{
  "exports": {
    ".": "./dist/index.mjs",
    "./utils": "./dist/utils.mjs",
    "./dist/utils.js": "./dist/utils.mjs"
  }
}

The third entry is deliberately ugly: it maps the old internal path a consumer might have imported to the new public one, so existing code keeps working while the changelog tells people to move to my-lib/utils. Delete it in the next major.

Deciding which deep paths deserve that treatment is easier than it sounds. Public code search and your own issue tracker will surface the handful of internal paths people actually import, and in practice it is a short list dominated by two shapes: a utility module that should always have been exported, and a type-only file that consumers reached for because the root export did not re-export a type they needed. Both are signals about your public surface rather than merely migration debt — the first should become a permanent subpath, the second should be fixed by exporting the type from the entry point.

HAZARD PREVENTION

Symptom: After adding an exports map, a consumer reports ERR_PACKAGE_PATH_NOT_EXPORTED for a path that appears in your documentation, and the file plainly exists in the tarball.

Root cause: The map lists the source path rather than the published one, or lists a directory ("./utils/") rather than a pattern ("./utils/*"). Trailing-slash directory exports were removed from Node.js and are silently unusable in current versions.

Fix: Replace every trailing-slash entry with an explicit * pattern, and validate the whole map against the packed tarball with publint, which resolves each entry against the real file list rather than the working directory.


Anatomy of One Exports Entry

Reading an exports map is easier once the three nested levels have names. Every entry is a subpath, whose value is either a file or a condition object, whose values are in turn files or further condition objects.

The three levels of an exports entry Level one is the subpath key such as dot or slash feature. Level two is the condition object whose keys are matched in order. Level three is the target file path, which must exist in the published tarball. Three levels, three different kinds of mistake 1. subpath key "." or "./feature" omitting one makes it unimportable — this is the encapsulation level 2. condition object types, import, require, default order is semantic — first match wins, and default ends the walk 3. target path "./dist/index.mjs" must exist in the tarball, not merely in your working directory

Each level has its own validator: the subpath level is checked by trying the import, the condition level by attw, and the target level by publint against a packed tarball.


Guides in This Section



Back to Module System Fundamentals & Dual-Package Resolution