Skip to content

Fix: tsup Not Working — Build Failing, Types Not Generated, or ESM/CJS Output Wrong

FixDevs · (Updated: )

Part of:  JavaScript & TypeScript Errors

Quick Answer

How to fix tsup bundler issues — entry points, dual ESM/CJS output, TypeScript declaration files, external dependencies, tree shaking, and package.json exports configuration.

The Problem

tsup builds but the output doesn’t work when imported:

npx tsup src/index.ts
# Build succeeds but:
# require('./dist/index.js') → Error: Cannot use import statement
# import from './dist/index.mjs' → Error: Named export 'foo' not found

Or TypeScript declarations aren’t generated:

npx tsup src/index.ts --dts
# Error: Declaration generation is not supported for this file

Or dependencies are bundled when they shouldn’t be:

Output is 2MB — includes all of node_modules

Why This Happens

tsup is a zero-config TypeScript bundler powered by esbuild. It is the de facto build tool for shipping npm packages because it produces both ESM and CJS output, generates .d.ts declarations via the TypeScript compiler, and respects package.json exports — all in one command. The catch is that “zero-config” only means it boots without a config; making the output actually consumable by every kind of downstream environment requires careful choices about format, externals, and package.json wiring.

The most frequent symptom is “build succeeds but imports fail.” That almost always traces back to a mismatch between the output format and the consumer’s module system. ESM uses import/export; CJS uses require/module.exports. If your package.json sets "type": "module" but tsup outputs CJS to a .js file, Node will refuse to require() it because the closest package.json says ESM. Conversely, a CJS file at dist/index.js cannot be imported with import from a true ESM consumer unless the file uses .cjs or the package.json exports map directs the right condition to the right file. The fix is always the same: emit both formats, give them distinct extensions (.js for ESM, .cjs for CJS), and write a complete exports map with types/import/require conditions.

The second class of failures is declaration generation. --dts does not use esbuild for .d.ts — esbuild strips types and cannot synthesize them. tsup shells out to the TypeScript compiler instead, which means any TS error in your source that blocks compilation also blocks declaration generation. If tsc --noEmit fails, tsup --dts will fail too. Some projects use a separate tsconfig.build.json that excludes test files and tightens settings; this is the cleanest way to keep the build clean without distorting the dev experience. The third common failure is bundle bloat: by default tsup externalizes anything in node_modules, but noExternal set too broadly — or a misnamed peer dependency — will pull all of node_modules into your dist, producing a 2MB tarball that nobody can install.

  • Match the output format to consumers — emit both ESM and CJS for libraries; use .cjs for the CJS file.
  • --dts runs tsc — any TypeScript error in your source breaks declarations too.
  • external and noExternal decide what ships — wrong settings produce bloat or missing modules.
  • The exports field is strict — paths must be exact, with extensions, and types first.

In Production: Incident Lens

When tsup breaks, the blast radius isn’t a running service — it’s that your library cannot ship. The failure typically surfaces at the CI gate just before npm publish, or worse, after publish when an unsuspecting consumer pulls down the package and hits a cryptic ERR_MODULE_NOT_FOUND or Cannot use import statement outside a module. Both outcomes block the release train, but a broken published version is the production incident — every dependent project’s CI starts going red and you cannot un-publish freely (npm only allows unpublish within 72 hours).

How it surfaces: your release pipeline finishes the build, runs tests against the source, but never exercises the actual built artifact in a fresh Node process. The bug ships. Within hours, GitHub issues start landing with copy-pasted error messages from downstream users — “doesn’t work with Next.js,” “doesn’t work in Bun,” “types not found.” The shape of the error tells you which condition in the exports map is wrong.

Monitoring signal: in CI, after tsup finishes, do a smoke test that exercises both module systems against the built artifact. Create a tiny temp project that imports your package from a real node_modules install, run it in both ESM and CJS modes, and assert that the type definitions resolve. The standard tools are publint and @arethetypeswrong/cli (attw --pack). Wire both into CI — they catch malformed exports maps, missing .d.cts files, and wrong types condition ordering before publish.

