Build Tools Best Practices
Good build tooling is invisible when it works and catastrophic when it lies โ wrong env values in client bundles, missing source maps, or CI that rebuilds the world. These practices apply across Vite, Webpack, Rspack, Parcel, tsup, and Bun.
info
Write requirements before picking brands. See the full matrix in Choosing a Bundler.
| Requirement | Practice | Anti-pattern |
|---|---|---|
| App with HMR | Vite / framework bundler | Using tsup as an app bundler |
| Publish library | tsup/Rollup + dual exports | Bundling React into the package |
| Webpack debt + speed | Rspack migration | Big-bang rewrite without gates |
| Monorepo CI time | Turborepo/Nx + remote cache | Faster laptop โ fixed CI |
Dev servers optimize for speed (native ESM, less minify). Production optimizes for size and caching. Parity means behavior matches โ not that pipelines are identical.
| Area | Keep aligned | OK to differ |
|---|---|---|
| Env semantics | Same public env names & shapes | Local-only debug flags |
| Path aliases | Identical resolve rules | โ |
| Feature flags | Same evaluation rules | Dead-code elimination aggressiveness |
| CSS order | No divergent cascade bugs | CSS minify |
| Polyfills | Same browser targets | Dev may skip heavy polyfills |
warning
Source maps reconnect minified production stack traces to original TypeScript/JSX. Without them, production debugging is guesswork.
| 1 | import { defineConfig } from "vite"; |
| 2 | |
| 3 | export default defineConfig({ |
| 4 | build: { |
| 5 | // "hidden" emits .map files without a public //# sourceMappingURL |
| 6 | sourcemap: "hidden", |
| 7 | }, |
| 8 | }); |
| Mode | When | Security note |
|---|---|---|
| true / inline | Local debug | Do not ship inline maps to public CDN casually |
| hidden | Prod + private upload | Preferred for many SaaS apps |
| disabled | Rare | Only if you accept blind prod errors |
best practice
Bundlers inline compile-time constants. Anything prefixed for the client (VITE_*, NEXT_PUBLIC_*) becomes world-readable in the JS bundle.
| 1 | // UNSAFE โ secret ends up in the client bundle |
| 2 | const BAD = process.env.DATABASE_URL; |
| 3 | fetch("/api", { headers: { Authorization: process.env.SECRET_API_KEY } }); |
| 4 | |
| 5 | // SAFE โ only public config in client code |
| 6 | const API_BASE = import.meta.env.VITE_API_BASE_URL; |
| 7 | |
| 8 | // SAFE โ secrets only on the server |
| 9 | // server.ts |
| 10 | const dbUrl = process.env.DATABASE_URL; |
| Practice | Why | How |
|---|---|---|
| Allowlist public env | Prevent accidental leakage | Framework prefixes + review |
| Validate env at boot | Fail fast on misconfig | zod / t3-env / custom schema |
| Scan bundles | Catch leaked secrets | CI grep for known secret patterns |
| List env in cache keys | Avoid wrong turbo/nx hits | Declare in turbo/nx config |
danger
Cache at multiple layers. Missing one layer leaves free performance on the table.
| Layer | What to cache | Key by |
|---|---|---|
| Install | pnpm/npm/yarn store | lockfile hash |
| Task | turbo/nx outputs | inputs + env + task def |
| Bundler FS cache | Webpack/Rspack cache dirs | config + lockfile |
| Docker layers | deps before source copy | Dockerfile order |
| 1 | - uses: actions/cache@v4 |
| 2 | with: |
| 3 | path: | |
| 4 | ~/.local/share/pnpm/store |
| 5 | .turbo |
| 6 | node_modules/.cache |
| 7 | key: ${{ runner.os }}-build-${{ hashFiles('pnpm-lock.yaml', 'turbo.json') }} |
Measure before optimizing. Bundle analyzers show duplicated packages, accidental polyfills, and oversized locales.
| 1 | # Vite |
| 2 | npx vite-bundle-visualizer |
| 3 | |
| 4 | # Webpack / Rspack โ webpack-bundle-analyzer or similar |
| 5 | # Ensure stats JSON is generated in CI artifacts for regressions |
| 6 | |
| 7 | # Size budgets in CI (fail PR if main chunk exceeds threshold) |
| 8 | # Use bundlesize, size-limit, or custom scripts on stats |
- Track compressed (gzip/brotli) sizes โ raw bytes mislead.
- Set budgets on route-level chunks, not only total.
- Investigate sudden jumps after dependency upgrades.
Two Reacts in one page break hooks. Two lodashes inflate size. Deduplication is both a package-manager and bundler concern.
| Technique | Where | When |
|---|---|---|
| peerDependencies | Libraries | Always for React/Vue/etc. |
| pnpm overrides / resolutions | Root package.json | Force single version |
| Bundler alias / resolve.dedupe | Vite/Webpack | When graph still duplicates |
| Module Federation shared | MF hosts/remotes | Singleton runtime deps |
| 1 | import { defineConfig } from "vite"; |
| 2 | |
| 3 | export default defineConfig({ |
| 4 | resolve: { |
| 5 | dedupe: ["react", "react-dom"], |
| 6 | }, |
| 7 | }); |
- Prefer ESM packages with static import/export.
- Mark "sideEffects": false honestly in libraries.
- Avoid wildcard re-exports that defeat shaking when unnecessary.
- Import locales/icons surgically (e.g. individual icon paths).
note
- Pin tool versions in lockfiles.
- Avoid embedding wall-clock timestamps in banners.
- Use content hashes in filenames for long-term caching.
- Fail CI on unexpected diff of critical artifacts when doing reproducible builds.
| 1 | [ ] Requirements documented (HMR, SSR, federation, dual package, CI budget) |
| 2 | [ ] Tool choice matches product shape (app vs lib vs monorepo) |
| 3 | [ ] Production preview smoke-tested locally |
| 4 | [ ] Source maps generated and uploaded privately |
| 5 | [ ] Client env allowlisted; secrets server-only |
| 6 | [ ] Install + task caches configured in CI |
| 7 | [ ] Bundle analyzed; budgets enforced on critical routes |
| 8 | [ ] React/framework deduped; peers externalized in libs |
| 9 | [ ] Lockfile committed; tool versions pinned |
Community
Get help on Slack, Discord or VIP
Stuck on a guide? Join the community and ask.