modern-react-spa

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.html rewritten 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:

  1. Named imports. import { format } from 'date-fns' shakes; import dateFns from 'date-fns'; dateFns.format(…) does not.
  2. sideEffects: false in the dep’s package.json. You can’t fix this from your side; you can pick deps that ship it (most modern ones do).
  3. 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.

PluginWhat it does
@vitejs/plugin-reactReact Fast Refresh + Compiler hook (Ch 2)
vite-tsconfig-pathsHonor paths from tsconfig.json (next section)
vite-plugin-svgrImport SVGs as React components (§17.6)
@vitejs/plugin-legacyIE11 / older-Safari fallback (only when you must)
rollup-plugin-visualizerBundle map (Ch 31)
vite-plugin-checkerTS + 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-html for templating index.html — for a single-app SPA, hand-edit index.html. The plugin matters in multi-page builds.
  • @rollup/plugin-commonjs added 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)
ModeOutputBest for
DefaultURL<img src={logo}>, CSS background-image
?urlURL (explicit)Same; use when default mode is overloaded
?react (svgr)Inline React componentIcons 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 APIvite.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

  1. Forgetting the VITE_ prefix. The env var is undefined at runtime and you wonder why. Verify with console.log(import.meta.env) once.
  2. Importing barrel files in dev. import { get } from 'lodash' pulls the whole library through pre-bundling and HMR. Use lodash-es/get or import get from 'lodash/get'.
  3. process.env.X in browser code. Works in dev (Vite shims process.env.NODE_ENV only), breaks in prod. Use import.meta.env.
  4. manualChunks over-splitting. Twenty 2 KB chunks is slower than five 8 KB ones on slow connections. Visualize, don’t guess.
  5. Forgetting --host on WSL or in a container. Without it the dev server binds to localhost, which the host machine can’t reach. Set server.host: true in vite.config.ts once.
  6. @vitejs/plugin-legacy on 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.
  7. Editing tsconfig.json paths without vite-tsconfig-paths. TS resolves it, Vite doesn’t, build crashes. Use the plugin once and stop maintaining two configs.
  8. Importing .svg?react without vite-plugin-svgr installed. Default Vite treats ?react as 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.
  • manualChunks plus content hashing gives long-cacheable vendor chunks across deploys.
  • Six plugins cover 95 % of SPA needs.
  • Env vars need the VITE_ prefix and live on import.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.manualChunks reference.
  • https://rollupjs.org/plugin-development/ — plugin-hook reference shared with Vite.
  • npm run analyze companion: 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.

Topics in this chapter (8)
  1. 17.1 Dev-server architecture
  2. 17.2 Production build — Rollup, code-splitting knobs
  3. 17.3 Plugins worth knowing
  4. 17.4 Env handling
  5. 17.5 Path aliases
  6. 17.6 SVGs and static assets — three import modes
  7. 17.7 SSR / SSG mode
  8. 17.8 Debugging Vite itself