The simplest approach for new packages in 2026. Set "type": "module" and use the "exports" field to define your public API:
// package.json - ESM-only package
{
"name": "my-esm-package",
"version": "1.0.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": ["dist"],
"engines": {
"node": ">=18"
}
}
Why ESM-Only?
Node.js 23+ can require() ES Modules natively, so CJS consumers can still use your package without you shipping a separate CJS build. This eliminates the need for dual publishing in most cases.
If you need to support older Node.js versions (before 22) or want maximum compatibility, you can ship both ESM and CJS builds using conditional exports:
// package.json - Dual ESM + CJS package
{
"name": "my-dual-package",
"version": "1.0.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"default": "./dist/index.js"
}
},
"main": "./dist/index.cjs",
"files": ["dist"]
}
// Build both formats with a bundler like tsup:
// tsup src/index.ts --format esm,cjs --dts
Dual Package Hazard:
When a package is available as both ESM and CJS, it can be loaded twice in the same application -- once via import and once via require(). This creates two separate instances with separate state. Singletons, caches, and registries will not be shared. Use stateless utilities or document the limitation clearly.
Define multiple entry points for your package. Only paths listed in exports are importable -- all other files are private:
// package.json - Subpath exports
{
"name": "my-toolkit",
"type": "module",
"exports": {
".": "./dist/index.js",
"./utils": "./dist/utils.js",
"./math": "./dist/math.js",
"./package.json": "./package.json"
}
}
// Consumer usage:
import toolkit from 'my-toolkit'; // Main entry
import { debounce } from 'my-toolkit/utils'; // Subpath
import { sum } from 'my-toolkit/math'; // Subpath
// This will FAIL - not listed in exports:
import internal from 'my-toolkit/dist/internal.js'; // Error!
Serve different code depending on the environment. Conditions are checked in order -- first match wins:
// package.json - Conditional exports
{
"name": "my-universal-lib",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"node": {
"import": "./dist/node/index.js",
"require": "./dist/node/index.cjs"
},
"browser": "./dist/browser/index.js",
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"default": "./dist/index.js"
}
}
}
// Condition resolution order:
// 1. "types" - TypeScript declaration files
// 2. "node" - Node.js runtime (nested: import/require)
// 3. "browser" - Browser bundlers (webpack, Vite, etc.)
// 4. "import" - ESM fallback (import statement or import())
// 5. "require" - CJS fallback (require() call)
// 6. "default" - Universal fallback (always last)
Use * to map entire directories without listing every file:
// package.json - Wildcard subpath exports
{
"exports": {
".": "./dist/index.js",
"./components/*": "./dist/components/*.js",
"./utils/*": "./dist/utils/*.js"
}
}
// Consumer usage:
import { Button } from 'my-lib/components/Button';
import { format } from 'my-lib/utils/format';
Entry Point Restrictions:
The "exports" field completely replaces "main" and "module" for any runtime that supports it (Node 12.11+). Files not listed in exports are inaccessible to consumers. This is intentional -- it lets you restructure internal files without breaking public APIs. Always include "./package.json": "./package.json" if consumers need to read your package.json.
The "imports" field in package.json lets you define private import aliases using the # prefix. These are internal to your package and not visible to consumers:
// package.json - Self-referencing imports
{
"name": "my-package",
"type": "module",
"imports": {
"#utils": "./src/utils/index.js",
"#db": {
"node": "./src/db/node-client.js",
"default": "./src/db/browser-client.js"
},
"#config": {
"development": "./src/config/dev.js",
"production": "./src/config/prod.js",
"default": "./src/config/dev.js"
}
}
}
// In your source code:
import { debounce } from '#utils'; // Resolved internally
import { query } from '#db'; // Platform-conditional
import { settings } from '#config'; // Environment-conditional
// Benefits:
// - No deep relative paths (../../utils/index.js)
// - Conditional platform mappings built into the specifier
// - Private to your package - consumers cannot use # imports
// - Works with all modern runtimes and bundlers
Key Rule:
All "imports" entries must start with #. This distinguishes them from package specifiers and prevents conflicts with npm package names. Unlike "exports", the "imports" field is only for code inside your own package.
Generate .d.ts files so TypeScript consumers get full type support. The "types" condition must always be listed first in exports:
// tsconfig.json - Generate declarations
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true
},
"include": ["src"]
}
// Build: tsc --declaration
// package.json - "types" MUST be first in each export
{
"name": "my-typed-lib",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
},
"./utils": {
"types": "./dist/utils.d.ts",
"import": "./dist/utils.js",
"default": "./dist/utils.js"
}
},
"files": ["dist"]
}
declarationMap:
Enable declarationMap: true in tsconfig to generate .d.ts.map files. This lets consumers "Go to Definition" in their IDE and navigate to your original TypeScript source instead of the generated declaration file.
The standard workflow for publishing an ESM package to the npm registry:
# Build your package
npm run build
# Preview what will be published
npm pack --dry-run
# Verify the exports work correctly
npx publint # Lint package.json exports
npx attw --pack . # Check TypeScript types (Are The Types Wrong)
# Publish with provenance (recommended)
npm publish --provenance
# Provenance attestation proves:
# - Which repository the code came from
# - Which CI/CD workflow built it
# - The exact commit that was published
# Requires: GitHub Actions, GitLab CI, or supported CI provider
JSR is an ESM-native package registry created by the Deno team. It supports TypeScript natively, generates documentation automatically, and works with npm, Deno, and Bun:
// deno.json (or jsr.json) - JSR package config
{
"name": "@myorg/my-package",
"version": "1.0.0",
"exports": "./mod.ts"
}
# Publish to JSR
npx jsr publish # From any runtime (npm/Node.js)
deno publish # From Deno
# Install JSR packages in any runtime
npx jsr add @myorg/my-package # npm project
deno add jsr:@myorg/my-package # Deno project
bunx jsr add @myorg/my-package # Bun project
Why JSR?
Missing "types" first in exports
TypeScript checks export conditions in order. If "import" comes before "types", TypeScript will try to resolve types from the .js file and fail. Always put "types" as the first condition.
Missing file extensions in source
ESM requires full file extensions in imports. Writing import { foo } from './utils' instead of import { foo } from './utils.js' will fail at runtime in Node.js. This is the most common issue when migrating from CJS to ESM.
Forgetting the "files" array
Without "files" in package.json, npm publishes your entire project directory (minus .gitignore patterns). This can include source code, tests, configs, and unnecessary files. Always specify "files": ["dist"] to only ship built output.
Not testing with different consumers
Your package may work in your project but fail for consumers. Use npx publint to lint your exports field, npx attw --pack . (Are The Types Wrong) to verify TypeScript types, and test with both import and require() before publishing.
For new packages in 2026, ESM-only is recommended. Node.js 23+ can require() ESM modules, so CJS consumers can still use your package. Dual publishing adds complexity and risks the dual package hazard.
The exports field defines the public API of your package, controlling which files consumers can import. It supports conditional exports for different environments and replaces the older main/module fields.
Use the "types" condition in your exports field, always listed first. Generate .d.ts files with tsc --declaration and include declarationMap for source navigation.
JSR (JavaScript Registry) is an ESM-native package registry created by the Deno team. It supports TypeScript natively, generates documentation automatically, and works with npm, Deno, and Bun.