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.). |