Recovery sequence: if a broken version is already on npm, immediately publish a patch with the fix and deprecate the broken version (npm deprecate <pkg>@<version> "broken exports, use X.Y.Z"). Do not rely on unpublish. Reproduce the failure locally with npm pack and install the resulting tarball into a fresh project — never test against npm link or a workspace, both of which mask real-world resolution behavior. The postmortem preventive is making publint and attw blocking CI gates, plus a release dry run that installs the prerelease tag in a synthetic consumer project covering ESM, CJS, TypeScript strict, and bundler resolution (Webpack/Vite/Next).

Fix 1: Basic Library Build

npm install -D tsup
// tsup.config.ts
import { defineConfig } from 'tsup';

export default defineConfig({
  entry: ['src/index.ts'],
  format: ['esm', 'cjs'],     // Dual output
  dts: true,                    // Generate .d.ts files
  sourcemap: true,              // Source maps for debugging
  clean: true,                  // Clean dist/ before build
  splitting: false,             // Don't code-split (single entry)
  treeshake: true,              // Remove unused code
  outDir: 'dist',

  // External packages — don't bundle these
  external: [
    // By default, all node_modules are external
    // Add specific externals if needed
  ],

  // Banner/footer
  banner: {
    js: '/* MIT License - My Package */',
  },
});
// package.json — correct exports configuration
{
  "name": "my-package",
  "version": "1.0.0",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": {
        "types": "./dist/index.d.ts",
        "default": "./dist/index.js"
      },
      "require": {
        "types": "./dist/index.d.cts",
        "default": "./dist/index.cjs"
      }
    },
    "./utils": {
      "import": {
        "types": "./dist/utils.d.ts",
        "default": "./dist/utils.js"
      },
      "require": {
        "types": "./dist/utils.d.cts",
        "default": "./dist/utils.cjs"
      }
    }
  },
  "files": ["dist"],
  "scripts": {
    "build": "tsup",
    "dev": "tsup --watch",
    "prepublishOnly": "npm run build"
  }
}

Fix 2: Multiple Entry Points

// tsup.config.ts — multiple entries
import { defineConfig } from 'tsup';

export default defineConfig({
  entry: {
    index: 'src/index.ts',
    utils: 'src/utils/index.ts',
    cli: 'src/cli.ts',
    'react': 'src/react/index.ts',
  },
  format: ['esm', 'cjs'],
  dts: true,
  sourcemap: true,
  clean: true,
  // Split code between entries (shared chunks)
  splitting: true,

  // Separate config per entry
  // Or use multiple defineConfig entries:
});

// Alternative: array config for different settings per entry
export default defineConfig([
  {
    entry: ['src/index.ts'],
    format: ['esm', 'cjs'],
    dts: true,
  },
  {
    entry: ['src/cli.ts'],
    format: ['esm'],
    dts: false,
    banner: { js: '#!/usr/bin/env node' },
  },
]);

Fix 3: Fix Declaration File Generation

// tsup.config.ts
export default defineConfig({
  entry: ['src/index.ts'],
  format: ['esm', 'cjs'],

  // Option 1: Generate .d.ts with tsup (uses tsc internally)
  dts: true,

  // Option 2: Only generate declarations (no bundle)
  // dts: { only: true },

  // Option 3: Use a specific tsconfig for declarations
  // dts: { tsconfig: './tsconfig.build.json' },

  // Option 4: Generate .d.ts and .d.cts for dual package
  dts: true,
  // tsup auto-generates .d.cts when format includes 'cjs'
});
// tsconfig.build.json — dedicated config for declarations
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "declaration": true,
    "declarationMap": true,
    "emitDeclarationOnly": true,
    "outDir": "dist"
  },
  "include": ["src"],
  "exclude": ["src/**/*.test.ts", "src/**/*.spec.ts"]
}

Common dts errors and fixes:

// Error: "Cannot find module 'X' or its corresponding type declarations"
// Fix: Install @types/X or add to tsconfig paths

// Error: "Declaration generation not supported"
// Fix: Remove `isolatedModules: true` from tsconfig, or use a separate tsconfig for dts

// Error: "Referenced project 'X' must have setting composite"
// Fix: Add `"composite": true` to referenced tsconfig

Fix 4: Handle Dependencies Correctly

