tsup
tsup is a zero-config TypeScript bundler powered by esbuild. It targets library authors who need fast builds, dual ESM/CJS output, and declaration files without standing up a full Rollup or Webpack pipeline. Under the hood it uses esbuild for JS emit and can generate .d.ts via a TypeScript/rollup-dts path.
Use tsup when you publish packages. Prefer Vite/Webpack/Rspack when you ship applications with HTML, CSS pipelines, and HMR.
info
| Need | How tsup helps | When not enough |
|---|---|---|
| Fast TS → JS | esbuild transform + bundle | Need Babel plugin ecosystem |
| Dual package | --format esm,cjs | Exotic dual-package hazards need careful exports |
| Types | --dts / dts: true | Complex project references may need tsc alone |
| Zero config | Sensible defaults from entry | Heavy CSS-in-JS app bundling |
| 1 | npm install -D tsup typescript |
| 2 | |
| 3 | # One-shot build from CLI |
| 4 | npx tsup src/index.ts --format esm,cjs --dts --clean |
| 5 | |
| 6 | # Watch during local development of a monorepo package |
| 7 | npx tsup src/index.ts --format esm,cjs --dts --watch |
| 1 | { |
| 2 | "name": "@acme/utils", |
| 3 | "version": "1.0.0", |
| 4 | "type": "module", |
| 5 | "main": "./dist/index.cjs", |
| 6 | "module": "./dist/index.js", |
| 7 | "types": "./dist/index.d.ts", |
| 8 | "exports": { |
| 9 | ".": { |
| 10 | "types": "./dist/index.d.ts", |
| 11 | "import": "./dist/index.js", |
| 12 | "require": "./dist/index.cjs" |
| 13 | } |
| 14 | }, |
| 15 | "files": ["dist"], |
| 16 | "scripts": { |
| 17 | "build": "tsup", |
| 18 | "dev": "tsup --watch", |
| 19 | "typecheck": "tsc --noEmit" |
| 20 | }, |
| 21 | "devDependencies": { |
| 22 | "tsup": "^8.0.0", |
| 23 | "typescript": "^5.0.0" |
| 24 | } |
| 25 | } |
Prefer tsup.config.ts for typed options. Keep CLI flags for one-offs.
| 1 | import { defineConfig } from "tsup"; |
| 2 | |
| 3 | export default defineConfig({ |
| 4 | entry: ["src/index.ts"], |
| 5 | format: ["esm", "cjs"], |
| 6 | dts: true, |
| 7 | sourcemap: true, |
| 8 | clean: true, |
| 9 | minify: false, // usually false for libraries — let consumers minify |
| 10 | target: "es2022", |
| 11 | splitting: false, // enable for multi-entry code-splitting libs |
| 12 | treeshake: true, |
| 13 | external: ["react", "react-dom"], |
| 14 | esbuildOptions(options) { |
| 15 | options.banner = { |
| 16 | js: '"use client";', // only if you intentionally ship RSC client markers |
| 17 | }; |
| 18 | }, |
| 19 | }); |
warning
Dual packages remain common while ecosystems migrate. tsup emits both formats; you must wire exports correctly to avoid the dual-package hazard.
| Field | Purpose | Notes |
|---|---|---|
| exports["."].import | ESM consumers | Point at .js ESM build |
| exports["."].require | CJS consumers | Point at .cjs |
| exports["."].types | TypeScript | Put types condition first |
| type: "module" | Default .js as ESM | Use .cjs extension for require |
danger
dts: true generates declaration files. For multi-entry packages, ensure each entry gets types. For stubborn cases, emit types with tsc --emitDeclarationOnly and let tsup handle JS only.
| 1 | import { defineConfig } from "tsup"; |
| 2 | |
| 3 | // Strategy A — tsup owns dts |
| 4 | export default defineConfig({ |
| 5 | entry: { index: "src/index.ts", cli: "src/cli.ts" }, |
| 6 | format: ["esm", "cjs"], |
| 7 | dts: true, |
| 8 | }); |
| 9 | |
| 10 | // Strategy B — tsc for types (run in parallel in package scripts) |
| 11 | // "build": "tsup && tsc -p tsconfig.build.json --emitDeclarationOnly" |
| 12 |
best practice
In monorepos, run tsup watch in library packages while the app uses Vite/Next HMR against workspace sources or built dist — depending on whether you resolve to source via package exports.
| 1 | # From repo root with Turborepo |
| 2 | turbo run dev --filter=@acme/utils --filter=@acme/web |
| 3 | |
| 4 | # Package scripts |
| 5 | # utils: "dev": "tsup --watch" |
| 6 | # web: "dev": "vite" |
| 7 | |
| 8 | # Tip: point the app at source during local dev via package.json exports |
| 9 | # conditions, or use a tool like vitest/vite that can resolve TS sources. |
By default tsup does not bundle dependencies listed in dependencies / peerDependencies (behavior can be configured). Explicitly externalize peers for UI libraries.
| 1 | import { defineConfig } from "tsup"; |
| 2 | |
| 3 | export default defineConfig({ |
| 4 | entry: ["src/index.ts"], |
| 5 | format: ["esm", "cjs"], |
| 6 | dts: true, |
| 7 | external: [ |
| 8 | "react", |
| 9 | "react-dom", |
| 10 | "react/jsx-runtime", |
| 11 | /^@acme\//, // keep workspace packages external if consumers resolve them |
| 12 | ], |
| 13 | noExternal: [], // force-bundle specific packages if needed |
| 14 | }); |
| Dependency type | Usually | Why |
|---|---|---|
| peerDependencies | External | Avoid duplicate React / framework copies |
| tiny helpers | Bundle or depend | Fewer install surprises vs larger install graph |
| Node builtins | External / platform mark | Do not polyfill into browser builds accidentally |
| 1 | import { defineConfig } from "tsup"; |
| 2 | |
| 3 | export default defineConfig({ |
| 4 | entry: { |
| 5 | index: "src/index.ts", |
| 6 | server: "src/server.ts", |
| 7 | "plugins/vite": "src/plugins/vite.ts", |
| 8 | }, |
| 9 | format: ["esm", "cjs"], |
| 10 | dts: true, |
| 11 | splitting: true, |
| 12 | clean: true, |
| 13 | }); |
Mirror entries in package.json exports so consumers can import @acme/utils/server without deep relative paths.
- No typechecking — pair with tsc.
- Limited transform plugins vs Babel — avoid exotic syntax transforms.
- Some TS features (const enums, namespaces) need care — prefer erasable syntax.
- CSS handling is basic compared to Vite/Webpack app pipelines.
note
| Tool | Best for | Tradeoff |
|---|---|---|
| tsup | Fast zero-config TS libs | Less flexible than Rollup plugins |
| Rollup | Fine-grained ESM output control | More config surface |
| Vite lib mode | Libs that share Vite plugins/CSS | Heavier than tsup for simple packages |
| unbuild | Nuxt/unjs-style packages | Different conventions |
| 1 | # 1. Build clean artifacts |
| 2 | npm run build |
| 3 | |
| 4 | # 2. Typecheck separately |
| 5 | npx tsc --noEmit |
| 6 | |
| 7 | # 3. Pack and inspect contents |
| 8 | npm pack --dry-run |
| 9 | # Ensure only dist + README + LICENSE; no src secrets |
| 10 | |
| 11 | # 4. Smoke test dual imports |
| 12 | node -e "console.log(require('./dist/index.cjs'))" |
| 13 | node --input-type=module -e "import('./dist/index.js').then(console.log)" |
| 14 | |
| 15 | # 5. Publish |
| 16 | npm publish --access public |
best practice
Community
Get help on Slack, Discord or VIP
Stuck on a guide? Join the community and ask.