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.
- • Run
pnpm buildand check output sizes - • Use dynamic
import()to split routes - • Prefer ESM-only packages when given a choice
- • Diagnose tree-shaking failures via bundle stats
- • Mark your own package's
sideEffectshonestly - • Tune
manualChunksto 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
- • 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
- • Diagnose HMR misses by inspecting the websocket payload
- • Tune
optimizeDeps.include/excludefor 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'],
},
},
},
},
});
- • Enable source maps for production
- • Set long cache life on hashed assets, short on index.html
- • Use
pnpm previewto test the build locally before deploy
- • Configure
manualChunksdeliberately 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; }
- • Use VITE_-prefixed env vars for public configuration
- • Add a vite-env.d.ts for type-safe
import.meta.env - • Never commit
.env.local
- • Use runtime config endpoint for true per-environment values
- • Validate env at build time with a Zod schema in the config
- • Use
loadEnvfromvitein 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 },
},
},
});
- • 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
- • 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 inpublic/are copied verbatim to the build output root. The URL is the path relative topublic(<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.
- • Import assets that your code references
- • Put favicon, robots, sitemap in public/
- • Don't mix the two modes for the same file
- • Use
?urland?rawquery suffixes to force a specific import mode - • Set
basecorrectly 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 indist/.preview:vite preview— servesdist/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.
- • Memorise dev/build/preview
- • Run tsc --noEmit alongside vite build
- • Use scripts to hide ops commands
- • 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.
- • Commit the lockfile; never .gitignore it
- • Use
npm ci/pnpm i --frozen-lockfilein CI - • Pick one package manager per repo and stick to it
- • Read a lockfile diff in PRs to catch supply-chain surprises
- • Use pnpm workspaces with catalogs for monorepo version pinning
- • Cache
~/.local/share/pnpm/storein 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).
- • Know which bundler your framework uses
- • Don't hand-roll webpack configs in 2026
- • Use esbuild for tiny CLIs and Lambdas
- • 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. Useimport { debounce } from 'lodash-es'(note:-es, the ESM version). Better: installlodash.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": falseinpackage.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-commonjsconverts 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 }),
],
});
- • Install rollup-plugin-visualizer and look at the treemap
- • Use named imports from -es packages
- • Check bundle size after each dependency add
- • Mark your own
sideEffectscorrectly so consumers tree-shake - • Replace moment with date-fns or Temporal; remove icon sets you don't use
- • Audit transitive deps with
pnpm whyandnpm 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>;
}
- • Plain .css for global, .module.css for component-scoped
- • Tailwind via @tailwindcss/vite for utility-first styling
- • autoprefixer driven by browserslist in package.json
- • 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.
- • Enable sourcemap: 'hidden' in prod builds
- • Upload maps to your error monitor in CI
- • Tag releases with a stable identifier
- • 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-tsgives you React + TypeScript with sensible defaults.pnpm create vitedoesn't install dependencies; do that manually so you control the package manager.- The first
pnpm devruns Vite's dependency pre-bundling — slower than subsequent starts. pnpm previewis 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),
},
};
});
loadEnvreads.env*files server-side; pair with Zod to fail fast on missing vars.- The
@alias matches thepathssetting in tsconfig.json for clean imports. manualChunkskeeps third-party code in stable-name chunks so users only re-download when the chunk content changes.definelets 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'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'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 previewbefore 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. resolveIdclaims the import;loadproduces 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-iconsandunplugin-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. resolveIdclaims the import;loadproduces 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-iconsandunplugin-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 stagingloads.env.staging(and.env,.env.local).- Each environment gets its own artifact — deploy step downloads the matching one.
pnpm typecheckruns 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
-
Scaffold and ship. Goal: take a Vite + React + TS project from
createto deployed in under 30 minutes.pnpm create vite@latestwith the react-ts template.- Add a single page with one fetch from a public API (
https://api.github.com/users/octocat). - Configure a
vite.config.tswith a/apiproxy. pnpm buildand deploy to Vercel / Netlify / Cloudflare Pages (drag-and-drop thedist/folder).- You're done when: the live URL renders the data and
pnpm previewmatches.
-
Env var triangulation. Goal: distinguish build-time vs runtime config.
- Add three
.env*files:.env,.env.development,.env.productionwith one variable each. - Read all three in your app via
import.meta.env.MODEand display which file won. - Run
pnpm devandpnpm build && pnpm previewand observe the difference. - Add a
vite-env.d.tstyping for the three vars and confirm TS autocomplete. - You're done when: you can explain which file wins under which command without guessing.
- Add three
-
Bundle visualiser hunt. Goal: find and remove a ≥30 KB unused dependency.
- Install
rollup-plugin-visualizerand add it tovite.config.ts. - Build and open
dist/stats.html. - Identify a dependency you don't use (often a moment.js, a date-fns full import, an icon set).
- Remove or swap it (e.g., moment → date-fns; full lodash →
lodash.debounce). - You're done when: gzipped bundle size dropped by ≥30 KB without breaking anything.
- Install
-
HMR break-fix. Goal: cause and fix an HMR full-reload regression.
- In a Vite + React project, add a
console.log('top-level')at the top of a component file. - Save another file — HMR works fine.
- Now add
window.foo = 1at the top of the component file. Save and watch HMR fall back to a full reload. - Move the side-effect into a
useEffectand confirm HMR recovers. - You're done when: you can predict which patterns break HMR without running them.
- In a Vite + React project, add a
-
CI install hygiene. Goal: convert a CI workflow to use
--frozen-lockfileand pnpm store cache.- Take a GitHub Actions / Azure Pipelines step that runs
pnpm install. - Change it to
pnpm install --frozen-lockfile. - Add a cache step for
~/.local/share/pnpm/storekeyed onpnpm-lock.yaml. - Push a PR that intentionally drifts
package.jsonfrom the lockfile; observe the CI failure. - You're done when: CI fails on lockfile drift and second-run install time drops by half.
- Take a GitHub Actions / Azure Pipelines step that runs
-
Code-split a route. Goal: turn an eager-loaded heavy route into a lazy chunk.
- Find a route component that pulls in a chart library or a code editor (≥80 KB).
- Replace
import Foo from './Foo'withconst Foo = lazy(() => import('./Foo')). - Wrap the route in
<Suspense fallback={<Spinner />}>. - Build and confirm the chunk appears as a separate file with its own hash.
- You're done when: initial bundle is smaller and the chart route fetches the chunk on demand.
-
Custom plugin: git SHA banner. Goal: write a 20-line Vite plugin that injects the current git SHA.
- Read
git rev-parse --short HEADat config time. - Use
define: { __COMMIT__: JSON.stringify(sha) }so__COMMIT__is inlined. - Render it in the app footer.
- Verify the SHA appears in the built bundle (
grep __COMMIT__ dist/assets/*.jsshould not match — it's been replaced). - You're done when: every prod build embeds its source commit.
- Read
-
Audit a real
package.jsonfor bloat. Goal: identify three dependencies that could be removed or shrunk.- Run
pnpm why <pkg>for the five largest top-level deps. - Check the bundle visualiser for each.
- Replace one full-import library with named imports (
lodash→lodash.debounce). - Replace one heavy library with a lighter alternative (
moment→date-fns). - You're done when: the bundle drops by another 30 KB and your dependency count is lower.
- Run
Self-check questions
- What does Vite serve to the browser in dev, and how is that different from a webpack dev server?
- Why does Vite pre-bundle npm dependencies but not your source code in dev?
- What is HMR, and what makes a file fall back to a full reload?
- Difference between
import.meta.envandprocess.env. When is each available? - Why are
VITE_*env vars not secret? - When would you put a file in
public/vs import it? - Difference between
pnpm installandpnpm install --frozen-lockfile. Where does each belong? - Why is pnpm's
node_moduleslayout different from npm's, and what bugs does it prevent? - What is tree shaking? Name three things that prevent it from working.
- What does
manualChunksgive you that the default chunking doesn't? - Why do you usually need
tsc --noEmiteven when Vite's build "compiles" your TS? - When would you use esbuild directly instead of Vite?
- What is a source map, and why do you ship them in production?
- What is a virtual module in a Vite plugin, and why does the id start with a null byte?
- Why does Vite use Rollup for production but esbuild for dev?
- What does
pnpm why <package>tell you, and when would you reach for it? - Explain
manualChunkscache invalidation: why is it a vendor-caching strategy and not a perf strategy? - What does
--mode stagingdo, and which files does it pick up?
High-signal resources
Official docs
- Vite Guide — read it cover-to-cover; it's under an hour.
- 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 Internals — annotated walkthrough of how Vite is built.
- Frontend Master's Build Tooling course by Jem Young.
Practitioner posts
- Evan You — Why Vite — the original case.
- Anthony Fu — Reinventing Modules and unplugin posts.
- Lee Robinson — npm vs pnpm vs Bun benchmark (or the equivalent current post).
- Jason Miller — Differential Loading is dead, long live ESM on bundler evolution.
- Sebastian Markbåge — React + Code-splitting at scale (search for code-splitting posts).
- Tobias Koppers — webpack 5 release post — the canonical voice on what bundlers actually do.
Weekly milestones
- Day 1 — Read the Vite Guide. Do Exercise 1.
- Day 2 — Read Vite's "Env Variables and Modes" + "Dependency Pre-Bundling". Do Exercise 2.
- Day 3 — Read about Rollup tree shaking and
sideEffects. Do Exercise 3. - Day 4–5 — Do Exercises 4 and 6. Answer self-check 1-7.
- 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