
4 package.json 'exports' Rules That Will Save Your Library From ESM Hell
Stop guessing why your package works in Vite but breaks in Jest by mastering the modern standard for entry points.
I spent three hours once trying to figure out why a "simple" utility library was throwing ERR_PACKAGE_PATH_NOT_EXPORTED in a standard Node environment but working perfectly in my Vite sandbox. I had my main field, I had my module field, and I had a growing sense of existential dread. What I didn't have was a grasp on the exports field. It turns out the old way of defining entry points is mostly just "vibes and prayers" for bundlers, while the exports field is a strict, powerful contract that actually dictates how your code enters the world.
If you're building a library today, you need to stop relying on main as your primary driver. Here are the four rules of the exports field that will keep your GitHub issues clear of ESM-related pitchforks.
1. The "Bouncer" Rule: If it's not listed, it doesn't exist
The biggest shift from the old main field to exports is encapsulation. In the old days, if someone installed your package, they could reach into your dist folder and import whatever private helper file they wanted: import { secret } from 'my-pkg/dist/internal-utils.js'.
Once you add an exports field, your package becomes a black box. Only the paths you explicitly define can be imported.
{
"name": "my-library",
"exports": {
".": "./dist/index.js",
"./lite": "./dist/lite.js"
}
}If a user tries to import { stuff } from 'my-library/dist/internal.js', Node will throw an error and tell them to go away. This is a feature, not a bug. It prevents users from depending on your internal file structure, meaning you can refactor your folder layout without it being a breaking change.
2. Order is everything (No, really)
Node.js parses the exports object from top to bottom. The first key that matches is the one that wins. This becomes a massive headache when you start using conditional exports for things like import (ESM) and require (CommonJS).
I see people do this all the time, and it drives them crazy:
// ❌ WRONG: The 'default' or a broad match might swallow everything
"exports": {
".": {
"default": "./dist/index.cjs",
"import": "./dist/index.mjs"
}
}In many environments, if default comes first, the consumer might grab the CJS version even if they could have used the ESM version. Always put your most specific conditions (like import or require) at the top, and your fallback (default) at the bottom.
// ✅ BETTER
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"default": "./dist/index.cjs"
}
}Pro tip: Always put types at the very top of your condition block. TypeScript is picky, and putting it first ensures it finds your definitions before it gives up.
3. Use Subpath Patterns to avoid "JSON Bloat"
If you have a library with 50 components, you probably don't want to manually map every single one in your package.json. You'll miss one, and someone will inevitably complain that my-pkg/Button works but my-pkg/Card doesn't.
The * glob pattern is your best friend here. It allows you to map an entire directory in one go.
"exports": {
"./*": "./dist/*.js"
}Now, if a user calls import 'my-pkg/components/Button', Node looks for ./dist/components/Button.js. This keeps your package.json clean and makes your library feel much more professional to consume. Just remember: the * on the left must match the * on the right.
4. Solve the "Dual Package Hazard" with conditions
The "ESM Hell" we often talk about usually refers to the Dual Package Hazard. This happens when a user ends up with two different versions of your library in their memory—one loaded via require and one via import. If your library relies on singletons or instanceof checks, your app will break in weird, silent ways.
The exports field is the only real way to mitigate this. By using the import and require keys correctly, you tell the consumer's environment exactly which file to grab based on their own module system.
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.cjs"
}
}
}Wait, why the nested types? Because ESM and CJS actually handle type definitions differently now (the .d.mts vs .d.ts saga). By nesting them, you ensure that if someone is using type: "module" in their own package.json, they get the ESM-compatible types, and CJS users get the CJS ones.
The "Gotcha" to remember
Even with exports, you should still keep the main field for a while. Tools like older versions of React Native or very old versions of Jest don't fully support exports yet. Think of main as the "emergency fallback" for the legacy world, while exports is the "source of truth" for the modern world.
Setting this up correctly the first time feels like a chore, but it’s the difference between a library that "just works" and one that generates 400-comment-long threads on StackOverflow. Be the hero. Use exports.