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.

Bare Specifiers: The Deepest Divergence

Relative and absolute specifiers behave similarly enough across environments to feel portable. Bare specifiers — import { z } from "zod" — do not, and the reason is architectural rather than a matter of configuration. Node.js resolves a bare specifier by walking up the directory tree looking for node_modules, then consults that package’s manifest. A browser has no node_modules and no filesystem to walk: a bare specifier is simply an invalid URL unless an import map tells the browser what it means.

One specifier, three resolution strategies The specifier import from zod resolves three ways: Node.js walks parent node_modules directories and reads the package manifest, a bundler rewrites the specifier to a real path during the build, and a native browser requires an import map entry or the load fails. import { z } from "zod" — resolved by whom? Node.js walks up for node_modules reads the exports map applies conditions extension is required fails loudly if unlisted bundler rewrites at build time honours exports, mostly tolerates missing extensions may add its own conditions nothing is left at runtime native browser no filesystem to search needs an import map entry every URL must be explicit no conditions at all bare specifier = TypeError

For a library author the consequence is concrete: a package that works when bundled can be unusable when loaded natively, and the difference is entirely in what your published files import from each other. Emitting import "./utils" instead of import "./utils.js" costs nothing under a bundler and breaks a native ESM load outright, because the browser will request /utils and get a 404 while Node.js throws ERR_MODULE_NOT_FOUND. This is why the extension discipline enforced by moduleResolution: nodenext matters even for packages whose authors only ever test through a bundler.

An import map makes the browser side workable when you need it:

<script type="importmap">
{
  "imports": {
    "my-lib": "/node_modules/my-lib/dist/index.mjs",
    "my-lib/": "/node_modules/my-lib/dist/"
  }
}
</script>
<script type="module">
  import { createClient } from "my-lib";
</script>

Note that the import map must repeat, by hand, whatever your exports map expresses — including the condition choice, since the browser applies none. Publishing a package that is pleasant to use this way means keeping the browser entry point at a stable, documented path rather than a hashed or build-dependent filename.

Testing Resolution in Every Environment You Claim to Support

Claims about environment support are cheap; a matrix that exercises them is not, but it can be small. Three probes cover the ground for most libraries, and each answers a question the others cannot.

# 1. Node.js, both module systems, against the packed tarball
node --input-type=module -e "import('my-lib').then(m => console.log('esm:', Object.keys(m)))"
node --input-type=commonjs -e "console.log('cjs:', Object.keys(require('my-lib')))"

# 2. A bundler, resolving through the browser condition
npx esbuild --bundle --platform=browser --format=esm entry.ts --outfile=/dev/null && echo "browser bundle: ok"

# 3. A native browser load, no bundler involved
npx http-server . -p 8080 &
node -e "/* open /native-test.html and assert the module executed */"

The second probe deserves a note. Passing --platform=browser activates the browser condition, and if your package declares one, this is the only check that exercises that file. A library shipping a browser-specific build that nobody ever resolves under the browser condition is shipping untested code — and because the browser build usually exists to remove Node.js built-ins, its failure mode is a bundler error about node:fs appearing in a browser bundle, reported by a consumer rather than by you.

Conditions are additive, which makes the matrix smaller than it first appears. A bundler targeting the browser typically requests ["browser", "module", "import", "default"]; Node.js ESM requests ["node", "import", "default"]; Node.js CommonJS requests ["node", "require", "default"]. Each probe above pins one of those sets, and between them every branch of a typical exports map gets executed at least once.

One final trap worth designing around: the browser field and the browser condition are different mechanisms with the same name. The legacy top-level "browser" field is a bundler convention that predates exports and can remap individual files; the "browser" key inside exports is a resolution condition. Bundlers consult both, with exports taking precedence when present, so a package carrying a stale top-level browser field alongside a modern exports map has two sources of truth that can disagree. Keep the field only if you still support tooling that ignores exports, and make sure both point at the same file when you do.


The Condition Sets Each Environment Requests

Every probe above is really asking the same question — which condition keys are active? — so it is worth having the answer in one place. Conditions are requested as an ordered set, and the first key in your exports object that appears in that set wins.

Which conditions each environment activates Node ESM requests node, import and default. Node CommonJS requests node, require and default. A browser-targeting bundler requests browser, module, import and default. A native browser load requests no conditions at all because it never reads the manifest. Condition sets, in the order they are matched Node.js, import node → import → default Node.js, require node → require → default browser bundler browser → module → import → default native browser no conditions — the manifest is never read

The last row is the one to design around. A package meant to be usable without a build step needs a stable, documented file path, because no amount of condition engineering reaches a runtime that never opens package.json.


Pages in This Section


↑ Module System Fundamentals & Dual Package Resolution