Bundling cheatsheet.

Tooling map

  • tsx: dev runner. tsx src/index.ts like node but TS-aware. Replaces ts-node.
  • esbuild / swc: fast TS→JS transpiler. No type checking. Used by bundlers.
  • tsup: thin wrapper over esbuild for library builds. Outputs ESM + CJS + .d.ts.
  • unbuild: more opinionated; uses rollup under the hood.
  • vite: bundler for apps (uses esbuild for dev, rollup for prod).
  • tsc: official compiler, slow but emits accurate .d.ts files.

tsx (dev)

npm i -D tsx

# Run directly
npx tsx src/index.ts

# Watch
npx tsx watch src/index.ts

package.json:

{
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "start": "node dist/index.js"
  }
}

esbuild (raw)

npm i -D esbuild
esbuild src/index.ts \
  --bundle \
  --platform=node \
  --target=node20 \
  --format=esm \
  --outfile=dist/index.js

Programmatic:

import { build } from "esbuild";

await build({
  entryPoints: ["src/index.ts"],
  bundle: true,
  platform: "node",
  target: "node20",
  format: "esm",
  outfile: "dist/index.js",
  sourcemap: true,
  external: ["lodash"],
});
npm i -D tsup

tsup.config.ts:

import { defineConfig } from "tsup";

export default defineConfig({
  entry: ["src/index.ts"],
  format: ["esm", "cjs"],
  dts: true,
  clean: true,
  target: "node20",
  splitting: false,
  sourcemap: true,
  minify: false,
  external: [],
});
npx tsup
# Outputs:
# dist/index.js (ESM)
# dist/index.cjs (CJS)
# dist/index.d.ts
# dist/index.d.cts

Multiple entries

entry: {
  index: "src/index.ts",
  cli: "src/cli.ts",
  utils: "src/utils.ts",
}

Each generates its own bundle.

Watch mode

tsup --watch
tsup --watch --onSuccess "node dist/index.js"

Type-only build with tsc (separately)

Faster builds: let esbuild emit JS, let tsc emit types.

{
  "scripts": {
    "build:js": "esbuild ... --outfile=dist/index.js",
    "build:types": "tsc --emitDeclarationOnly --outDir dist",
    "build": "npm run build:js && npm run build:types"
  }
}

tsconfig.json:

{ "compilerOptions": { "noEmit": false, "emitDeclarationOnly": true } }

Type checking in CI

{
  "scripts": {
    "typecheck": "tsc --noEmit",
    "build": "tsup"
  }
}
- run: npm run typecheck
- run: npm test
- run: npm run build

Build is fast (esbuild); typecheck is separate.

External dependencies

Library bundles should externalize peer deps:

// tsup.config.ts
export default defineConfig({
  external: ["react", "react-dom"],
});

Or auto-externalize from package.json:

import pkg from "./package.json";

external: [
  ...Object.keys(pkg.peerDependencies ?? {}),
  ...Object.keys(pkg.dependencies ?? {}),
];
banner: { js: "#!/usr/bin/env node" }       // for CLIs

Define / env replace

// esbuild / tsup
define: {
  "process.env.NODE_ENV": '"production"',
  __VERSION__: '"1.0.0"',
}

CSS / asset loaders

esbuild handles .css, .png, etc, but treat them as external for libs:

loader: { ".png": "file" }

Vite (apps)

npm create vite@latest myapp -- --template react-ts

vite.config.ts:

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  server: { port: 3000 },
});

SWC (alternative)

npm i -D @swc/cli @swc/core

Pure transpile (faster than tsc), no bundling. Good for monorepo packages.

Comparison

tscesbuildswctsup
Type checkyesnonono
Emit .d.tsyesnonoyes (tsc)
Speedslowfastfastfast
Bundlenoyesnoyes
Dual ESM/CJSmanualmanualnoone config

Common mistakes

  • Building with esbuild alone — no .d.ts files.
  • Bundling node_modules in a lib — bloated, version-locked.
  • Wrong platform (node vs browser) — runtime fails.
  • Forgetting external for peer deps — react bundled twice.
  • Using tsc --watch for dev — slow; use tsx instead.

Read this next

If you want my tsup-based library template, it’s at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .