Dinesh/ Blog
application engineering

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.

36 min readChecking device speech…
Lesson preparation & details

Level: beginner

By the end, you should be able to

  • Explain Vite 6 transforms versus TypeScript checking
  • Distinguish public build configuration from secrets and private source maps
  • Generate valid virtual-module JavaScript and reason about HMR/tree shaking

Bring with you

Sources checked: . A source check does not change the publication or update date.

Editorial review: · What review means

In this article · 34 sections

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

Why this chapter

A frontend build tool resolves modules, transforms syntax and packages assets. This chapter deliberately uses Vite 6 / Rollup 4 / esbuild and pnpm 9 terminology. Later Vite majors have different internals; @latest is not a reproducible way to obtain this baseline. 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.

Bundler mental model — Module graphs, code splitting, tree shaking — what they are and why they exist.

Dev server fluency — Native ES modules in dev, HMR, proxy to a backend, env vars.

Production build — Minification, hashing, source maps, chunk boundaries you control.

package.json scripts — dev, build, preview, lint, test — and the difference between install vs ci.

Lockfile semantics — Why pnpm-lock is structurally different from package-lock, and how to read both.

Bundle analysis — Open the visualiser, find the 200KB module you didn't mean to ship.

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. Static ESM dependencies make reachability analyzable; required top-level effects can remain even when unused exports are removed. 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.

Tree shaking removes unused code only when analysis and side-effect metadata permit it. sideEffects: true or missing metadata does not mean every export must remain: retaining module evaluation and retaining every export are different decisions. Marking a registration or CSS module as side-effect-free can remove required behavior. Dynamic imports can contain bounded variables (Vite 6 supports filename variables one level deep); arbitrary runtime paths need an explicit map or import.meta.glob. Inspect actual output rather than treating one import spelling as a universal rule.

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. On-demand transforms reduce eager startup work, but plugin initialization and dependency discovery still affect startup; no fixed sub-second result is promised.

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) propagates module updates over a WebSocket. React Fast Refresh can preserve state for compatible component edits; hook-signature changes, incompatible exports or edits outside an accepted boundary may remount or reload. Some stable non-component exports are allowed. A full reload is sometimes the correct behavior, not proof that all top-level effects are forbidden.

pnpm create vite@6 my-app --template react-ts
cd my-app
# Review the generated package versions, then lock reviewed compatible versions.
pnpm install
pnpm dev --host 127.0.0.1
# Illustrative local URL: http://127.0.0.1:5173; timing depends on the project

Good enough to ship

• Scaffold with pnpm create vite • Know which Fast Refresh edits preserve state and which intentionally remount • 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 client-build minifier in this Vite 6 baseline). 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. Generate maps when needed for debugging. build.sourcemap: 'hidden' emits maps without a sourceMappingURL comment; it is not access control. Upload them privately and exclude them from publicly served output if source disclosure is not intended. 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 avoids a runtime configuration fetch and exposes the value publicly; it also 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.

File values are loaded in this order (later wins): .env, .env.local, .env.[mode], .env.[mode].local. Environment variables already present when Vite starts have higher priority than these files. 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(',').filter(Boolean);
// Declarations aid checking but do not prove the build actually supplied a value.
 
// 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; }

Secret leak warning

Every VITE_* variable is shipped to the browser bundle. Anyone with DevTools can read them. Never put API secrets, signing keys, or anything you wouldn't print on a billboard there. Secrets stay on the server.

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. For these relative, proxied requests the browser sees the Vite origin, so it does not need cross-origin access to the backend. Direct cross-origin requests or redirects can still require CORS. 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) • Use relative proxied URLs; configure CORS if you still call a different origin directly

Expert tier

• Use ws: true for WebSockets; SSE is streaming HTTP and needs suitable buffering/timeouts • 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 participates in URL rewriting, hashing or small-asset inlining, 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. Imported assets participate in the build graph. Public assets are copied without hashing and need their own cache/version policy; Vite does not automatically optimize image bytes. Reference a public file by URL rather than relying on an import to give it hashing semantics. For a runtime-constructed public URL on a sub-path deployment, use ${import.meta.env.BASE_URL}logo.png; Vite cannot rewrite an arbitrary string assembled at runtime. Set base for the deployment and inspect the emitted URLs. Pick a cache policy for each mode.

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 shown tsc --noEmit && vite build pattern assumes the selected tsconfig includes the application sources. For a scaffold using project references, keep its reviewed tsc -b check or target the actual source project; tsc --noEmit at an empty solution root can check nothing. The separate-check 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.lock (newer Bun; older versions used bun.lockb)). The lockfile records resolved versions and integrity information. Reproducing the installed result also requires compatible package-manager/Node versions, platform and configuration; lifecycle scripts and native or optional dependencies are additional inputs.

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. npm ci also removes an existing node_modules before installing and can run lifecycle scripts; it is not just a download command. Cache state, platform and scripts affect timing, so measure speed rather than promising it. Plain pnpm install may already default to frozen behavior in CI when a lockfile is present; spelling out --frozen-lockfile makes the intended check explicit.

Separate lockfile format from installation layout. pnpm 9 represents importers and resolved dependency relationships in pnpm-lock.yaml; its default node_modules/.pnpm virtual store uses links to package files in a content-addressable store, with symlinks encoding dependency relationships. npm records packages in its own lockfile format and commonly hoists dependencies. pnpm’s default layout reduces accidental access to undeclared dependencies, but hoisting settings and tooling can change that behavior. Prefer fixing declarations or targeted package extensions over globally enabling shamefully-hoist. Locate the configured store with pnpm store path rather than hardcoding a machine path.

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 • Locate the store with pnpm store path; key a CI cache on lockfile and tool/platform inputs

webpack and esbuild — awareness

Webpack remains an option when its integrations fit the project; choosing it is not limited to legacy maintenance. The mental model still matters: webpack bundles in development and has a mature loader/plugin ecosystem; comparative speed depends on configuration and workload. 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 a Go-based transformer, minifier and bundler. Vite 6 uses it for dependency prebundling and many syntax transforms. It can also produce production bundles directly; choosing Rollup in Vite 6 is a toolchain/ecosystem decision, not evidence that esbuild has no scope hoisting or a universal 1000× speed advantage. Both skip TypeScript semantic checking in these transforms.

SWC is a Rust-based compilation toolchain used by Next.js for JavaScript/TypeScript transforms. Next.js distributes platform-specific @next/swc-* bindings; SWC also has standalone packages such as @swc/core. Transform, bundling and minification responsibilities depend on the framework version and configured pipeline; neither equal feature sets nor equal speed follows from the implementation language.

Turbopack is the incremental Rust bundler that Next.js 16 uses by default for development and production builds. The installed Next.js 16.2.6 documentation explicitly says it bundles in development, unlike Vite 6’s native-ESM source-serving model. Use the documentation for your exact framework version rather than carrying Vite’s architecture across by analogy.

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. Look for duplicated dependencies, unexpected transitive modules and heavy imports. Record whether sizes are raw, minified, gzip or Brotli before comparing them.

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).
  • Side-effect metadata describes module evaluation, not whether every export is live. Declare "sideEffects": false only if importing unused modules is genuinely safe to omit; list CSS/registration exceptions when needed.
  • Star re-exports (export * from './foo') are statically analyzable and do not re-export default. They do not inherently defeat tree shaking; required module side effects must still execute.
  • CommonJS dependencies are harder to analyze when export shape or require targets are dynamic. The conversion plugin can optimize some static patterns; inspect the dependency and output instead of promising all-or-nothing tree shaking.

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 one utility-CSS option; it integrates with Vite via the official @tailwindcss/vite plugin (Tailwind 4+) or @tailwindcss/postcss in Tailwind 4; Tailwind 3 used tailwindcss itself as the PostCSS plugin.

With /// <reference types="vite/client" />, Vite already provides a generic .module.css declaration. That enables imports but does not prove a class key exists. For exact class-name checking, generate per-file declarations with a reviewed tool rather than duplicating the generic declaration.

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

• Use version-specific source discovery: Tailwind 3 content globs versus Tailwind 4 automatic detection/@source • 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 — but the files remain downloadable if published. Exclude them from the public artifact or deny access, and upload a private copy to the selected monitor.

The upload step typically lives in CI right after pnpm build. Use the version-specific source-map workflow of the chosen monitor. Any injection that changes JavaScript must occur before final hashing/packaging, and deployed bytes must match the uploaded maps; an upload command alone does not establish that match. 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@6 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 --host 127.0.0.1  # local-only development
pnpm build        # → dist/, optimised bundle
pnpm preview      # serve dist/ on 4173 to verify before deploy
 
# 5) test the build
ls -lh dist/assets/
# Inspect your actual filenames and sizes; no fixed output sizes are promised.
  • --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

An integration config with proxy, chunking, env validation and bundle analysis. It requires Vite 6-compatible React/visualizer plugins, Zod, Node types and every package named in manualChunks; the preceding scaffold alone does not install those chart/query dependencies. Keep stats.html and private maps out of public output.

import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
import { fileURLToPath } from 'node:url';
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 }) => {
  EnvSchema.parse(loadEnv(mode, process.cwd(), 'VITE_')); // only public configuration
 
  return {
    plugins: [
      react(),
      mode === 'production' && visualizer({ filename: 'dist/stats.html', gzipSize: true }),
    ],
    resolve: {
      alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
    },
    server: {
      host: '127.0.0.1',
      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 ?? 'dev'),
    },
  };
});
  • 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 groups selected modules; the default emitted filenames still contain hashes. A stable group name is not a stable URL, and graph/dependency changes can invalidate vendor output or change side-effect order. Inspect the resulting graph and cache behavior.
  • 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) a dynamic import whose path is outside Vite 6's supported variable pattern
#       SUPPORTED SHAPE: await import(`./locales/${lang}.json`) // one-level filenames
#       OK:   const langs = { en: () => import('./locales/en.json'), de: () => import('./locales/de.json') };
#             await langs[lang]()
 
#    b) CommonJS dep with named imports that don'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:  explicitly import the registration module and correct sideEffects metadata; optimizeDeps is not a production tree-shaking fix
  • Native ESM source serving is not permission to use arbitrary runtime paths: compare the exact dev and production transforms for the supported dynamic-import pattern.
  • Compare dev and production transforms when diagnosing CJS interop; neither a default nor named import is universally correct for all CJS packages.
  • A source-mapped preview is one useful check; unit tests, static artifact inspection and other production-like harnesses also find defects.

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

This plugin scans trusted local src/icons/*.svg files and exports a string dictionary from virtual:icon-sprite. Despite the historical heading, it does not assemble SVG <symbol> sprites. Do not pass untrusted SVG through dangerouslySetInnerHTML; sanitize it with an appropriate policy or serve it as an image.

// 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)).sort();
      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(', ')} };`; // virtual module must emit JavaScript
    },
  };
}
 
// 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.
  • This default-exported dictionary does not guarantee per-icon tree shaking. Named per-icon modules or explicit imports provide clearer splitting boundaries. JSON.stringify escapes string contents; it does not sanitize SVG markup.
  • Register svgSprite() in plugins and declare virtual:icon-sprite as Record<string, string> for TypeScript consumers. This simple plugin does not implement file-watching/HMR invalidation: restart after icon edits. Test its generated JavaScript independently of a whole Vite build.

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: 22, cache: pnpm } # pin a supported patch in your actual workflow
      - 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: explain the Vite + React + TS path from scaffold to a locally inspectable artifact; optional deployment is a separate authorized exercise.

    1. Use the Vite 6 scaffold for this chapter and retain a reviewed lockfile.
    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. Run pnpm build and inspect with pnpm preview --host 127.0.0.1. A deployment to a static host is optional and separate; a Vite dev proxy is not shipped in dist/.
    5. You're done when: the local production preview renders data using a deliberate production API URL, same-origin backend or local mock. If you choose deployment, verify its routing separately.
  2. Env var triangulation. Goal: distinguish build-time vs runtime config.

    1. Put the same key, VITE_LABEL, in .env, .env.development and .env.production, with values base, dev and prod.
    2. Display import.meta.env.MODE and import.meta.env.VITE_LABEL. MODE is a mode name, not a way to read three files; conflicting values for the same key demonstrate precedence.
    3. Run pnpm dev and pnpm build && pnpm preview and observe the difference.
    4. Add a vite-env.d.ts declaration for VITE_LABEL; confirm autocomplete, then remove the supplied value to see that a type declaration does not supply runtime data.
    5. You're done when: you can explain which file wins under which command without guessing.
  3. Bundle visualiser hunt. Goal: identify an avoidable dependency and measure its actual transferred-size cost.

    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: before/after gzip measurements and behavioral checks explain the result; a smaller gain or a justified decision to retain a dependency is valid.
  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. Add/change a non-component export consumed outside a refresh boundary. Observe whether invalidation reaches the page root; a top-level assignment alone does not guarantee reload.
    4. Separate the non-component module from the component boundary and compare compatible component edits, hook-signature edits and export changes.
    5. You're done when: the observed remount/reload follows the accepted module graph and plugin rules, with a saved reproduction.
  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. Cache the configured pnpm store path, keyed on lockfile and relevant platform/tool versions.
    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 any measured cache improvement is reported without a promised speedup.
  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 (lodash → lodash.debounce).
    4. Replace one heavy library with a lighter alternative (moment → date-fns).
    5. You're done when: the audit records dependency ancestry, measured bundle deltas and behavior checks, including any justified no-change decisions.

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 need a separate TypeScript check (such as tsc --noEmit, or tsc -b for a referenced template project) when Vite transforms 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?

Worked assessment answers

Modes: vite build --mode staging loads the staging overlay but still performs a production build; MODE and NODE_ENV are distinct concepts. Existing process variables outrank files. A declaration in vite-env.d.ts does not manufacture a missing value.

Plugin: the original returned export default {...} as const, which is TypeScript syntax in a JavaScript virtual module. Returning plain JavaScript fixes parsing. The dictionary export is not “named-import friendly,” and serializing an SVG string is not XSS sanitization. Sorting filenames makes generation deterministic for the same file set. Computed property keys also preserve an icon named __proto__.svg: a quoted "__proto__": value in an object literal is special prototype syntax, not an ordinary own property.

Source maps: request assets/file.js.map from an imagined static host even when its JS has no sourceMappingURL. If the file is published, hidden mode has not protected it. A valid private-upload design must also prove the maps correspond to the final JS bytes.

Self-check key: (1–2) Vite 6 transforms application ESM on demand and prebundles dependencies for compatibility/request reduction; (3) HMR updates accepted boundaries, with reload/remount for incompatible changes; (4–5) import.meta.env is public client configuration while process.env belongs to Node/build processes; (6) public assets have stable names, imported assets join the graph; (7) frozen installs reject lock drift; (8) pnpm’s links separate package visibility from a flattened root; (9) static reachability, effectful module evaluation and dynamic CJS shape govern tree shaking; (10) manualChunks controls grouping but can add dependencies or alter evaluation timing; (11) transpilation is not type checking; (12) esbuild can serve a smaller standalone pipeline; (13) maps translate generated locations to sources and need an exposure policy; (14) virtual imports use a public virtual: name and an internal null-byte ID to avoid normal resolution; (15) this is Vite 6’s chosen division of labor, not a law; (16) pnpm why explains dependency ancestry; (17) vendor chunking changes caching and may improve or worsen loading performance; (18) mode selects env overlays, not deployment credentials.

High-signal resources

Official docs

  • Vite Guide — read the sections used by the exercises; allow time to try the examples rather than assuming a fixed reading duration.
  • Vite Config Reference — searchable, comprehensive.
  • Rollup docs — the actual bundler under Vite's production mode.
  • esbuild docs — the transformer under Vite's dev mode.
  • pnpm docs — workspaces, catalogs, peer deps.

Books or courses

  • patak — vite build — a historical walkthrough explicitly based on Vite 2.6.2, last updated in 2021; useful background, not the Vite 6 contract for this chapter.
  • Frontend Masters course catalog — optional browsing. The previously listed /courses/build-tooling/ URL returned 404 on 9 October 2026; no specific course or Jem Young attribution is verified here.

Practitioner posts

  • Vite 6 — Why Vite — the official rationale for the version used here.
  • Anthony Fu’s site — optional project and blog navigation; not a verified article titled “Reinventing Modules.”
  • Lee Robinson’s site — optional reading; the previously linked benchmark URL returned 404 on 9 October 2026, so it is not evidence for a package-manager performance comparison.
  • JASON Format blog — optional JavaScript reading; this homepage is not a link to a verified article titled “Differential Loading is dead, long live ESM.”
  • Overreacted — Dan Abramov’s blog — optional React reading, not a specific code-splitting article.
  • Webpack 5 release — the official release post dated 10 October 2020; historical webpack context, not a personal-author or current-version guarantee.

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 whose actual cache invalidation is checked 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 can shorten that investigation when the uploaded maps match the deployed JavaScript. Measure upload cost and debugging impact in your own pipeline; neither has a fixed duration.

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, and how lockfile format differs from installation layout. Estimate migration work from actual plugin, resolution, CSS and runtime differences; understanding the graph reduces guessing but does not fix its duration.

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

Corrected contracts and failure analysis

VITE-prefixed values are compiled into client code and are public, not secret storage. A development proxy changes local routing; it does not deploy an API proxy to production or provide an authorization boundary. Build once and promote immutable artifacts only when environment-specific configuration is loaded at runtime; otherwise each environment build is a distinct artifact and needs its own test and hash.

Tree shaking depends on static imports and correct sideEffects declarations. optimizeDeps primarily addresses development dependency prebundling; it is not a general fix for an incorrectly removed production side effect. Import registration explicitly and correct package metadata. Source maps can reveal proprietary source and comments; send maps privately to a configured error tracker or deliberately accept public disclosure. A preview server is a verification tool, not a production hosting recommendation.

Boundary exercise with solution

Why does putting a payment secret in VITE_PAYMENT_KEY leak it even if .env is gitignored?

Solution and reasoning

The build embeds the exposed value in the browser bundle. Git ignore only controls tracking, not compilation. Keep the secret server-side and expose a narrowly authorized API; inspect built assets using a dummy marker, never a real credential.

Source-backed review notes

  • Env Variables and Modes | Vite — accessed 2026-10-07. Exact supporting passage: “To prevent accidentally leaking env variables to the client, only variables prefixed with VITE_ are exposed to your Vite-processed code. e.g. for the following env variables:”

Version-specific reference contracts

Documentation snapshots accessed 7 October 2026; mutable service/browser documentation is timestamped evidence, not an immutable product-version pin. Exact supporting passages and hashes accompany the review ledger.

Review and execution boundary

Vite 6 documentation baseline; do not project its Rollup/esbuild internals onto later Vite majors.

Fresh whole-current review on 9 October 2026, reusing the hash-verified 7 October original reading and coverage audit. The exact SVG plugin ran with a fake filesystem, including an __proto__.svg negative control; generated JavaScript was imported locally. MDX and code syntax checks are separate from application execution. No Vite build, dev server, install, CI job or deployment was executed.

Fresh primary references for the corrections: Vite 6 public base paths, TypeScript project references, npm ci, Rollup manualChunks, and object-initializer prototype keys. The pnpm 9 documentation URL redirects to current pnpm docs; that current page supports the explicit frozen-install policy, not every pnpm 9 default. Historical setup commands and optional exercises were not executed. No cloud resources, third-party packages or external side effects were created. Old screenshots and unavailable private assets remain in the private recovery archive, not prerequisites for this lesson.

Pause / Recall / Apply

Can you explain it without the page?

Close the example. Reconstruct the core idea, then change one assumption. Mark complete when you’re ready; you can always undo it.

Stored in this browser only. No account, no sync. Clearing browser data removes your record.