// tsup.config.ts
export default defineConfig({
  entry: ['src/index.ts'],
  format: ['esm', 'cjs'],
  dts: true,

  // External — don't bundle these (default for node_modules)
  external: [
    'react',
    'react-dom',
    /^@types\//,  // Regex pattern
  ],

  // noExternal — force bundle these into the output
  noExternal: [
    'tiny-utility-lib',  // Bundle this small dependency
  ],

  // For a CLI tool — bundle everything
  // noExternal: [/.*/],  // Bundle all dependencies

  // Environment variables
  env: {
    NODE_ENV: 'production',
  },

  // Replace values at build time
  define: {
    'process.env.VERSION': JSON.stringify(require('./package.json').version),
  },
});
// package.json — peer dependencies stay external
{
  "peerDependencies": {
    "react": ">=18.0.0",
    "react-dom": ">=18.0.0"
  },
  "peerDependenciesMeta": {
    "react-dom": { "optional": true }
  },
  "dependencies": {
    "tiny-utility": "^1.0.0"
  },
  "devDependencies": {
    "tsup": "^8.0.0",
    "typescript": "^5.0.0"
  }
}

Fix 5: React Component Library

// tsup.config.ts — for React component libraries
import { defineConfig } from 'tsup';

export default defineConfig({
  entry: ['src/index.ts'],
  format: ['esm', 'cjs'],
  dts: true,
  sourcemap: true,
  clean: true,
  treeshake: true,

  // Don't bundle React — it's a peer dependency
  external: ['react', 'react-dom', 'react/jsx-runtime'],

  // Inject React JSX runtime
  esbuildOptions(options) {
    options.jsx = 'automatic';
  },

  // CSS handling
  // Option 1: Extract CSS to separate file
  // (CSS modules are supported automatically)

  // Option 2: Inject CSS into JS (not recommended for libraries)
  // injectStyle: true,
});

// src/index.ts — export components
export { Button } from './components/Button';
export { Input } from './components/Input';
export { Card } from './components/Card';

// Export types
export type { ButtonProps } from './components/Button';
export type { InputProps } from './components/Input';

Fix 6: Watch Mode and Development

# Watch for changes during development
npx tsup --watch

# Watch specific directories
npx tsup --watch src --watch lib

# Ignore patterns
npx tsup --watch --ignore-watch node_modules --ignore-watch dist
// package.json scripts
{
  "scripts": {
    "build": "tsup",
    "dev": "tsup --watch",
    "typecheck": "tsc --noEmit",
    "lint": "eslint src/",
    "test": "vitest",
    "prepublishOnly": "npm run build",
    "size": "size-limit",
    "clean": "rm -rf dist"
  }
}

Still Not Working?

“Cannot use import statement in a module” — the consumer is using require() on an ESM file. Add format: ['esm', 'cjs'] to generate both formats, and configure package.json exports to map require to the .cjs file and import to the .js file.

Output is huge (MBs) — dependencies are being bundled. Check noExternal isn’t set too broadly. By default, tsup externalizes node_modules. Add treeshake: true to remove unused code. For libraries, peer dependencies and regular dependencies should be external.

.d.ts files missing — add dts: true to the config. If it fails, check for TypeScript errors in your source (tsc --noEmit). tsup runs the TypeScript compiler for declarations — any TS error that prevents compilation also prevents declaration generation.

Exports field doesn’t resolve — Node.js’s exports is strict. The "." entry must exist for the main export. Paths must include the full file extension (.js, .cjs). The types condition must come first in each export block. Run npx publint to validate your package.json.

attw reports “Masquerading as CJS” — your .d.ts is used for both import and require conditions even though the runtime files differ. tsup needs to emit a separate .d.cts for the CJS condition. Make sure format includes 'cjs'; tsup will then generate dist/index.d.cts automatically. If it doesn’t, pin tsup to v8+ — older versions did not emit dual declarations.

Build is slow because of dtsdts: true runs tsc over your entire source tree, which dominates wall time on large projects. Use dts: { resolve: true } only when you need bundled declarations; otherwise prefer a parallel tsc --emitDeclarationOnly via npm-run-all -p tsup tsc. Or split the build: dts: { only: true } for one job, JS-only for another.

Tree-shaking does nothing on the consumer side — the consumer’s bundler treats your package as having side effects. Add "sideEffects": false to your package.json (or list specific files that do have side effects). Without it, Webpack and Vite assume any import from your package may have a side effect and disable tree-shaking entirely.

For related build tool issues, see Fix: esbuild Not Working, Fix: Rspack Not Working, Fix: Vite Failed to Resolve Import, and Fix: Webpack Module Not Found.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles