ES Modules Logo ESModules.com

Publishing ESM Packages

Publishing ES Module packages requires getting package.json exports right. Learn how to configure your package for ESM-only or dual CJS/ESM consumers, add TypeScript types, and publish to npm and JSR.

ESM-Only Packages

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.

Dual Package Publishing

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.

The "exports" Field In Depth

Subpath Exports

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!

Conditional Exports

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)

Wildcard Patterns

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.

Self-Referencing with "imports"

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.

TypeScript Declaration Files

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.

Publishing to npm and JSR

npm Publish Workflow

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 (JavaScript Registry)

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?

  • ESM-only -- no CJS compatibility baggage
  • Publish TypeScript source directly (no build step needed)
  • Auto-generated API documentation from JSDoc/TSDoc
  • Quality score that checks types, docs, and best practices
  • Works with npm, Deno, and Bun consumers

Common Mistakes

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.

Frequently Asked Questions