Chapter 17
Vite Deep Dive
Vite for React 19.2 — dev-server internals, Rollup production builds, plugins, env vars, path aliases, SVG modes, SSR/SSG mode, and debugging Vite itself.
Published 2026-05-23
🎯 Chapter Goal — After this chapter you can explain how Vite serves a 1,000-module SPA in roughly half a second of cold start, configure it for a real React 19.2 + TypeScript project (SVG, env vars, path aliases, legacy fallback), tune the production bundle, and pick the six plugins that matter — knowing what each one actually does at build time.
🧭 Prerequisites — Node 22 LTS + npm 10. Ch 16 (bundler landscape) for context. Comfort with native ES modules and dynamic
import(). Sample project for this chapter:examples/ch17-vite/bootstrap-to-ship/.
🔹 17.1 Dev-server architecture
Vite’s developer experience is built on one trick: don’t bundle in development. Where CRA / Webpack 4 traversed the whole module graph and produced one big JS blob before serving the first byte, Vite hands the browser native ES modules and lets the browser request what it needs, when it needs it.
That’s the headline. The reality is two pipelines glued together.
Two pipelines, one server
Pipeline 1 — Dependency pre-bundling (runs once)
Anything under node_modules/ gets pre-bundled by esbuild on first run. Why: most npm packages still ship CommonJS, and the browser can’t import CJS. esbuild converts them to ESM and caches the output at node_modules/.vite/deps/. It also flattens deep dependency trees (a package with 200 transitive imports becomes one file) so the browser doesn’t open 200 HTTP requests.
Pipeline 2 — Source-file serving (runs per request)
When the browser requests /src/App.tsx, Vite reads the file, runs it through esbuild’s transformer (TS → JS, JSX → _jsx calls), rewrites any non-relative imports to point at the pre-bundled deps, and streams the result back. No bundling. No file-system traversal of your whole project. Just the one file the browser asked for.
Browser ──GET /src/App.tsx──▶ Vite ──esbuild transform──▶ JS
│
├──▶ /node_modules/.vite/deps/* (pre-bundled, cached)
└──▶ HMR over WebSocket on file change
The HMR layer sits on top: when a file changes, Vite computes the minimal set of modules to invalidate via the import-graph and pushes only those updates over a WebSocket. A 1,000-file SPA reloads one module on most edits.
See it for yourself
📄 examples/ch17-vite/bootstrap-to-ship/ — run with the resolve debugger on:
DEBUG=vite:resolve npm run dev
You’ll see every resolution decision in the terminal — which imports went to pre-bundled deps, which went to source files, which triggered a fresh pre-bundle. Useful when “why is this package making me reload?” is the question.
The CJS-only-package trap
optimizeDeps.include is the lever for packages Vite can’t auto-detect. The classic symptom: a dependency works on first load, then a full reload fires on every other navigation.
📄 vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
optimizeDeps: {
include: ['some-cjs-only-pkg', 'some-cjs-only-pkg/submodule'], // ← force pre-bundle
},
});
⚠️ Barrel files (a single index.ts re-exporting everything from a folder) defeat pre-bundling. If you import { get } from 'lodash', Vite has to load all of lodash to find get. Use import get from 'lodash/get' or, better, lodash-es.
🔹 17.2 Production build — Rollup, code-splitting knobs
Production is a different bundler. Dev uses esbuild + native ESM; npm run build uses Rollup. The two pipelines share plugin hooks (which is why most plugins work in both) but make different decisions about chunking, asset hashing, and tree-shaking.
The first time a team learns this is usually after a “works in dev, broken in prod” bug. Internalize it now: every claim about Vite in this chapter applies to one of two pipelines, not both.
What Rollup does, by default
For a typical React SPA with React Router, Vite’s Rollup config produces:
- One
index-[hash].js— the app entry. - One chunk per dynamic
import()boundary (typically: one per route). - Static assets emitted to
dist/assets/with content-hashed names. - A
dist/index.htmlrewritten to point at the hashed entry.
That’s the baseline. Two knobs you’ll touch:
manualChunks — vendor splitting
📄 examples/ch17-vite/bootstrap-to-ship/vite.config.ts
export default defineConfig({
// …
build: {
rollupOptions: {
output: {
manualChunks: {
'react-vendor': ['react', 'react-dom'],
router: ['react-router'],
},
},
},
},
});
What this buys: when you ship a JS-only change, the browser still serves react-vendor-[hash].js from cache because its hash didn’t change. Without manualChunks, every redeploy busts the React cache.
┌─ dist/assets after build ─────────────────────────────────┐
│ index.[hash].js 18 kB │
│ react-vendor.[hash].js 42 kB ◀── stable across deploys│
│ router.[hash].js 11 kB │
│ Home.[hash].js 3 kB │
│ Invoices.[hash].js 4 kB │
│ Tenants.[hash].js 2 kB │
│ Settings.[hash].js 2 kB │
└───────────────────────────────────────────────────────────┘
⚠️ Over-splitting is real. Eight 2 KB chunks is more wall-clock time than one 16 KB chunk on a phone. The visualizer (next subsection) is how you tell.
Tree-shaking discipline
Three rules:
- Named imports.
import { format } from 'date-fns'shakes;import dateFns from 'date-fns'; dateFns.format(…)does not. sideEffects: falsein the dep’spackage.json. You can’t fix this from your side; you can pick deps that ship it (most modern ones do).- No top-level mutation in your source. A file that calls a function at import time is “side-effecting” and Rollup keeps the whole file. Wrap in a function and call from the consumer.
Source maps
build.sourcemap accepts three useful values:
false— no maps. Smallest output. Production stack traces are unreadable.'hidden'— emits maps but doesn’t reference them from the JS. Your error-reporting tool (Sentry, Datadog) uploads them; users never download them. The right default for production.'inline'— base64-embedded in the JS. Convenient for previews. Never ship to production — doubles your bundle.
The bundle visualizer
npm run analyze
This runs vite build --mode analyze, which (in the example’s config) activates rollup-plugin-visualizer. The browser opens to a sunburst diagram of dist/. Drill into the biggest slice; that’s usually where to start.
┌─ Visualizer ── dist/assets ────────────────────────────────────┐
│ ●●●●●●●●●●●●●●●●●●●● react-vendor 42 kB │
│ ●●●●●●●● Invoices 28 kB │
│ ●●●●●●● Tenants 24 kB │
│ ●●●●● router 11 kB │
│ ●●●● index 18 kB │
└─────────────────────────────────────────────────────────────────┘
▲ ▲
click a slice for the per-file breakdown gzip / brotli toggle
🔹 17.3 Plugins worth knowing
For a real SPA you need six plugins. Not eighty.
| Plugin | What it does |
|---|---|
@vitejs/plugin-react | React Fast Refresh + Compiler hook (Ch 2) |
vite-tsconfig-paths | Honor paths from tsconfig.json (next section) |
vite-plugin-svgr | Import SVGs as React components (§17.6) |
@vitejs/plugin-legacy | IE11 / older-Safari fallback (only when you must) |
rollup-plugin-visualizer | Bundle map (Ch 31) |
vite-plugin-checker | TS + ESLint in the dev overlay |
📄 examples/ch17-vite/bootstrap-to-ship/vite.config.ts — five of the six wired up (no legacy plugin; this app targets evergreen browsers).
⚠️ Plugins that seem useful but are usually a smell:
- CSS-in-JS Babel plugins in a Vite project — Vite handles CSS natively; you’re probably fighting your tooling.
vite-plugin-htmlfor templatingindex.html— for a single-app SPA, hand-editindex.html. The plugin matters in multi-page builds.@rollup/plugin-commonjsadded by hand — Vite already runs CJS interop in pre-bundling. Adding the Rollup plugin manually double-converts and causes weird errors.
🔹 17.4 Env handling
Vite exposes env vars via import.meta.env. Three rules and a recipe.
Rule 1 — only VITE_ prefix variables are exposed. Anything else is filtered out at build time so you can’t accidentally ship secrets to the browser.
Rule 2 — .env files load in a fixed precedence. .env.local overrides .env; .env.production only loads in production mode; .env.production.local overrides all of those.
Rule 3 — process.env does not exist in the browser. Vite replaces process.env.NODE_ENV for compatibility, but anything else is undefined. Migrating from CRA, this is the #1 footgun.
The recipe — validate at boot
📄 examples/ch17-vite/bootstrap-to-ship/src/config/env.ts
import { z } from 'zod';
const Schema = z.object({
VITE_API_BASE_URL: z.string().url(),
VITE_FEATURE_TENANT_SWITCHER: z.enum(['on', 'off']).default('off'),
});
const parsed = Schema.safeParse(import.meta.env);
if (!parsed.success) {
console.error('Invalid env:', parsed.error.flatten().fieldErrors);
throw new Error('Environment misconfigured — see console');
}
export const env = parsed.data;
The whole app imports env from this module. The validator runs once at module load. A typo in .env.local is a crash at boot, not a runtime explosion three pages deep.
define for build-time constants
When you need a value baked into the JS at build time (not read at runtime):
// vite.config.ts
export default defineConfig({
define: {
__BUILD_SHA__: JSON.stringify(process.env.GITHUB_SHA ?? 'dev'),
},
});
Now any __BUILD_SHA__ reference in your source is literally replaced at build time. Useful for a build banner in the footer; useless if you want runtime configurability.
🔹 17.5 Path aliases
Two configs that must stay in sync — or one plugin that keeps them in sync.
📄 examples/ch17-vite/bootstrap-to-ship/tsconfig.json
{
"compilerOptions": {
// …
"baseUrl": ".",
"paths": {
"@/*": ["src/*"] // ← TypeScript knows about @/
}
}
}
📄 examples/ch17-vite/bootstrap-to-ship/vite.config.ts
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [react(), tsconfigPaths()], // ← Vite reads paths from tsconfig
});
That’s the whole pattern. vite-tsconfig-paths reads tsconfig.json at startup and registers the same aliases with Vite’s resolver. No duplicated config.
⚠️ Vitest needs the same alias resolution. If you have tsconfig.json set up but vitest run fails on @/ imports, add vite-tsconfig-paths to your Vitest config too (Ch 36 covers Vitest setup; until then, the symptom is “Cannot find module ’@/…’”).
🔹 17.6 SVGs and static assets — three import modes
Vite gives you three intentional ways to import an SVG. Pick by use case.
import logo from './logo.svg'; // URL string — resolved to a final hashed path
import logoUrl from './logo.svg?url'; // explicit URL — same as above, just explicit
import Logo from './logo.svg?react'; // React component (requires vite-plugin-svgr)
| Mode | Output | Best for |
|---|---|---|
| Default | URL | <img src={logo}>, CSS background-image |
?url | URL (explicit) | Same; use when default mode is overloaded |
?react (svgr) | Inline React component | Icons that take a color / size prop |
📄 examples/ch17-vite/bootstrap-to-ship/src/components/Logo.tsx
import LogoSvg from '@/assets/logo.svg?react';
type Props = { size?: number };
export const Logo = ({ size = 32 }: Props) => (
<LogoSvg width={size} height={size} aria-label="Acme" role="img" />
);
Bundle implications: ?react inlines the SVG markup in your JS, so the SVG ships inside the chunk that imports it. Great for tiny icons (cheaper than a network request); wasteful for a 40 KB illustration that only one route uses. Default (URL) mode keeps the SVG as its own file with its own hash.
🔹 17.7 SSR / SSG mode
A pure SPA doesn’t need any of this. But the day a marketing team asks for SEO-friendly landing pages, you’ll want to know it exists.
Vite SSR API — vite.ssrLoadModule(). Runs modules in a Node context with browser globals shimmed. The starting point if you’re rolling your own SSR.
vite-ssg — turns a Vite SPA into a static site by pre-rendering every route at build time. The HTML is generated; the JS still hydrates client-side. Practical answer for “make this SPA crawlable” without restructuring as Next/Astro.
The full SSG / hybrid story is Part 6 (Ch 22–24). Cross-link there if and when the question comes up.
🔹 17.8 Debugging Vite itself
Three commands that pay off:
DEBUG=vite:* npm run dev # everything
DEBUG=vite:resolve npm run dev # just import resolution
DEBUG=vite:hmr npm run dev # just HMR decisions
npm run dev -- --force # clear node_modules/.vite, re-pre-bundle
npm run dev -- --debug --profile # write a startup profile to disk
When to --force: after a dep bump that didn’t trigger re-pre-bundling automatically. Symptoms: an import that works in node_modules/ doesn’t resolve, or a stale CJS shim gets served. Clear and rebuild.
Reading dependency-graph errors: Vite’s error messages list “Import trace for requested module” — read it top-down. The first entry in the trace is the import that’s broken; the rest is who pulled it in. The bottom of the trace is where you can probably make the change.
🪤 Common Pitfalls
- Forgetting the
VITE_prefix. The env var isundefinedat runtime and you wonder why. Verify withconsole.log(import.meta.env)once. - Importing barrel files in dev.
import { get } from 'lodash'pulls the whole library through pre-bundling and HMR. Uselodash-es/getorimport get from 'lodash/get'. process.env.Xin browser code. Works in dev (Vite shimsprocess.env.NODE_ENVonly), breaks in prod. Useimport.meta.env.manualChunksover-splitting. Twenty 2 KB chunks is slower than five 8 KB ones on slow connections. Visualize, don’t guess.- Forgetting
--hoston WSL or in a container. Without it the dev server binds tolocalhost, which the host machine can’t reach. Setserver.host: trueinvite.config.tsonce. @vitejs/plugin-legacyon modern-only deps. The plugin produces an ES5 polyfill build for old browsers; if a dep uses ESM-only features, the legacy build silently breaks at runtime. Only add the legacy plugin when you have a measured user base on old browsers.- Editing
tsconfig.jsonpaths withoutvite-tsconfig-paths. TS resolves it, Vite doesn’t, build crashes. Use the plugin once and stop maintaining two configs. - Importing
.svg?reactwithoutvite-plugin-svgrinstalled. Default Vite treats?reactas just another query string and returns a URL — your component renders[object Object]or similar.
✅ Recap
- Two pipelines: dev (esbuild + native ESM) and prod (Rollup). Most “works in dev, breaks in prod” bugs trace to forgetting this.
- The dev server’s secret is not bundling — pre-bundle deps once, serve source files on demand, HMR via import-graph diff.
manualChunksplus content hashing gives long-cacheable vendor chunks across deploys.- Six plugins cover 95 % of SPA needs.
- Env vars need the
VITE_prefix and live onimport.meta.env. Validate at boot with Zod. - One config for path aliases —
tsconfig.json paths+vite-tsconfig-paths. - Three intentional SVG modes: URL, explicit URL, React component.
- Vite can do SSG via
vite-ssg; full coverage in Part 6.
🔗 Further Reading
- https://vitejs.dev/ — Vite docs (pin to current major at draft time).
@vitejs/plugin-react— Fast Refresh + Compiler integration.- Patrick Brouwer / Evan You — “Vite under the hood” talks; the canonical mental-model resources.
- Rollup
output.manualChunksreference. - https://rollupjs.org/plugin-development/ — plugin-hook reference shared with Vite.
npm run analyzecompanion: https://bundlejs.com/ for one-off size lookups.
In the book — not on the site
Each topic has an 🧠 Under-the-hood subsection — the algorithm, the data structures, what React DevTools surfaces, debugging recipes. Plus a 🧪 hands-on lab per chapter with a starter repo. Reserved for the book.