Take control of how your npm package is accessed with the package.json exports
field
Imagine you’re developing an npm package…
You want to offer multiple entry points, but also restrict access to internal files. You need to support both CJS and ESM, include type definitions, and perhaps even ensure browser compatibility. How do you manage all these requirements?
In earlier Node.js versions, packages used the main
field in package.json
to define a single entry point. While simple, this approach had limitations: it only allowed one entry point and left all files in the package accessible, with no way to protect internal files. As the ecosystem evolved—especially with the rise of ESM and the demand for multi-format packages—this approach quickly became inadequate.
Terminology
Term | Definition |
---|---|
ESM | ECMAScript Module: standardized JavaScript module format using native import & export syntax |
CJS | CommonJS: Node.js’s legacy module format using require() to import & module.exports to export |
Package entry point | A possible entry path to access a package (e.g. pkg-a or pkg-a/file ) |
Package subpath | The path within a package accessible to users (e.g., pkg-a/this/is/subpath.js → /this/is/subpath.js ). |
What’s the exports
field?
Introduced in Node.js v12.7.0 (July 2019), the exports
field in package.json
addresses these needs with two core features:
Subpath exports: Packages can define multiple entry points, allowing only specific files to be exposed while blocking access to package internals.
Conditional exports: Packages can toggle entry points to resolve to different files for different environments (e.g., Node.js vs. browsers) and module types (e.g., CJS vs. ESM).
Since then, exports
has become widely supported across major JavaScript tools and build systems, such as TypeScript, Deno, Vite, Webpack, esbuild, etc.
Benefits of exports
Protecting internal files
Previously, consumers could import any file in a package, even internal ones. This made it hard for package maintainers to update or restructure the package, as they couldn’t tell if users relied on these internal files. With exports
, maintainers can explicitly define which files are accessible, establishing a clear public API and preventing unintended imports of internal files. This helps maintainers manage updates without risking breakage for users.
Mapping subpaths to dist
directory
JavaScript projects often compile code from the src
directory into dist
, resulting in imports like import foo from 'pkg-a/dist/util'
. Package authors may prefer not to have dist
in the import path for a simpler API, but outputting files to the package root requires complex publishing steps that may pollute the development environment.
With the exports
field, package subpaths can map directly inside the dist
directory, allowing consumers to use cleaner imports like import foo from 'pkg-a/util'
without complex publishing scripts for maintainers.
Multi-format packages
Today, packages often face the challenge of supporting multiple environments—Node.js, browsers, ESM, CJS, and TypeScript definitions. The exports
field in package.json
allows you to specify different files for each environment and module format. This ensures compatibility and optimizes imports by only including what’s relevant for each target.
How to use exports
Schema
To understand the structure of the exports
field, here’s a TypeScript schema that illustrates how it’s shaped. This schema provides an overview of the different paths and conditions you can define:
Subpath exports
Subpath exports allows you to define the entry points to your package and map them to file paths within the package.
Syntax
To define multiple entry points, the exports
field can be set to a subpaths object, where each key starts with a .
. The .
key indicates the main package entry, and subpaths start with ./
. The keys can map to a file path within the package, or a conditions object which we’ll talk about later.
Examples
Follow along with the interactive demo
Single entry point
The simplest use of the exports
field is a string that points to your package’s entry file. While this is similar to the main
field, there’s a notable distinction: as soon as you use exports
, it black-boxes your package. This means no subpaths (not even package.json
) are accessible by default unless explicitly specified.
Multiple entry points
To define multiple entry points, set exports
to a subpaths object—an object where each key starts with a .
and the value is a relative path to a file within the package. As mentioned above, the .
key indicates the main package entry, and subpaths start with ./
.
Exposing all package files
You can make all files in your package accessible by using subpath patterns (*
). This pattern captures any string from the subpath, including nested paths, and maps it to the target file path. With this setup, users can import any file in the package by referencing its path.
Warning
- The
*
character is not glob syntax; unlike globs, it captures nested paths. - Be careful with exposing all files as it will allow users to depend on internal files, making it risky to make updates in the future.
Exposing a subset of package files
To only expose a specific directory, place the subpath pattern in the subdirectory. This approach allows consumers to import files from only the specified directory. Additionally, you can block access to subpaths by mapping them to null
.
Mapping subpaths
The exports
field’s subpaths object lets you define arbitrary subpaths as keys mapped to a file path in the package. This enables you to expose deeply nested paths with simpler, shorter subpaths.
Subpaths resolution Advanced
Node resolution behavior changes
In CJS resolution, implicit file extensions are automatically resolved. However, when using an exports
object with subpath patterns ("./*": "./dist/*"
), this automatic lookup is disabled, requiring explicit file extensions.
In contrast, while ESM resolution requires explicit file extensions, subpath patterns can also allow them to be omitted: "./*": "./dist/*.js"
Subpath pattern priority
Exact subpaths (e.g., pkg-a/entry-a
) are straightforward to resolve. However, subpath patterns (*
) can create ambiguity when multiple patterns match the same subpath.
In such cases, the resolution order follows this algorithm, which compares the specificity of the subpaths. For a practical example, see this implementation.
To avoid ambiguity, always aim to be as specific as possible.
Conditional exports Intermediate
Conditional exports is an incredibly powerful feature. They enable your package to dynamically load different files based on conditions provided by the consumer. Leveraging this feature allows you to optimize your package for various environments.
As an introductory example, let’s say you want your package entry-point to toggle between two different files. To do this, set a conditional export object in the exports
field of your package.json
:
{
"name": "pkg-a",
"exports": {
"condition-a": "./file-a.js",
"condition-b": "./file-b.js"
}
}
When importing this package, the file that gets loaded depends on the condition provided at runtime:
import foo from 'pkg-a' // ❓ Can be file-a.js or file-b.js based on the condition provided
Passing in conditions
If you’re using Node.js, you can specify the condition with the --conditions, -C
flag. For example, this will load file-a.js
because we’re specifying condition-a
:
$ node --conditions=condition-a ./load-pkg-a.js
If you’re using a bundler, you can pass in conditions in the configuration. For example, with Vite, you can pass it in resolve.conditions
(there’s a list of docs for tools that support conditions below).
If no condition is provided, this will fail to resolve and throw an error, as no default
path is defined.
Note
The runtime or bundler usually sets default conditions that match the target environment. For example, web development tools like Vite and Webpack will incorporate the browser
condition by default.
Syntax
In contrast to the subpaths object, a conditional export object is any object inside the exports
field where the keys don’t all start with .
. The conditions are ordered by priority and Node.js resolves to the first matching entry. (This may feel unintuitive as objects are technically unordered in JavaScript.) The objects can also be nested to specify a combination of conditions necessary for resolving a file.
Examples
Follow along with the interactive demo
Targeting Node.js + Browser
The exports
field can define an entry point that adapts to either Node.js or browser environments. In the Node.js runtime, default conditions used for resolution include node
, default
, and the import types (import
for ESM, require
for CJS).
The priority of the conditions is deferred to the key order in the package’s conditional exports object.
Targeting Node.js ESM + CJS + Type definitions
You can use the import
and require
conditions to target ESM and CJS environments. And by nesting conditional export objects, you can further specify additional conditions required to resolve a file. Since TypeScript supports the type
condition to resolve type definitions, you can nest the types
condition for each format to provide format-specific type definitions.
Fallback array Advanced
Warning
This feature is rarely used and was designed for future compatibility.
Any path in the exports
field can be interchanged with an array of fallback paths. However, the fallback array does not act as a fallback for missing files. Instead, the resolver skips unprocessable paths (like unsupported protocols) but resolves to the first valid path it encounters—even if that path may not exist. This behavior is designed to minimize disk access, improving performance and ensuring efficient resolution in future network-based environments (spec, confirmation).
For example, a hypothetical node-test-polyfill
package could define its exports like this:
{
"name": "node-test-polyfill",
"exports": ["node:test", "./node-test-polyfill.js"]
}
In Node.js, the first path node:test
resolves because the path is syntactically valid for Node. Other environments like Deno or bundlers will fall back to ./node-test-polyfill.js
as it does not recognize the node:
protocol.
Ecosystem support
The exports
field is widely supported by modern build tools and bundlers, making it a highly compatible option for defining package entry-points. Here are some popular tools that support exports
:
- Vite
- Rollup via
@rollup/plugin-node-resolve
- esbuild
- Webpack
- Parcel
- Deno
- TypeScript
- Modules Reference for package authors
tsconfig.json#compilerOptions.customConditions
for consumers
Tools for working with exports
Validation
publint by Bjorn Lu (Vite team member)
A website & CLI linter for npm packages that checks package.json
for valid entry points, format consistency, and best practices in ESM/CJS usage. It verifies file existence, type exports, browser compatibility, and flags deprecated fields, ensuring packages are well-structured and compliant with Node.js standards.
Are the types wrong? by Andrew Branch (TypeScript team member)
A website & CLI tool to detect issues with TypeScript types in npm packages, focusing on ESM module resolution. It helps identify problems like missing types, incorrect exports, or unexpected syntax across node10
, node16
, and bundler
resolution modes, ensuring your package works seamlessly with TypeScript.
Bundlers
pkgroll
A zero-config package bundler that parses the exports
field to automatically determine how to bundle your package.
Utils
resolve-pkg-maps
A utility for resolvers to support exports
and imports
fields in package.json
, handling complex mappings and conditions. Tested against Node.js for 100% accuracy.
pkg-entry-points
This tool exhaustively lists all possible entry points of a package by generating combinations of conditions—useful for debugging and seeing the different ways you can access a package. Used on pkg-size.dev to pick entry points for bundling.
get-conditions
A utility to retrieve the active conditions in the current Node.js runtime environment. Node currently doesn’t offer an API to access this information.
Aside: Imports map
There’s also an imports
field in package.json
which allows internal-only aliases within a package, similar to exports
but scoped for use solely within the package itself. The aliases must be prefixed with #
, and like exports
, it supports conditional mappings.