Skip to content
ExponaBLOG
JAVASCRIPT & TYPESCRIPT · DAY 087 / 100

Publishing dual packages: a hands-on guide for engineers

ESM and CommonJS without headaches.

Expona Admin7 min read

Publishing dual packages: why it matters

If you've built a library and released it to npm, you've likely faced a choice: support CommonJS, ESM, or both? Publishing dual packages—offering both ESM and CommonJS—is the pragmatic middle ground that keeps your users happy whether they're running legacy Node codebases or modern unbundled applications. The challenge isn't technical anymore; it's configuration, testing, and knowing when to say "ESM only" is the better call.

Over the past 5.5 years working on SDKs, utilities, and frameworks at various scales, I've shipped dual packages dozens of times. This guide distills what actually works in production, what breaks, and how to avoid two weeks of debugging consumer issues.

Understanding the dual-package landscape

Modern Node and bundlers understand both module formats. CommonJS dominated until ES modules arrived; now ESM is the standard, but CommonJS remains entrenched in backend tools, build systems, and legacy applications.

A dual package delivers:

  • An ESM build (typically in dist/index.mjs or dist/index.js) for modern environments
  • A CommonJS build (typically in dist/index.cjs) for Node 12+ and bundlers that can't tree-shake ESM
  • Separate type definitions for each format

The package.json exports field orchestrates which build your consumers get, based on their import syntax and environment.

Use the exports field

The exports field is your contract with consumers. It explicitly maps import patterns to file paths and conditions.

Here's a real-world example from a CRM API client I maintained:

typescript
{
  "name": "@company/crm-sdk",
  "version": "2.1.0",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.mjs",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    },
    "./types": {
      "types": "./dist/types.d.ts"
    }
  },
  "files": ["dist"]
}

Key details:

  • "type": "module" tells Node your source is ESM (optional, but recommended)
  • "main" and "module" are legacy fallbacks; tooling prefers exports
  • "types" condition must come first in the object for TypeScript resolver compatibility
  • "import" condition matches import statements and import() dynamic calls
  • "require" condition matches require() calls

Consumers then use your package naturally:

typescript
// ESM user gets dist/index.mjs
import { Client } from '@company/crm-sdk';

// CommonJS user gets dist/index.cjs
const { Client } = require('@company/crm-sdk');

Bundlers (webpack, Rollup, esbuild) read exports first and respect the conditions. Node.js 12.20+ does too, so you're safe on supported runtimes.

If you're exporting multiple entry points, add them all:

typescript
{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    },
    "./client": {
      "types": "./dist/client.d.ts",
      "import": "./dist/client.mjs",
      "require": "./dist/client.cjs"
    },
    "./adapters": {
      "types": "./dist/adapters.d.ts",
      "import": "./dist/adapters.mjs",
      "require": "./dist/adapters.cjs"
    }
  }
}

Provide types for both formats

TypeScript doesn't care whether your code is ESM or CommonJS—it reads .d.ts files. But the resolution strategy differs slightly.

The simplest approach: generate a single set of type definitions and reference them in both conditions:

typescript
{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  }
}

Most packages work fine this way. Your tsconfig during development targets ESM syntax, and TypeScript figures out the rest during consumption.

For edge cases—when your CommonJS build needs different type signatures than ESM—generate separate type files. I've done this with a ride-hailing SDK that had async iterators in ESM but streams in CommonJS:

typescript
{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": {
        "types": "./dist/index.esm.d.ts",
        "default": "./dist/index.mjs"
      },
      "require": {
        "types": "./dist/index.cjs.d.ts",
        "default": "./dist/index.cjs"
      }
    }
  }
}

In practice, this nested syntax rarely works smoothly across all TypeScript versions. If you need different types, consider two separate type files at the top level and document which consumers should import which.

My recommendation: start with one .d.ts file. Separate them only if TypeScript reports incompatible signatures.

Test imports in both environments

Exports are useless if you don't verify they actually work. I've shipped packages where ESM imports worked flawlessly but CommonJS broke silently in CI.

Create a test directory to validate both formats:

typescript
// test/esm.mjs
import { Client } from '@company/crm-sdk';

