Without understanding exactly where the browser’s resolution algorithm stops and Node.js’s begins, a package that works perfectly in one environment will throw a TypeError or ERR_MODULE_NOT_FOUND the moment it runs in the other — and the failure is silent until it reaches production. This page covers both algorithms in detail, shows how to wire up the exports field to serve each runtime correctly, and provides a CI strategy that catches regressions before they ship.

Prerequisites

Before working through the steps below, confirm your environment matches these requirements:

How the Two Algorithms Diverge

The gap is not a quirk — it is a deliberate design difference between two specifications written years apart.

Browser ESM (WHATWG Fetch + HTML spec) resolves only explicit URLs and import maps. A bare specifier such as import { parse } from 'my-lib' throws a TypeError: Failed to resolve module specifier unless an <script type="importmap"> resolves it first. There is no node_modules traversal, no package.json reading, and no implicit extension appending — a relative import missing .js is a 404, not a fallback.

Node.js ESM (Node.js own resolver, not the browser spec) walks node_modules directories upward from the importing file, reads the target package’s package.json for exports, type, and main, and enforces explicit extensions on relative imports just as the browser does. For CommonJS (require()), it still appends .js, .json, and .node implicitly and ignores the exports field if none is present.

Bundlers (Vite, Webpack, esbuild) emulate Node.js resolution at build time but produce browser-compatible output. This makes everything appear to work locally while hiding the fact that the published package has never been tested against the actual browser resolver.

The diagram below shows the decision path each environment takes for a bare specifier versus a relative import:

Module Resolution Algorithm Comparison A flow diagram showing how browser ESM, Node.js ESM, and Node.js CJS each handle bare specifiers and relative imports differently. Browser ESM Node.js ESM Node.js CJS BARE SPECIFIER Check importmap no match → TypeError Walk node_modules → read exports field Walk node_modules → exports or main fallback RELATIVE IMPORT Fetch exact URL no .js → 404 Explicit ext required no .js → ERR_MODULE_NOT_FOUND Auto-appends .js / .json implicit extension works package.json TYPE Not read by browser "type":"module" required for .js ESM Defaults to CJS regardless

The critical takeaway: browser ESM and Node.js ESM share the rule that relative imports need explicit extensions, but that is where the similarity ends. Bare specifiers are handled by completely different mechanisms.

Canonical Configuration: The Exports Map

The single most important configuration decision for a dual-target package is the ordering of conditions inside the exports field. Node.js evaluates conditions top-to-bottom and stops at the first match, which makes the order load-bearing:

{
  "name": "@scope/dual-target-lib",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "node": {
        "import": "./dist/node/index.mjs",
        "require": "./dist/node/index.cjs"
      },
      "browser": "./dist/browser/index.mjs",
      "default": "./dist/browser/index.mjs"
    },
    "./utils": {
      "types": "./dist/utils.d.ts",
      "node": "./dist/node/utils.mjs",
      "browser": "./dist/browser/utils.mjs",
      "default": "./dist/browser/utils.mjs"
    }
  },
  "imports": {
    "#utils/*": "./src/utils/*.js"
  }
}

Inline comment guide:

  • "types" first — TypeScript resolves the type declaration before any runtime condition.
  • "node" before "browser" — Node.js matches its own condition; if "browser" appeared first, Node.js would load DOM-dependent code and produce ReferenceError: document is not defined.
  • "default" last — catches any environment not covered by an explicit condition (Deno, Bun, non-bundled CDN usage).
  • "imports" with #-prefixed keys — subpath imports are a Node.js-only feature; browsers cannot use them without a bundler step.

Step-by-Step Implementation

Step 1 — Add Explicit Extensions to All Relative Imports

Both browser ESM and Node.js ESM require explicit extensions. Running tsc with "module": "NodeNext" will catch missing extensions as type errors before the code ships. For large codebases, automate the fix:

#!/usr/bin/env bash
# Append .js to extensionless relative imports in TypeScript source files
find src -name "*.ts" -o -name "*.mts" | while read -r file; do
  # Match from './' or '../' that have no extension and add .js
  sed -i "s|from '\(\./[^']*\)'\([^.]\)|from '\1.js'\2|g" "$file"
done

Expected output: no output on success. Diff each file after to confirm changes.

Step 2 — Author the exports Map

Replace any top-level "main", "module", or "browser" fields with a single "exports" object. The legacy "browser" top-level field is a Webpack/Browserify-era convention that modern Node.js ignores entirely.

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

Expected diff: package.json loses "main", "module", and "browser" top-level keys.

Step 3 — Align TypeScript moduleResolution

TypeScript’s paths and baseUrl are compile-time only — they instruct the type checker where to find source files but write nothing into emitted JavaScript. If you rely on them for runtime resolution, Node.js will throw ERR_MODULE_NOT_FOUND. Set moduleResolution to match your actual runtime target with path mapping aligned to your build tool:

{
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "outDir": "./dist"
  }
}

Use rollup-plugin-alias, vite-tsconfig-paths, or tsup’s built-in aliasing to rewrite paths aliases at build time so they never appear in published output. moduleResolution: "bundler" is valid inside a browser-only application but must never appear in a library’s published tsconfig.json because consumers inheriting it will get incorrect resolution behaviour.

Expected output: tsc --noEmit exits 0 and no TS2835 (relative-import-needs-extension) errors.

Step 4 — Provide an Import Map for Browser Consumers

Browsers cannot read node_modules or package.json. An import map bridges the gap, letting bare specifiers work without a bundler. When building for browser consumers, publish an import map alongside the package or document it in your README:

<script type="importmap">
{
  "imports": {
    "@scope/dual-target-lib": "/node_modules/@scope/dual-target-lib/dist/browser/index.mjs",
    "@scope/dual-target-lib/utils": "/node_modules/@scope/dual-target-lib/dist/browser/utils.mjs"
  }
}
</script>
<script type="module">
  import { parse } from '@scope/dual-target-lib';
  console.log(parse);
</script>

Note that "imports" (subpath imports in package.json) and <script type="importmap"> are entirely separate features sharing confusingly similar names. The package.json "imports" field maps #-prefixed private specifiers inside a single Node.js package; the browser import map resolves bare specifiers for all scripts in a page.

Step 5 — Validate with Vite Library Mode

When building with Vite in library mode, preserveModules keeps the original file structure intact, which is critical for downstream tree-shaking to work. Without it, Vite produces a single bundle that eliminates the per-module boundaries the bundler needs:

import { defineConfig } from 'vite';
import { resolve } from 'path';

