Bundling cheatsheet.
Tooling map
- tsx: dev runner.
tsx src/index.tslikenodebut TS-aware. Replacests-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"],
});
tsup (recommended for libs)
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 / footer
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
| tsc | esbuild | swc | tsup | |
|---|---|---|---|---|
| Type check | yes | no | no | no |
| Emit .d.ts | yes | no | no | yes (tsc) |
| Speed | slow | fast | fast | fast |
| Bundle | no | yes | no | yes |
| Dual ESM/CJS | manual | manual | no | one 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
externalfor peer deps — react bundled twice. - Using
tsc --watchfor 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 .