Node.js Package.json Exports and Imports Explained

Modern Node.js development relies heavily on the exports and imports fields in the package.json file to manage module resolution. This article explains how these two fields provide fine-grained control over a package’s public API and internal path mapping, enabling better encapsulation, conditional loading for CommonJS and ES modules, and cleaner internal import statements.

The Significance of the exports Field

The exports field defines the public entry points of a npm package. While the traditional main field only allows you to specify a single entry point for a package, exports acts as a powerful alternative and extension.

1. Encapsulation and Security

The exports field introduces strict encapsulation. When you define the exports field, only the paths explicitly listed can be imported by external consumers. Any attempt to require or import a file not defined in exports will throw a Package import specifier is not designed error. This prevents users of your package from relying on internal, private files that might change in future updates.

2. Subpath Exports

You can define multiple entry points for different features within your package. For example:

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

A consumer can then import these specifically:

import { mainFeature } from 'my-package';
import { helper } from 'my-package/utils';

3. Conditional Exports

Conditional exports allow you to serve different files depending on how the package is loaded or the environment it is running in. This is highly useful for supporting both CommonJS (require) and ES Modules (import) simultaneously (dual-package hazard mitigation):

{
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs",
      "default": "./dist/index.js"
    }
  }
}

Node.js will automatically choose the correct file based on whether the consumer uses import or require. Other conditions include node, browser, development, and production.


The Significance of the imports Field

While exports manages what external consumers can see, the imports field manages how the package resolves paths internally. It allows you to create subpath imports, which are essentially internal aliases.

1. Eliminating Deep Relative Paths

In large codebases, importing files from deeply nested directories often results in messy relative paths like import '../../../../utils/logger.js'. The imports field solves this by letting you define internal mappings. All keys in the imports field must start with the # prefix:

{
  "imports": {
    "#utils/*": "./src/utils/*.js"
  }
}

Now, regardless of where your file is located inside the src directory, you can import utils cleanly:

import { logger } from '#utils/logger';

2. Conditional Internal Imports

Similar to exports, the imports field supports conditional mapping. This is extremely useful for writing environment-agnostic code. For example, if your package needs to run in both Node.js and the browser, you can dynamically load the correct dependency:

{
  "imports": {
    "#http-client": {
      "node": "./src/http-node.js",
      "browser": "./src/http-browser.js",
      "default": "./src/http-fallback.js"
    }
  }
}

When your internal code calls import client from '#http-client', Node.js resolves it to the correct implementation based on the runtime environment.


Key Differences Summary

Feature exports imports
Primary Purpose Defines public entry points for external consumers. Defines private path aliases for internal use.
Prefix Requirement Subpaths must start with . (e.g., ./utils). Keys must start with # (e.g., #utils).
Encapsulation Restricts external access to unlisted files. Hidden from external consumers entirely.
Conditional Support Yes (resolve based on import, require, etc.). Yes (resolve based on node, browser, etc.).