export default defineConfig({
  build: {
    lib: {
      entry: resolve(__dirname, 'src/index.ts'),
      formats: ['es'],
      fileName: 'index',
    },
    rollupOptions: {
      external: [/^@scope\//],
      output: {
        preserveModules: true,
        preserveModulesRoot: 'src',
      },
    },
  },
});

To test condition matching locally before publishing — simulating how a bundler or test runner would activate the browser condition:

node --conditions=browser -e "import('@scope/dual-target-lib').then(m => console.log(Object.keys(m)))"

And to verify what Node.js resolves without running the full app:

node -e "console.log(require.resolve('@scope/dual-target-lib'))"

HAZARD PREVENTION: Singleton bifurcation — placing "browser" before "node" in the exports map causes Node.js to load the browser build. If that build creates a singleton (a class registry, an event emitter, a global store), Node.js will create two separate instances: one from the browser artifact and one from the node artifact. The two instances do not share state. The fix is to reorder conditions so "node" appears first. See Navigating the Dual-Package Hazard for the full mitigation pattern.

HAZARD PREVENTION: Missing "types" condition — if "types" is absent from the exports map, TypeScript 5+ will not find declaration files for named subpath exports and will emit TS2307: Cannot find module. Add a "types" key as the very first condition in every export entry, pointing to the corresponding .d.ts file.

Tooling Validation

Run these commands before every publish to catch resolution issues before consumers do:

# Check exports map correctness and file existence
npx publint

# Verify TypeScript types resolve correctly for both ESM and CJS consumers
npx attw --pack .

# Type-check without emitting
npx tsc --noEmit

# Confirm Node.js resolves the correct file under each condition
node --conditions=node -e "import('@scope/dual-target-lib').then(m => console.log('node:', Object.keys(m)))"
node --conditions=browser -e "import('@scope/dual-target-lib').then(m => console.log('browser:', Object.keys(m)))"

Sample publint pass output:

✔  "." export: "types" resolves to dist/index.d.ts
✔  "." export: "node.import" resolves to dist/node/index.mjs
✔  "." export: "node.require" resolves to dist/node/index.cjs
✔  "." export: "browser" resolves to dist/browser/index.mjs
✔  "." export: "default" resolves to dist/browser/index.mjs
No issues found.

Sample publint failure output (condition ordering error):

✖  "browser" condition appears before "node" — Node.js will match "browser" first.
    Fix: reorder so "node" appears before "browser".

CI Validation and Environment Parity

A CI matrix that tests only one Node.js version against one module format will miss most real-world failures. The minimum useful matrix covers three Node.js LTS versions and two browser engines:

name: Module Resolution Validation
on: [push, pull_request]

jobs:
  matrix-test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node: [18, 20, 22]
        browser: [chromium, firefox]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
          cache: 'npm'
      - run: npm ci --ignore-scripts
      - run: npm run build
      - run: npx publint
      - run: npx attw --pack .
      - run: npx playwright install ${{ matrix.browser }} --with-deps
      - run: npm run test:resolution-snapshot
        env:
          BROWSER: ${{ matrix.browser }}

Snapshot-test resolved module paths so that a silent fallback change (such as a bundler switching from "browser" to "default" on an upgrade) is caught automatically and not discovered by a downstream consumer:

# Generate a deterministic snapshot of what each condition resolves to
node --input-type=module <<'EOF'
const { resolve } = await import('import-meta-resolve');
for (const cond of ['node', 'browser', 'default']) {
  const url = await resolve('@scope/dual-target-lib', import.meta.url, [cond]);
  console.log(`${cond}: ${url}`);
}
EOF

Compatibility Matrix

Feature Node 18 Node 20 Node 22 Webpack 5 Vite 5 esbuild 0.21
exports map ✅ Full ✅ Full ✅ Full ✅ via resolve.conditionNames ✅ via resolve.conditions ✅ via --conditions
"node" condition
"browser" condition ✅ with --conditions=browser ✅ automatic ✅ automatic ✅ automatic
"types" condition TypeScript only TypeScript only TypeScript only N/A N/A N/A
Subpath "imports" (#) ✅ via alias Partial Partial
Import maps N/A N/A N/A N/A ✅ via importmap plugin
Implicit ext (CJS only) .js/.json/.node .js/.json/.node .js/.json/.node N/A N/A N/A

Migration Strategy for Legacy Single-Format Packages

The safest migration from a legacy single-format package to a dual-target one follows three ordered steps to avoid leaving any consumers broken mid-migration:

  1. Add explicit .js extensions to all relative imports in source files (script above).
  2. Replace the deprecated top-level "browser" field with "exports" conditions. Keep a "main" key pointing to the CJS artifact only if Node.js 12 support is required; drop it otherwise.
  3. Add "type": "module" to package.json and rename all CommonJS files to .cjs.

For projects that still need CJS consumers during the transition, keep a "require" condition pointing to a .cjs artifact and remove it only after confirming all known consumers have migrated. Resolving type:module conflicts in legacy projects covers the safe incremental rollout pattern, including how to handle the ERR_REQUIRE_ESM error that surfaces when CJS consumers try to require() a pure-ESM package.

Pages in This Section


↑ Module System Fundamentals & Dual Package Resolution