| name | configure-bundler-build |
| description | Configures and optimizes the JS/TS build toolchain — tsconfig plus a bundler (Vite/esbuild/Rollup/tsup/webpack) — for correct module output (ESM/CJS/dual + types), code splitting, tree-shaking, sourcemaps, env injection, and fast incremental builds. |
| when_to_use | Setting up or fixing how an app or library compiles and bundles — wrong module format, broken tree-shaking, missing/incorrect types, slow builds, tsconfig errors. Distinct from dockerfile-optimize (container images) and optimize-core-web-vitals (browser runtime metrics). |
When to Use
Reach for this skill when the problem is how source compiles and emits, not how it runs in a browser or container:
- "Set up the build for this app/library" (pick bundler, tsconfig, output format)
- "My library ships ESM but breaks in a CJS
require()" (or vice versa) — dual-package output
- "Consumers get
Could not find a declaration file" — missing/mislocated .d.ts
- "Tree-shaking isn't dropping unused exports" — dead code in the bundle
- "
tsc/vite build is slow" — switch transform to esbuild/swc, add a persistent cache
- "
define/import.meta.env isn't replacing my env var" or a secret leaked into the client bundle
- tsconfig errors:
module/moduleResolution mismatch, "x.js" has no exported member, paths not resolving
NOT this skill:
- Shrinking the runtime container image, multi-stage Docker layers → dockerfile-optimize
- LCP/INP/CLS, lazy-loading images, render-blocking JS in the browser → optimize-core-web-vitals
- Cross-package build orchestration, workspace topo build order, Turbo/Nx pipelines → setup-monorepo-tooling
npm publish, files/publishConfig, provenance, version bump → publish-package-registry
- ESLint/Prettier/pre-commit wiring → setup-lint-format-precommit
- Pinning the Node/pnpm/tsc versions themselves (engines,
.nvmrc, Volta) → pin-toolchain-versions
Steps
-
Pick the bundler by build target — do not default to webpack.
| Target | Use | Why |
|---|
| App (SPA/SSR, has an entry HTML or framework) | Vite | Rollup-based prod build, esbuild dev, HMR, code-splitting out of the box |
| Library (published to npm, consumers bundle it) | tsup (esbuild) or Rollup | dual ESM+CJS + .d.ts in one config; Rollup when you need fine-grained chunking |
| Node tool / CLI / serverless fn (single self-run entry) | esbuild | fastest, bundle deps in, --platform=node, no chunk graph needed |
| Legacy app needing module federation / exotic loaders | webpack | only when a Vite/Rollup plugin doesn't exist |
Default: app → Vite, library → tsup, node-tool → esbuild. One tool emits JS; tsc emits types (or tsup --dts / vite-plugin-dts wraps it). Never run tsc as the bundler for shipping code — it doesn't bundle, tree-shake, or split.
-
Set the tsconfig essentials — moduleResolution is the #1 footgun. Pick the resolution mode by who resolves modules:
| Scenario | module | moduleResolution |
|---|
| Bundler handles resolution (Vite/tsup/esbuild) | ESNext (or Preserve) | bundler |
| Node runs the output directly (Node ESM/CJS) | NodeNext | nodenext |
{
"compilerOptions": {
"target":
Common Errors
moduleResolution: node (classic) with modern packages. Fails to resolve exports-map-only packages. Use bundler (bundler resolves) or nodenext (Node resolves) — never the legacy node/node10.
types condition placed last in the exports map. TS reads conditions top-down and takes the first match; if import/require come before types, the consumer gets "no declaration file." types must be the first key in each condition block.
.cjs file emitting export {} (or .mjs with require). The exports map points at the wrong file per condition, or "type": "module" mismatches the extension. ESM → .js/.mjs, CJS → .cjs. Verify with node -e "require('your-pkg')" and a separate import.
"sideEffects": false on a package that has side effects. Tree-shaking drops a polyfill/CSS/registration import → feature silently missing in prod only. List the real side-effect files instead of a blanket false.
- Secret in a
VITE_/defined var. It's inlined into client JS and shipped to every browser. Only public values get VITE_/NEXT_PUBLIC_; secrets stay server-side at runtime.
paths alias resolves in the editor but Cannot find module '@/x' at build. tsc/paths is type-only; the bundler needs its own alias (vite-tsconfig-paths, resolve.alias, or tsconfig-paths). Configure both.
isolatedModules errors on export { Foo } where Foo is a type. esbuild/swc compile each file alone and can't tell types from values. Use export type { Foo } / import type (enforced by verbatimModuleSyntax).
Verify
- Clean build succeeds:
rm -rf dist && <build> exits 0 and dist/ contains the expected entry files (.js, .cjs, .d.ts, .map).
- Types resolve both ways (library):
npx @arethetypeswrong/cli --pack reports no ❌ — no "masquerading as CJS/ESM", no missing types per condition.
- Package shape is publishable:
npx publint is clean — exports, main/module/types, and file extensions all consistent.
- Dual import actually loads: in a scratch dir,
node -e "import('your-pkg').then(m=>console.log(Object.keys(m)))" and node -e "console.log(Object.keys(require('your-pkg')))" both print the API — no ERR_REQUIRE_ESM / ERR_PACKAGE_PATH_NOT_EXPORTED.
- Type-check passes independently:
tsc --noEmit exits 0 (proves the build path didn't skip a type error).
- Tree-shaking works: bundle a fixture importing one named export; the visualizer/
--metafile shows unused siblings absent from output. Bundle size drops when an unused heavy import is removed.
- Code-splitting present (app): prod build emits ≥1 async chunk per lazy route, and the vendor chunk is separate from app code (check
dist/assets/).
- No secret in the bundle:
grep -r "<a known secret substring>" dist/ returns nothing; only intended public VITE_*/NEXT_PUBLIC_* values appear.
- Sourcemaps map back: open a built file's
.map or trigger an error — stack trace points to original src/ lines, not minified columns.
- Incremental rebuild is fast: a one-line edit triggers a sub-second rebuild (warm cache), not a full cold compile.
Done = clean build emits the correct module formats + types, attw and publint are clean, both import() and require() load the API, tsc --noEmit passes, tree-shaking and code-splitting are confirmed in the output, no secret leaked into dist/, and warm rebuilds are fast.