const client = new Client({ apiKey: 'test' });
console.log('ESM import successful:', client.constructor.name);
typescript
// test/cjs.cjs
const { Client } = require('@company/crm-sdk');

const client = new Client({ apiKey: 'test' });
console.log('CommonJS require successful:', client.constructor.name);

Add these to your test script or CI pipeline:

typescript
{
  "scripts": {
    "test": "jest",
    "test:dual": "npm test && node test/esm.mjs && node test/cjs.cjs"
  }
}

Even better, use a real bundler or module loader to catch issues early. I test CommonJS builds with esbuild in Node mode, and ESM builds with native Node imports:

typescript
// In your build or test script
import * as esbuild from 'esbuild';

// Test ESM with native Node
await import('./dist/index.mjs');

// Test CommonJS with esbuild
await esbuild.build({
  entryPoints: ['test/cjs.cjs'],
  bundle: true,
  platform: 'node',
  external: ['@company/crm-sdk'],
});

Also test that your types resolve correctly:

typescript
// test/types.ts
import { Client } from '@company/crm-sdk';
import * as Namespace from '@company/crm-sdk';

const client: Client = new Client({ apiKey: 'test' });
const namespace: typeof Namespace = Namespace;

export {};

Run this with tsc --noEmit in CI. If types don't resolve, you'll catch it before publishing.

Consider ESM-only for new packages

Not every package needs dual support. If you're starting fresh in 2024, ESM-only is often the right call.

ESM-only packages are simpler to build, smaller to distribute, and easier to maintain. They force users to either upgrade or use a compatibility layer (which they should).

Ship ESM-only if:

  • Your target audience is modern browsers or Node 14+ (most are)
  • You don't support legacy enterprise codebases
  • Your dependencies are already ESM-only (like newer versions of chalk, p-queue)
  • You're building utilities for frameworks like Next.js, Remix, Vite that assume ESM

For ESM-only, your package.json is cleaner:

typescript
{
  "name": "@company/analytics",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "default": "./dist/index.js"
    }
  },
  "engines": {
    "node": ">=14.0.0"
  }
}

Keep dual packages only for:

  • Libraries used by backend tools (CLIs, build systems, test runners)
  • SDKs consumed by large enterprises
  • Packages with existing CommonJS user bases

I switched a utilities package to ESM-only last year and cut build complexity by 60%. Users on CommonJS simply used dynamic imports or transpiled; no complaints.

Common mistakes

Mistake 1: Same file path for both formats. Don't do this:

typescript
{
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "require": "./dist/index.js"
    }
  }
}

A single file can't be both valid ESM and CommonJS. You'll hit circular dependency errors or loader conflicts. Always separate them.

Mistake 2: Forgetting to ship built files. If your files field doesn't include dist/, npm won't publish your built files:

typescript
{
  "files": ["dist", "README.md"]
}

Mistake 3: Types field last in exports object. TypeScript's resolver reads exports in order. If types isn't first, some setups won't find it:

typescript
// ❌ Wrong
{
  "import": "./dist/index.mjs",
  "types": "./dist/index.d.ts"
}

// ✓ Correct
{
  "types": "./dist/index.d.ts",
  "import": "./dist/index.mjs"
}

Mistake 4: Not testing with real consumers. Test your dual package in an actual Next.js app, Express server, and Remix app before publishing. Exports look correct in isolation but fail in real bundlers.

Mistake 5: Assuming `main` and `module` don't matter. Many older bundlers and Node versions still check these fields. Keep them for backward compatibility:

typescript
{
  "main": "./dist/index.cjs",
  "module": "./dist/index.mjs",
  "exports": { ... }
}

Key takeaways

  • Use the exports field to conditionally serve ESM and CommonJS builds—it's the modern way and works across Node and bundlers
  • Keep a single .d.ts file unless your CommonJS and ESM builds have genuinely different type signatures
  • Test both import styles (ESM and CommonJS) in your CI pipeline before publishing; this catches 90% of user issues
  • For new packages in 2024, strongly consider ESM-only unless you have a specific reason to support CommonJS
  • List types first in the exports object to ensure TypeScript resolves correctly across all environments
  • Keep main and module fields as fallbacks for older tooling, even with exports defined
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles