Search Tech Journey

Find topics, journeys and posts

back to blog
system designbeginner 28m2026-06-10

MS Stack Ch 8 — Frontend build tooling

Vite, HMR, Rollup, esbuild, npm vs pnpm, tree-shaking, env vars, dev proxy. Understanding what your bundler is actually doing so when it breaks, you know where to look.

Chapter 8 of From Novice to Fluent on the Modern Microsoft Web Stack — a 22-chapter self-study plan.

Why this chapter

Every frontend project today sits on a build tool you didn't write — Vite for new projects, webpack for older ones, Next.js's built-in Turbopack/swc pipeline for App Router apps, and an assortment of legacy Parcel and CRA installs in the wild. You can ship for months without ever opening vite.config.ts. The day you can't — when HMR stops working, when the bundle is mysteriously 4 MB, when a dependency throws "module not found" only in production — is the day you wish you understood the build.

The shipping-vs-expert gap here is "I can run pnpm dev" vs "I can explain what happens between pnpm dev and the first paint, debug a tree-shaking failure, and configure code-splitting boundaries on purpose". The shipping engineer trusts the defaults. The expert knows the defaults — Vite's dev server uses native ES modules through esbuild for transform; the production build uses Rollup with esbuild as the minifier; environment variables prefixed VITE_ are inlined into the bundle; process.env doesn't exist in the browser. None of this is hidden, but none of it is automatic either.

You finish this chapter when you can scaffold a Vite project from scratch, configure a dev proxy to a local API, set environment variables for three environments, debug a tree-shaking failure with the bundle visualiser, and explain why pnpm's lockfile is structurally different from npm's.

Concepts and depth

Why a bundler exists — module graphs, code splitting, tree shaking

A modern frontend app is hundreds of .ts/.tsx/.css/.svg files plus dozens of npm packages. The browser can't load them one-by-one without spending most of its time on HTTP overhead (HTTP/2 helps but doesn't solve everything), and it can't resolve import 'react' to a path on disk by itself. A bundler walks the import graph starting at your entry, transforms each file (TypeScript → JavaScript, JSX → calls, CSS Modules → unique class names), and produces a small number of optimised assets the browser can load efficiently.

The three optimisations that matter:

  • Bundling combines many modules into fewer files. In production, the browser issues a handful of requests instead of hundreds. In dev, modern tools (Vite) skip this — the browser handles modules natively and the bundler only transforms.
  • Code splitting breaks the bundle into chunks loaded on demand. A dynamic import('./Dashboard') becomes a separate chunk fetched when the route is visited, keeping the initial bundle small. Without code splitting, every user pays the bandwidth cost of every feature on first load.
  • Tree shaking removes exports that are never imported. It depends on ES modules being statically analysable (no require(variable), no top-level side effects in modules you re-export). When it works, your bundle drops the unused half of a library; when it doesn't, you ship lodash twice and wonder why your bundle is 600 KB.

