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

TermDefinition
ESMECMAScript Module: standardized JavaScript module format using native import & export syntax
CJSCommonJS: Node.js’s legacy module format using require() to import & module.exports to export
Package entry pointA possible entry path to access a package (e.g. pkg-a or pkg-a/file)
Package subpathThe 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:

  1. Subpath exports: Packages can define multiple entry points, allowing only specific files to be exposed while blocking access to package internals.

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

pkg-a/package.json
{
  "name": "pkg-a",
  "main": "./file-a.js", // For legacy Node consumers
  "exports": {
    ".": "./file-a.js", // Loaded via `pkg-a`
    "./subpath-entry": "./file-b.js" // Loaded via `pkg-a/subpath-entry`
  }
}
A package using subpath exports to define multiple entry points

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.

→ See example code

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.

→ See example code

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.

→ See example code

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:

Show types

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.

package.json
{
  "name": "pkg-a",
  "exports": {
    ".": "./dist/index.js", // import 'pkg-a'
    "./entry-a": "./dist/entry-a.js", // import 'pkg-a/entry-a'
    "./entry-b": "./dist/dir/entry-b.js" // import 'pkg-a/entry-b'
  }
}
All subpath exports object keys must start with .

Examples

Follow along with the interactive demo

→ Play with subpath exports on StackBlitz

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.

Show code
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 ./.

Show code
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.
Show code
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.

Show code
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.

Show code

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"

→ Demo of this behavior

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:

pkg-a/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:

app/load-pkg-a.js
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:

Terminal
$ 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

→ Play with conditional exports on StackBlitz

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.

Show code
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.

Show code

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:

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.

Show example

References


Thanks for reading! Hope you'll stick around.
— Hiroki Osame
Open source Engineer. Living in Tokyo. Working at Square.