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.


Guides in This Section



Back to Module System Fundamentals & Dual-Package Resolution