The art is understanding when each optimisation kicks in and when it fails. Tree shaking famously breaks on packages marked "sideEffects": true (or unmarked, which defaults to true) — every export is presumed to have side effects and kept. Code splitting needs a import() boundary the bundler can recognise (no await import(\./$`)` with a dynamic path). Bundling needs your code to use ES modules cleanly; CommonJS interop is a constant source of "default vs named export" pain.

Good enough to ship
  • • Run pnpm build and check output sizes
  • • Use dynamic import() to split routes
  • • Prefer ESM-only packages when given a choice
Expert tier
  • • Diagnose tree-shaking failures via bundle stats
  • • Mark your own package's sideEffects honestly
  • • Tune manualChunks to control caching boundaries

Vite dev server — native ES modules + HMR

Vite's headline insight: in dev, browsers already support ES modules. So instead of bundling everything for pnpm dev (which is what webpack does, and what makes it slow on big projects), Vite serves your source files directly to the browser as ES modules, with on-demand transforms (TypeScript stripping, JSX compilation) handled by esbuild as files are requested. The dev server start time is decoupled from project size — a 1000-file app starts in under a second.

The only thing Vite pre-bundles in dev is npm dependencies (it's called dependency pre-bundling, done by esbuild on first start, cached in node_modules/.vite/). Reason: many npm packages still ship CommonJS, and CJS doesn't work natively in browsers. Pre-bundling converts them to ESM and unifies hundreds of internal files (e.g., lodash-es) into a single module so the browser doesn't issue 600 requests.

HMR (Hot Module Replacement) is the killer feature. When you save a file, Vite sends the updated module to the browser over a websocket; the browser swaps it in place without a full reload. React state is preserved (when paired with @vitejs/plugin-react's Fast Refresh), so you don't lose your form input or the panel you have open. HMR has rules — components must be exported, must be the only export in the file (or paired with a stable identity), and side effects at the module top-level disrupt the reconciliation. When HMR misbehaves and does a full reload, the cause is usually one of these.

pnpm create vite@latest my-app -- --template react-ts
cd my-app && pnpm install && pnpm dev
# Vite v6 ready in 320 ms — http://localhost:5173
Good enough to ship
  • • Scaffold with pnpm create vite
  • • Know HMR preserves state; full reload means something went wrong
  • • Trust the dev server defaults; configure only what you need
Expert tier
  • • Diagnose HMR misses by inspecting the websocket payload
  • • Tune optimizeDeps.include/exclude for tricky packages
  • • Write a small Vite plugin to inject build-time data

Production build — minification, asset hashing, source maps

For production (pnpm build), Vite switches to Rollup as the bundler (with esbuild as the default minifier in Vite 4+). Rollup does the real graph optimisation: tree shaking, scope hoisting, chunk splitting. The output is a small set of hashed files (assets/index-a1b2c3.js) plus an index.html referencing them.

Asset hashing (the a1b2c3 in the filename) is content-addressable caching done right. The hash changes only when the file content changes; cache-friendly headers (Cache-Control: public, max-age=31536000, immutable) can be set on /assets/* because the URL itself uniquely identifies the bytes. The unhashed index.html is the cache-buster — short cache life, references the new hashes whenever a deploy happens.

Source maps map the minified, transformed code in your bundle back to the original source. Always ship them in production (build.sourcemap: true or, for security, 'hidden' to emit but not link from the bundle, then upload to your error monitor). Without source maps, a stack trace in Sentry reads at e(a.js:1:10312) and is useless; with them, you get at handleClick(Login.tsx:42:5).

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  build: {
    sourcemap: 'hidden',          // emit but don't link in bundle
    rollupOptions: {
      output: {
        manualChunks: {
          react: ['react', 'react-dom'],
          charts: ['highcharts', 'highcharts-react-official'],
        },
      },
    },
  },
});
Good enough to ship
  • • Enable source maps for production
  • • Set long cache life on hashed assets, short on index.html
  • • Use pnpm preview to test the build locally before deploy
Expert tier
  • • Configure manualChunks deliberately for vendor caching
  • • Upload source maps to Sentry/Datadog with cleaning on the bundle
  • • Reason about chunk granularity vs HTTP/2 multiplexing trade-offs

import.meta.env and build-time environment variables

In the browser there is no process.env. The Node convention doesn't carry over, and Vite refuses to pretend it does. Instead, Vite exposes import.meta.env — a build-time-replaced object containing variables prefixed VITE_ from your .env* files (plus a few built-ins like MODE, DEV, PROD, BASE_URL).

The mental model: variables are not read at runtime. They are replaced at build time. import.meta.env.VITE_API_URL becomes the literal string "https://api.example.com" in the bundled output. This is fast and safe (no runtime overhead, no module needed) but it means you cannot change env vars without rebuilding. For runtime-configurable values (different env per deployment from a single image), you either rebuild per environment or load config from a /config.json endpoint at app start.

Files are loaded in this order (later wins): .env, .env.local, .env.[mode], .env.[mode].local. Mode is development for pnpm dev, production for pnpm build, and any string passed via --mode staging. Keep .env*.local out of git; .env and .env.production (if non-secret) can be checked in.

# .env
VITE_API_URL=http://localhost:8080
VITE_FEATURE_FLAGS=newDashboard,exportCsv
// usage
const apiUrl: string = import.meta.env.VITE_API_URL;
const flags: string[] = import.meta.env.VITE_FEATURE_FLAGS.split(',');

// types — add to src/vite-env.d.ts
/// <reference types="vite/client" />
interface ImportMetaEnv {
  readonly VITE_API_URL: string;
  readonly VITE_FEATURE_FLAGS: string;
}
interface ImportMeta { readonly env: ImportMetaEnv; }
Good enough to ship
  • • Use VITE_-prefixed env vars for public configuration
  • • Add a vite-env.d.ts for type-safe import.meta.env
  • • Never commit .env.local
Expert tier
  • • Use runtime config endpoint for true per-environment values
  • • Validate env at build time with a Zod schema in the config
  • • Use loadEnv from vite in custom plugins

Dev-time API proxying

In dev, your frontend usually runs at http://localhost:5173 and your backend at http://localhost:8080. A direct fetch('http://localhost:8080/api/users') from the browser is a cross-origin request — the backend must send CORS headers, and you spend an afternoon configuring them. The cleaner answer in dev: proxy.

Vite's dev server can forward selected paths to another origin. Calls go to http://localhost:5173/api/users, the dev server proxies them to http://localhost:8080/api/users, and the browser thinks it's a same-origin call. CORS never enters the picture in dev. In production you typically deploy frontend and backend behind the same domain (or use a real reverse proxy / API gateway), so the path-rewrite pattern matches.

// vite.config.ts
export default defineConfig({
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
        // optional: rewrite '/api/users' to '/v1/users' on the way out
        // rewrite: (path) => path.replace(/^\/api/, '/v1'),
      },
      // websocket support
      '/socket.io': { target: 'ws://localhost:8080', ws: true },
    },
  },
});
Good enough to ship
  • • Proxy /api to your local backend in vite.config.ts
  • • Use the same path prefix in prod (behind a reverse proxy)
  • • Skip CORS configuration in dev
Expert tier
  • • Proxy websockets and SSE with ws: true
  • • Rewrite paths to match a different backend layout
  • • Mock missing endpoints with a tiny custom Vite plugin (intercept middleware)

Public vs imported assets

Two ways to ship a static file (image, font, JSON) with your Vite app:

  • Import it (import logo from './logo.svg'). The bundler processes the file — it gets a content hash, optimisation, and is included in the dependency graph. The import returns a URL string. Use this for assets your code references explicitly.
  • Put it in public/. Files in public/ are copied verbatim to the build output root. The URL is the path relative to public (<img src="/logo.svg" />). No hashing, no processing. Use this for files that must have a stable URL — favicon.ico, robots.txt, sitemap.xml, OG images served by URL.

The two modes are not interchangeable. An imported asset gets cache-busting and tree shaking; a public/ asset is forever and outside your dependency graph. A common mistake: putting an image in public/, then importing it via import logo from '/logo.png'. That works in dev but breaks in production if base is not /. Pick the right mode and stick to it.

Good enough to ship
  • • Import assets that your code references
  • • Put favicon, robots, sitemap in public/
  • • Don't mix the two modes for the same file
Expert tier
  • • Use ?url and ?raw query suffixes to force a specific import mode
  • • Set base correctly for sub-path deployments
  • • Use Vite's asset inlining threshold deliberately

package.json scripts — dev, build, preview

package.json scripts are not magic — pnpm dev runs whatever scripts.dev says, with node_modules/.bin on the path. The standard four for a Vite project:

  • dev: vite — starts the dev server with HMR.
  • build: vite build — produces a production bundle in dist/.
  • preview: vite preview — serves dist/ locally so you can sanity-check the production build before deploy. Not a production server.
  • lint / typecheck / test: your own glue around eslint, tsc, vitest.

Compose scripts with && (sequential) or pnpm workspace tools. Avoid npm-run-all unless you really need parallel execution — modern shells handle this fine. Hide one-off ops (deploy, db migrate) in scripts so they're discoverable via pnpm run.

{
  "scripts": {
    "dev": "vite",
    "build": "tsc --noEmit && vite build",
    "preview": "vite preview --port 5173",
    "lint": "eslint . --max-warnings 0",
    "typecheck": "tsc --noEmit",
    "test": "vitest",
    "test:e2e": "playwright test"
  }
}

The tsc --noEmit && vite build pattern is important: Vite's build uses esbuild for type stripping, which does not type-check. If you don't run tsc --noEmit first, type errors slip through to production. Some teams put the type check in CI separately to keep build fast; either approach works as long as the check happens somewhere.

Good enough to ship
  • • Memorise dev/build/preview
  • • Run tsc --noEmit alongside vite build
  • • Use scripts to hide ops commands
Expert tier
  • • Wire pre/post hooks (prebuild, postinstall) sparingly
  • • Use turborepo or nx for multi-package script orchestration
  • • Cache typecheck output for fast incremental CI

npm install vs npm ci, lockfile semantics

npm install (and pnpm install and yarn install and bun install) does two things: resolves the dependency tree (consulting package.json and the registry), and writes/updates the lockfile (package-lock.json / pnpm-lock.yaml / yarn.lock / bun.lockb). The lockfile pins exact resolved versions and integrity hashes so future installs are deterministic.

npm ci (and pnpm install --frozen-lockfile) is the CI mode: install exactly what the lockfile says, fail if the lockfile is out of sync with package.json, and don't modify the lockfile. This is what you want in CI and Docker builds — predictable, reproducible, no surprise version bumps. The win is also speed: ci skips the resolution step and just downloads.

pnpm's lockfile is structurally different: it doesn't flatten the dependency tree the way npm does. Each package gets its own dependencies installed in a content-addressable store (~/.local/share/pnpm/store/), and node_modules is a tree of symlinks. This means: no phantom dependencies (you can't accidentally import a package you didn't list — npm/yarn allow this by hoisting), disk usage is shared across projects, installs are faster after the first one. The downside: some packages with sloppy peer-dependency declarations break under pnpm and need .npmrc workarounds (shamefully-hoist=true, auto-install-peers=true).

The mental model: package.json is "what you ask for" (with semver ranges), the lockfile is "what was resolved" (with exact versions), node_modules is "what was installed" (the actual files). Edit package.json, commit the lockfile, never touch node_modules by hand.

Good enough to ship
  • • Commit the lockfile; never .gitignore it
  • • Use npm ci / pnpm i --frozen-lockfile in CI
  • • Pick one package manager per repo and stick to it
Expert tier
  • • Read a lockfile diff in PRs to catch supply-chain surprises
  • • Use pnpm workspaces with catalogs for monorepo version pinning
  • • Cache ~/.local/share/pnpm/store in CI for fast installs

webpack and esbuild — awareness

You won't configure webpack from scratch in 2026 unless you join an existing webpack project (or build a framework). The mental model still matters: webpack is older, slower (because it bundles everything in dev too), and infinitely configurable. Its plugin and loader ecosystem is enormous — every transform is a loader, every output transformation is a plugin. Next.js's webpack mode is the most common place you'll meet it.

esbuild is the high-speed Go-based bundler at the core of Vite (for transforms and dependency pre-bundling) and many other tools. It does TypeScript and JSX transforms, minification, bundling — fast (1000× faster than tsc for stripping types). It is not a full Rollup replacement for production bundling: it has weaker tree-shaking and no scope hoisting, which is why Vite prefers Rollup for production. Use esbuild directly when you need a build pipeline simpler than Vite (a Node CLI, a Lambda).

swc is Rust's answer to esbuild, used by Next.js (via the next/swc package) for transforms and minification. Comparable speed, comparable feature set, a different ecosystem. You don't pick swc; Next.js picks it for you.

Turbopack is Vercel's Rust-based bundler that Next.js is migrating to. As of mid-2026, it's stable for next dev on App Router projects and graduating for next build. Same mental model as Vite (dev = native modules + on-demand transforms, prod = optimised bundle).

Good enough to ship
  • • Know which bundler your framework uses
  • • Don't hand-roll webpack configs in 2026
  • • Use esbuild for tiny CLIs and Lambdas
Expert tier
  • • Debug an existing webpack config without rewriting it
  • • Write an esbuild plugin for a domain-specific transform
  • • Track Turbopack stability for your framework version

Bundle analysis and tree-shaking debugging

When your bundle is bigger than you expect, open a bundle analyser. rollup-plugin-visualizer for Vite emits a treemap HTML showing every module's size in the bundle. webpack-bundle-analyzer does the same for webpack. The first time you run one of these, you find a 300 KB moment.js, a 200 KB lodash full import, or a 150 KB icon set you only used three of.

Common tree-shaking failures and their fixes:

  • Importing the whole library: import _ from 'lodash' ships every function. Use import { debounce } from 'lodash-es' (note: -es, the ESM version). Better: install lodash.debounce (the single-function package).
  • sideEffects: true (or missing) on a dependency means the bundler can't prove the import is unused. If you own the package, mark it "sideEffects": false in package.json. If you don't, file an issue or fork.
  • Default re-exports (export * from './foo') defeat tree-shaking when the re-exporter has side effects. Use named re-exports.
  • CommonJS dependencies don't tree-shake. Vite's @rollup/plugin-commonjs converts them but loses dead-code elimination on the non-static parts.

A useful pattern: after every dependency add, run pnpm build and check the bundle size delta. If a single dependency adds more than 20 KB gzip, ask whether there's a smaller alternative.

// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer';

export default defineConfig({
  plugins: [
    react(),
    visualizer({ open: true, gzipSize: true, brotliSize: true }),
  ],
});
Good enough to ship
  • • Install rollup-plugin-visualizer and look at the treemap
  • • Use named imports from -es packages
  • • Check bundle size after each dependency add
Expert tier
  • • Mark your own sideEffects correctly so consumers tree-shake
  • • Replace moment with date-fns or Temporal; remove icon sets you don't use
  • • Audit transitive deps with pnpm why and npm ls

CSS handling — modules, PostCSS, Tailwind

Vite handles CSS out of the box. Plain .css files imported from a component are injected as <style> tags in dev and bundled (and code-split per chunk) in production. .module.css files are CSS Modules — class names are renamed to unique strings at build time, scoping styles to the importing component without runtime overhead. The import returns an object mapping logical name to generated class.

PostCSS runs automatically if a postcss.config.js is present at the project root. The two postcss plugins worth knowing: autoprefixer (adds vendor prefixes based on browserslist) and tailwindcss (the JIT engine that scans your source files for class names and emits matching CSS at build time). Tailwind is the de-facto styling system in 2026 for new projects; it integrates with Vite via the official @tailwindcss/vite plugin (Tailwind 4+) or the legacy PostCSS plugin.

The gotcha with CSS Modules: TypeScript doesn't know about your generated class names by default. Add a vite-env.d.ts with declare module '*.module.css' { const styles: Record<string, string>; export default styles; } or use vite-plugin-typed-css-modules to generate per-file .d.ts for full autocomplete.

import styles from './Button.module.css';
import './global.css';

export function Button({ children }: { children: React.ReactNode }) {
  return <button className={styles.primary}>{children}</button>;
}
Good enough to ship
  • • Plain .css for global, .module.css for component-scoped
  • • Tailwind via @tailwindcss/vite for utility-first styling
  • • autoprefixer driven by browserslist in package.json
Expert tier
  • • Tune Tailwind's content globs to keep JIT fast
  • • Use CSS layers + Tailwind's @layer to manage specificity
  • • Generate typed CSS Modules for safer refactors

Source maps and error monitoring

A production bundle is unreadable without source maps. The build emits *.map files alongside each JS chunk; with sourcemap: 'hidden', the maps exist but the bundle doesn't advertise them in a //# sourceMappingURL= comment — so users can't download them but you can ship them to Sentry / Datadog / Application Insights as a deploy step.

The upload step typically lives in CI right after pnpm build. Sentry's CLI (sentry-cli sourcemaps inject --org X --project Y ./dist && sentry-cli sourcemaps upload ./dist) is the canonical version; equivalents exist for every error monitor. Tag uploads with the same release identifier (commit SHA or semver) that you tag your bundle with via define: { __APP_VERSION__: ... } — the error monitor uses the release tag to match maps to events.

Once sourcemaps are wired, a Sentry event reads as at Login.handleSubmit (src/routes/Login.tsx:42:12) with the original variable names. Without them, every prod bug investigation starts with a 15-minute hunt for which minified identifier e.j actually was.

Good enough to ship
  • • Enable sourcemap: 'hidden' in prod builds
  • • Upload maps to your error monitor in CI
  • • Tag releases with a stable identifier
Expert tier
  • • Strip sourcesContent to keep map files small
  • • Verify upload with sentry-cli sourcemaps explain
  • • Automate release tagging in CI from git metadata

Worked examples

Example 1 — scaffold a Vite + React + TS project end-to-end

The full setup from pnpm create to a running dev server to a production build.

# 1) scaffold
pnpm create vite@latest analytics-dashboard -- --template react-ts
cd analytics-dashboard
pnpm install

# 2) add quality-of-life dependencies
pnpm add -D eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin \
  eslint-plugin-react eslint-plugin-react-hooks vitest @testing-library/react jsdom \
  rollup-plugin-visualizer

# 3) configure vite
# vite.config.ts — see Worked Example 2

# 4) run
pnpm dev          # http://localhost:5173, ~300ms cold start
pnpm build        # → dist/, optimised bundle
pnpm preview      # serve dist/ on 4173 to verify before deploy

# 5) test the build
ls -lh dist/assets/
# index-a1b2.js   42 KB
# vendor-c3d4.js  128 KB
  • --template react-ts gives you React + TypeScript with sensible defaults.
  • pnpm create vite doesn't install dependencies; do that manually so you control the package manager.
  • The first pnpm dev runs Vite's dependency pre-bundling — slower than subsequent starts.
  • pnpm preview is for verifying the build; not a real production server.

Example 2 — a complete vite.config.ts

A production-grade config with proxy, chunking, env validation, and bundle analysis.

import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'node:path';
import { visualizer } from 'rollup-plugin-visualizer';
import { z } from 'zod';

const EnvSchema = z.object({
  VITE_API_URL: z.string().url(),
  VITE_SENTRY_DSN: z.string().optional(),
});

export default defineConfig(({ mode }) => {
  const env = EnvSchema.parse(loadEnv(mode, process.cwd(), ''));

  return {
    plugins: [
      react(),
      mode === 'production' && visualizer({ filename: 'dist/stats.html', gzipSize: true }),
    ],
    resolve: {
      alias: { '@': path.resolve(__dirname, './src') },
    },
    server: {
      port: 5173,
      proxy: {
        '/api': { target: 'http://localhost:8080', changeOrigin: true },
      },
    },
    build: {
      sourcemap: 'hidden',
      target: 'es2022',
      rollupOptions: {
        output: {
          manualChunks: {
            react:  ['react', 'react-dom', 'react-router-dom'],
            charts: ['highcharts', 'highcharts-react-official'],
            query:  ['@tanstack/react-query'],
          },
        },
      },
    },
    define: {
      __APP_VERSION__: JSON.stringify(process.env.npm_package_version),
    },
  };
});
  • loadEnv reads .env* files server-side; pair with Zod to fail fast on missing vars.
  • The @ alias matches the paths setting in tsconfig.json for clean imports.
  • manualChunks keeps third-party code in stable-name chunks so users only re-download when the chunk content changes.
  • define lets you inject build-time constants (here, the package version) into the bundle.

Example 3 — debugging a "module not found" only in production

Symptom: pnpm dev works, pnpm preview (or production) throws Cannot read properties of undefined (reading 'foo').

# 1) build with sourcemap and serve it
pnpm build && pnpm preview

# 2) open DevTools → Sources → find the error in the (now mapped) source

# 3) common causes:
#    a) dynamic import path that includes a variable (Rollup can&apos;t analyse)
#       BAD:  await import(`./locales/${lang}.json`)
#       OK:   const langs = { en: () => import('./locales/en.json'), de: () => import('./locales/de.json') };
#             await langs[lang]()

#    b) CommonJS dep with named imports that don&apos;t exist as ESM named exports
#       BAD:  import { foo } from 'cjs-pkg'   // foo is undefined in prod
#       FIX:  import pkg from 'cjs-pkg'; const { foo } = pkg;

#    c) side-effects in a tree-shaken module (registered too late)
#       FIX:  add the module to `optimizeDeps.include` or explicit import
  • Dev uses native ESM and is forgiving of dynamic patterns; prod (Rollup) is stricter.
  • CJS interop is the #1 cause of "works in dev, fails in prod" bugs.
  • A source-mapped preview build catches 90% of these in under a minute.
  • Always run pnpm preview before deploy — it's the only way to catch these locally.

Example 4 — a custom Vite plugin: build-time SVG icon sprites

A 30-line plugin that scans src/icons/*.svg and exposes them as a single import('@/icons/sprite') virtual module.

// vite.config.ts (plugin section)
import fs from 'node:fs/promises';
import path from 'node:path';

function svgSprite(): import('vite').Plugin {
  const virtualId = 'virtual:icon-sprite';
  const resolvedId = '\0' + virtualId;

  return {
    name: 'svg-sprite',
    resolveId(id) { return id === virtualId ? resolvedId : null; },
    async load(id) {
      if (id !== resolvedId) return null;
      const dir = path.resolve(process.cwd(), 'src/icons');
      const files = await fs.readdir(dir);
      const entries: string[] = [];
      for (const f of files) {
        if (!f.endsWith('.svg')) continue;
        const name = f.replace(/\.svg$/, '');
        const svg = await fs.readFile(path.join(dir, f), 'utf-8');
        entries.push(`${JSON.stringify(name)}: ${JSON.stringify(svg)}`);
      }
      return `export default { ${entries.join(', ')} } as const;`;
    },
  };
}

// usage in app code:
//   import icons from 'virtual:icon-sprite';
//   <span dangerouslySetInnerHTML={{ __html: icons.search }} />
  • A virtual module (id starting with \0) is generated at build time without a real file on disk.
  • resolveId claims the import; load produces the source.
  • The SVGs are inlined as strings; the bundler tree-shakes unused icons because the module is named-import friendly.
  • This is the pattern behind plugins like vite-plugin-svg-icons and unplugin-icons.

Example 4 — a custom Vite plugin: build-time SVG icon sprites

A 30-line plugin that scans src/icons/*.svg and exposes them as a single import('@/icons/sprite') virtual module.

// vite.config.ts (plugin section)
import fs from 'node:fs/promises';
import path from 'node:path';

function svgSprite(): import('vite').Plugin {
  const virtualId = 'virtual:icon-sprite';
  const resolvedId = '\0' + virtualId;

  return {
    name: 'svg-sprite',
    resolveId(id) { return id === virtualId ? resolvedId : null; },
    async load(id) {
      if (id !== resolvedId) return null;
      const dir = path.resolve(process.cwd(), 'src/icons');
      const files = await fs.readdir(dir);
      const entries: string[] = [];
      for (const f of files) {
        if (!f.endsWith('.svg')) continue;
        const name = f.replace(/\.svg$/, '');
        const svg = await fs.readFile(path.join(dir, f), 'utf-8');
        entries.push(`${JSON.stringify(name)}: ${JSON.stringify(svg)}`);
      }
      return `export default { ${entries.join(', ')} } as const;`;
    },
  };
}

// usage in app code:
//   import icons from 'virtual:icon-sprite';
//   <span dangerouslySetInnerHTML={{ __html: icons.search }} />
  • A virtual module (id starting with \0) is generated at build time without a real file on disk.
  • resolveId claims the import; load produces the source.
  • The SVGs are inlined as strings; the bundler tree-shakes unused icons because the module is named-import friendly.
  • This is the pattern behind plugins like vite-plugin-svg-icons and unplugin-icons.

Example 5 — multi-environment build matrix

One codebase, three build outputs (dev, staging, prod) with mode-specific env files and a CI matrix.

# .github/workflows/build.yml (excerpt)
jobs:
  build:
    strategy:
      matrix:
        env: [development, staging, production]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v3
        with: { version: 9 }
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: pnpm }
      - run: pnpm install --frozen-lockfile
      - run: pnpm typecheck
      - run: pnpm build --mode ${{ matrix.env }}
      - uses: actions/upload-artifact@v4
        with:
          name: dist-${{ matrix.env }}
          path: dist
# .env.development
VITE_API_URL=http://localhost:8080
VITE_LOG_LEVEL=debug

# .env.staging
VITE_API_URL=https://api.staging.example.com
VITE_LOG_LEVEL=info

# .env.production
VITE_API_URL=https://api.example.com
VITE_LOG_LEVEL=warn
  • vite build --mode staging loads .env.staging (and .env, .env.local).
  • Each environment gets its own artifact — deploy step downloads the matching one.
  • pnpm typecheck runs once per matrix entry; consider running it once outside the matrix to save CI minutes.
  • Avoid runtime if/else for env-specific behaviour — the build does it.

Hands-on exercises

  1. Scaffold and ship. Goal: take a Vite + React + TS project from create to deployed in under 30 minutes.

    1. pnpm create vite@latest with the react-ts template.
    2. Add a single page with one fetch from a public API (https://api.github.com/users/octocat).
    3. Configure a vite.config.ts with a /api proxy.
    4. pnpm build and deploy to Vercel / Netlify / Cloudflare Pages (drag-and-drop the dist/ folder).
    5. You're done when: the live URL renders the data and pnpm preview matches.
  2. Env var triangulation. Goal: distinguish build-time vs runtime config.

    1. Add three .env* files: .env, .env.development, .env.production with one variable each.
    2. Read all three in your app via import.meta.env.MODE and display which file won.
    3. Run pnpm dev and pnpm build && pnpm preview and observe the difference.
    4. Add a vite-env.d.ts typing for the three vars and confirm TS autocomplete.
    5. You're done when: you can explain which file wins under which command without guessing.
  3. Bundle visualiser hunt. Goal: find and remove a ≥30 KB unused dependency.

    1. Install rollup-plugin-visualizer and add it to vite.config.ts.
    2. Build and open dist/stats.html.
    3. Identify a dependency you don't use (often a moment.js, a date-fns full import, an icon set).
    4. Remove or swap it (e.g., moment → date-fns; full lodash → lodash.debounce).
    5. You're done when: gzipped bundle size dropped by ≥30 KB without breaking anything.
  4. HMR break-fix. Goal: cause and fix an HMR full-reload regression.

    1. In a Vite + React project, add a console.log('top-level') at the top of a component file.
    2. Save another file — HMR works fine.
    3. Now add window.foo = 1 at the top of the component file. Save and watch HMR fall back to a full reload.
    4. Move the side-effect into a useEffect and confirm HMR recovers.
    5. You're done when: you can predict which patterns break HMR without running them.
  5. CI install hygiene. Goal: convert a CI workflow to use --frozen-lockfile and pnpm store cache.

    1. Take a GitHub Actions / Azure Pipelines step that runs pnpm install.
    2. Change it to pnpm install --frozen-lockfile.
    3. Add a cache step for ~/.local/share/pnpm/store keyed on pnpm-lock.yaml.
    4. Push a PR that intentionally drifts package.json from the lockfile; observe the CI failure.
    5. You're done when: CI fails on lockfile drift and second-run install time drops by half.
  6. Code-split a route. Goal: turn an eager-loaded heavy route into a lazy chunk.

    1. Find a route component that pulls in a chart library or a code editor (≥80 KB).
    2. Replace import Foo from './Foo' with const Foo = lazy(() => import('./Foo')).
    3. Wrap the route in <Suspense fallback={<Spinner />}>.
    4. Build and confirm the chunk appears as a separate file with its own hash.
    5. You're done when: initial bundle is smaller and the chart route fetches the chunk on demand.
  7. Custom plugin: git SHA banner. Goal: write a 20-line Vite plugin that injects the current git SHA.

    1. Read git rev-parse --short HEAD at config time.
    2. Use define: { __COMMIT__: JSON.stringify(sha) } so __COMMIT__ is inlined.
    3. Render it in the app footer.
    4. Verify the SHA appears in the built bundle (grep __COMMIT__ dist/assets/*.js should not match — it's been replaced).
    5. You're done when: every prod build embeds its source commit.
  8. Audit a real package.json for bloat. Goal: identify three dependencies that could be removed or shrunk.

    1. Run pnpm why <pkg> for the five largest top-level deps.
    2. Check the bundle visualiser for each.
    3. Replace one full-import library with named imports (lodashlodash.debounce).
    4. Replace one heavy library with a lighter alternative (momentdate-fns).
    5. You're done when: the bundle drops by another 30 KB and your dependency count is lower.

Self-check questions

  1. What does Vite serve to the browser in dev, and how is that different from a webpack dev server?
  2. Why does Vite pre-bundle npm dependencies but not your source code in dev?
  3. What is HMR, and what makes a file fall back to a full reload?
  4. Difference between import.meta.env and process.env. When is each available?
  5. Why are VITE_* env vars not secret?
  6. When would you put a file in public/ vs import it?
  7. Difference between pnpm install and pnpm install --frozen-lockfile. Where does each belong?
  8. Why is pnpm's node_modules layout different from npm's, and what bugs does it prevent?
  9. What is tree shaking? Name three things that prevent it from working.
  10. What does manualChunks give you that the default chunking doesn't?
  11. Why do you usually need tsc --noEmit even when Vite's build "compiles" your TS?
  12. When would you use esbuild directly instead of Vite?
  13. What is a source map, and why do you ship them in production?
  14. What is a virtual module in a Vite plugin, and why does the id start with a null byte?
  15. Why does Vite use Rollup for production but esbuild for dev?
  16. What does pnpm why <package> tell you, and when would you reach for it?
  17. Explain manualChunks cache invalidation: why is it a vendor-caching strategy and not a perf strategy?
  18. What does --mode staging do, and which files does it pick up?

High-signal resources

Official docs

Books or courses

Practitioner posts

Weekly milestones

  1. Day 1 — Read the Vite Guide. Do Exercise 1.
  2. Day 2 — Read Vite's "Env Variables and Modes" + "Dependency Pre-Bundling". Do Exercise 2.
  3. Day 3 — Read about Rollup tree shaking and sideEffects. Do Exercise 3.
  4. Day 4–5 — Do Exercises 4 and 6. Answer self-check 1-7.
  5. Day 6–7 — Read pnpm docs on workspaces and lockfile. Do Exercise 5. Answer self-check 8-18.

If you finish early, write a tiny Vite plugin that injects the current git SHA as a global constant. It's the smallest possible plugin that does something useful and teaches you the plugin API.

How it shows up in the capstone

The capstone is a Vite + React + TS app with a TanStack Query data layer, React Router for navigation, and Highcharts for the visualisations. The vite.config.ts looks very much like Worked Example 2: a /api proxy to the local ASP.NET backend in dev, manual chunks for react/charts/query so vendor caching is stable across deploys, env validation with Zod at build time, the bundle visualiser wired in for monthly audits.

Routes are lazy-loaded (lazy + Suspense) so the initial bundle is small and each dashboard view streams in only when visited. Asset hashing means the CDN can cache /assets/* for a year while index.html stays fresh. CI installs with pnpm install --frozen-lockfile and caches the pnpm store keyed on the lockfile hash; the type check runs in parallel with the build, both blocking merge.

You will feel this chapter most the day a dependency upgrade silently bloats the bundle by 200 KB and the visualiser tells you which one in 30 seconds — versus the alternative of bisecting commits for an afternoon. The tooling is invisible right up until it's the most important file in the repo.

The second time you'll feel it is during an incident. Production explodes; the stack trace points at e.j is not a function in a minified bundle. If you uploaded source maps to your error monitor, the trace resolves to a specific line in Login.tsx and you fix it in five minutes. If you didn't, you spend the next hour minifying locally to guess what e.j was. Source maps are the cheapest investment in the entire stack: ten seconds of CI per build, hours of recovered debugging per incident.

The third time is migration day. When the team decides to move from webpack to Vite (or from CRA to Vite — still common in 2026), the chapter you wrote in your head pays for itself: you know what HMR depends on, where env vars come from, what tree shaking needs, why pnpm's lockfile is different. The migration becomes an afternoon's work, not a quarter's project.

Next chapter → Ch 9 — React Previous chapter → Ch 7 — TypeScript