Vite Configuration & Ecosystem

Vite is two bundlers wearing one config file. In development it serves your source as native ES modules and uses esbuild only to pre-bundle dependencies; in production it hands the graph to Rollup for tree-shaking, chunking, and minification. Almost every confusing Vite behaviour — a plugin hook firing at the wrong moment, an import.meta.env value that exists in dev but not in the build, a dependency that works on the dev server and explodes during vite build — traces back to the fact that these two engines have different module resolution, different transform pipelines, and different caches. This overview maps the whole configuration surface, names the concrete engine responsible for each behaviour, and links out to the deep-dive guides for plugins, environment modes, the dev server, SSR/SSG, and library packaging.

The goal here is to give you a mental model precise enough that you can predict which engine handles a given file, then reach for the right guide to tune it. The sections below follow the order you actually hit problems in: configuration architecture, the plugin pipeline, environment and build modes, the dev server and HMR, SSR/SSG, and library mode. A decision matrix, a performance-and-observability section, and a future-trajectory note (Rolldown, Oxc, the Environment API) round it out, followed by an implementation checklist you can paste into a PR description.

Vite dual-engine architecture Source enters a shared plugin pipeline, then splits into an esbuild-powered dev server serving native ESM and a Rollup-powered production build. Source modules .ts .vue .jsx css Plugin pipeline resolveId / load transform (enforce) Dev: esbuild pre-bundle CJS to ESM .vite/deps Native ESM + HMR socket Build: Rollup Tree-shake chunk split Minify + hash dist/ assets dev build
Figure: one config, two engines — a shared plugin pipeline feeds esbuild-driven dev serving and a Rollup production build.

Core configuration architecture

The vite.config.ts file is the declarative control plane for module resolution, the dependency graph, and environment branching. Wrap it in defineConfig() so TypeScript infers the full schema and gives you autocomplete on nested keys like resolve.alias, optimizeDeps, and build.target. The config can also be a function — defineConfig(({ command, mode }) => ({ … })) — which is how you branch behaviour between vite serve and vite build without duplicating the file. Modern configs (Vite 5 and 6) assume native ESM throughout; authoring the config itself as .ts with top-level ESM imports is the default, and CommonJS interop only surfaces when a dependency ships CJS.

That CJS-to-ESM conversion is the job of dependency pre-bundling. On the first dev start, esbuild scans your entry HTML, finds bare imports into node_modules, converts any CommonJS or UMD packages into a single optimized ESM chunk per dependency, and caches the result in node_modules/.vite/deps. This is what gives Vite its sub-second cold start — esbuild is roughly 10–100x faster than a JavaScript-based transpiler — but the cache is also a frequent source of confusion. When a dependency is added, removed, or its lockfile hash changes, Vite re-runs the scan; if you see a full page reload with “new dependencies optimized” in the log, that is the pre-bundler re-running. Use optimizeDeps.exclude for packages that are already valid ESM and should not be pre-bundled, optimizeDeps.include to force-bundle a deep import that the scanner misses, and delete .vite/deps (or pass --force) when the cache desyncs in a monorepo.

Plugin pipeline and hook order

Vite plugins are a superset of Rollup plugins: every standard Rollup hook (resolveId, load, transform, renderChunk) runs in both dev and build, and Vite adds its own (config, configResolved, transformIndexHtml, handleHotUpdate). The two properties that govern execution order are enforce ('pre' runs before core plugins, 'post' runs after) and apply ('serve' or 'build' restricts a plugin to one engine). Getting these wrong is the most common plugin bug — a transform that depends on another plugin’s output but runs first will silently see the original source. The ordering rules, virtual-module conventions (the \0 null-byte prefix on resolved ids), and cross-plugin communication patterns are covered in depth in advanced Vite plugin configuration, and the specific case of pinning enforce and apply for a misbehaving chain is walked through in debugging Vite plugin hook order.

Framework plugins are where hook order matters most. @vitejs/plugin-react injects JSX runtime and Fast Refresh transforms; @vitejs/plugin-vue runs SFC compilation; @vitejs/plugin-react-swc swaps Babel for a Rust SWC core and typically cuts HMR transform time by 20–30% on large component trees. These must sit in the right slot in the plugins array relative to any enforce: 'pre' transform you author. Teams arriving from Webpack usually find that most of their loader chain collapses into two or three Vite plugins; the full porting path, including how resolve.alias replaces resolve.modules and how define replaces DefinePlugin, is laid out in migrating from Webpack 5 to Vite.

Environment variables and build modes

Vite resolves env files by mode with a strict precedence: .env.[mode].local beats .env.[mode] beats .env.local beats .env. Only variables prefixed VITE_ are exposed to client code through import.meta.env, and they are statically replaced at build time rather than read at runtime — which is exactly why a value that prints fine on the dev server can come back undefined in the production bundle if the prefix is missing or the variable is read dynamically. Type-safe access means augmenting ImportMetaEnv and adding /// <reference types="vite/client" />. The mode itself (development, production, or a custom --mode staging) drives both env-file selection and the import.meta.env.PROD/DEV booleans, so multi-environment setups should lean on mode rather than ad-hoc conditionals.

The mechanics of mode-specific overrides, how to keep secrets out of the client bundle, and the layered-file strategy for staging-versus-production are in environment variables and build modes in Vite, with the specific failure of import.meta.env coming back undefined in production builds given its own diagnosis guide. If you run several deployment targets, managing multiple env files across Vite environments covers the precedence edge cases that bite when .env.local leaks into CI.

Dev server and HMR

The dev server replaces the watch-and-rebuild loop with on-demand compilation. Source files are served as native ESM over HTTP, pre-bundled dependencies come from .vite/deps, and Hot Module Replacement runs over a persistent WebSocket: a chokidar file event triggers a targeted vite:beforeUpdate payload rather than a rebuild. The cost model is inverted from Webpack — startup is near-instant regardless of app size, but each requested module is transformed on first access, so a cold navigation to a deep route can feel slower than the homepage until the graph warms.

HMR quality depends on accept boundaries. A module that calls import.meta.hot.accept() becomes a boundary and updates in place; a module without one bubbles the update to its importers, and if nothing accepts it, Vite falls back to a full page reload that destroys component state. Two structural patterns force full reloads more than anything else: circular imports and barrel files that re-export an entire directory. Tuning WebSocket behaviour, server.warmup for critical entries, and the accept-boundary model is covered in optimizing the Vite dev server and HMR; the monorepo-specific slowdowns are in fixing slow Vite HMR in large monorepos, and the barrel-import reload trap is dissected in fixing Vite HMR full-reloads from circular barrel imports.

SSR and SSG

Server-side rendering forces you to confront the two-engine split directly, because the same module graph is compiled once for the browser and once for Node. The ssr.external and ssr.noExternal options decide which dependencies stay as Node require/import calls versus getting bundled into the server build; a misconfigured external array is the usual cause of double-bundled CSS-in-JS or a missing peer dependency at runtime. The build.ssrManifest flag emits a map from modules to their CSS and async chunks so the server can inline critical styles before client JS hydrates, which is the mechanism behind avoiding a flash of unstyled content.

A complete dual-build setup — separate client and server Rollup outputs, the dev middleware via vite.ssrLoadModule, and a production Express entry — is documented in Vite SSR and SSG integration, with a worked Express and Node.js SSR configuration you can lift directly. The class of bug where server and client markup disagree is its own beast; fixing hydration mismatch errors in Vite SSR covers the date, locale, and conditional-render causes that produce Hydration completed but contains mismatches.

Library mode and package bundling

When you are shipping a package rather than an app, build.lib reconfigures Vite into a deterministic library bundler. You set build.lib.entry, choose formats from ['es', 'cjs', 'umd'], and — critically — list your peer dependencies in build.rollupOptions.external so React, Vue, or whatever the consumer already has does not get bundled in and duplicated. Library mode also turns off most app-oriented defaults: no HTML entry, no asset hashing by default, and a single CSS file emitted alongside the JS. Type declarations are not produced by Vite’s core, so you bolt on vite-plugin-dts or run tsc --emitDeclarationOnly as a parallel step.

The full library workflow — entry and format selection, exports map wiring in package.json, and why UMD globals still matter for CDN distribution — is in Vite library mode and package bundling. The single most common library mistake, bundling a peer dependency and causing “two copies of React” errors in the consumer, has a dedicated walkthrough in externalizing peer dependencies in Vite library mode. For exact version pins — which Vite major needs which Node version, which Rollup version ships inside each Vite release, and which plugins break across majors — consult the Vite version compatibility reference before you upgrade.

Decision matrix: Vite vs Rollup vs esbuild vs Turbopack

Vite is not always the answer, and knowing when to drop to a lower-level tool keeps your build honest. The trade-off is roughly: Vite for applications with a dev server, raw Rollup for libraries that need fine-grained output control, raw esbuild for build scripts and CLIs where you want one fast pass, and Turbopack when you are already inside Next.js.

Tool Best for Dev server / HMR Output control Speed profile
Vite SPAs, SSR apps, most libraries Native ESM + HMR, fast Rollup-level via rollupOptions Instant start, on-demand transform
Rollup Libraries, custom build graphs None (build only) Highest; full plugin and output API Slower, fully optimized output
esbuild Build scripts, CLIs, transpile passes Basic serve/watch only Limited chunking and code-splitting Fastest single pass; weaker tree-shaking
Turbopack Next.js apps Incremental, Next-integrated Framework-managed Fast incremental rebuilds, Next-coupled

For applications, the deciding question is whether you need a dev server with HMR — if yes, Vite wins on developer experience while still giving you Rollup’s output API underneath. For libraries, the question is how much you need to shape output chunks and the exports map; library mode handles the common case, but a complex multi-entry package sometimes justifies driving Rollup directly. esbuild as a standalone tool shines for one-shot transforms and tooling scripts where its weaker tree-shaking does not matter; the lower-level esbuild and Turbopack workflows live in their own esbuild and Turbopack workflows overview. The bundler-agnostic foundations — what tree-shaking and ESM/CJS interop actually do under any of these tools — are in core concepts of modern bundling.

Performance and observability

Vite’s performance story has two halves: dev-server responsiveness and production bundle quality. On the dev side, the metrics that matter are cold-start time (dominated by the first esbuild dependency scan), warm-start time (which should be near-instant once .vite/deps is populated), and HMR update latency (the gap between save and browser repaint). Watch the server log for repeated “optimized dependencies changed, reloading” lines — that is the pre-bundler thrashing, usually from a dependency that should be in optimizeDeps.exclude or from an unstable lockfile in CI.

On the production side, instrument the Rollup build. Pass --profile or wire up rollup-plugin-visualizer to produce a treemap of the output, then set size budgets in CI so a stray import of a heavy library fails the build instead of shipping. Track total bundle size, the largest chunk, and the number of chunks (excessive chunking inflates HTTP requests; too few defeats caching). build.reportCompressedSize adds gzip/brotli numbers to the build output but slows large builds, so disable it in CI once budgets are enforced elsewhere. The two engines have different tree-shaking behaviour — esbuild’s is shallower than Rollup’s — which is why a dependency can look fine in dev and only reveal dead-code bloat in the production analysis. Pin versions deliberately and check the Vite version compatibility reference when a build regresses after an upgrade.

Future trajectory: Rolldown, Oxc, and the Environment API

The most consequential change on Vite’s roadmap is the move to a single bundler for both dev and build. Rolldown — a Rust port of Rollup’s API built on the Oxc toolchain — is being integrated as Vite’s bundler, which will eventually collapse the esbuild-in-dev / Rollup-in-build split that this entire overview is organized around. The transitional package, rolldown-vite, lets teams opt in early; it keeps the Rollup-compatible plugin interface while replacing the engine, so most plugins continue to work, but anything that depended on esbuild-specific pre-bundling quirks should be tested. Oxc (the Oxidation Compiler) also supplies a faster parser, resolver, and transformer that Vite is adopting incrementally, which is why some transform paths already feel faster on recent versions.

The other significant addition is the Environment API, which generalizes the client/SSR split into a first-class concept of named build environments. Instead of the binary ssr boolean, a config can declare multiple environments (client, SSR, edge, worker) each with their own module graph, resolution, and plugins — which is how Vite is positioning itself for edge runtimes and multi-target builds without bolt-on hacks. None of this changes the configuration surface described above in a breaking way today, but it does mean that the “two engines, one config” model is a snapshot of the current major, not a permanent law. Track the Vite version compatibility reference for when rolldown-vite stops being opt-in.

Implementation checklist

  • Wrap the config in defineConfig() and use the function form to branch on command/mode instead of duplicating files.
  • Audit optimizeDeps.include/exclude so the dev server stops re-running the dependency scan; delete .vite/deps or pass --force when the cache desyncs.
  • Set enforce and apply explicitly on every custom plugin; verify hook order against framework plugins.
  • Prefix all client-exposed env vars with VITE_, augment ImportMetaEnv, and confirm values survive the production build (not just dev).
  • Add import.meta.hot.accept() boundaries at component/module entry points; eliminate circular barrel imports that force full reloads.
  • For SSR, get ssr.external/ssr.noExternal right and emit build.ssrManifest to inline critical CSS.
  • For libraries, list every peer dependency in rollupOptions.external and generate type declarations with vite-plugin-dts or tsc --emitDeclarationOnly.
  • Wire rollup-plugin-visualizer and CI size budgets; disable build.reportCompressedSize once budgets are enforced.
  • Pin Vite, Rollup, and plugin versions against the compatibility reference before any major upgrade; test rolldown-vite in a branch.

Explore Topics

Advanced Vite Plugin Configuration

Vite’s plugin architecture extends the Rollup interface with dev-server-specific hooks, environment-aware execution contexts, and a tightly …

  • Debugging Vite Plugin Hook Order with enforce and apply
  • Migrating from Webpack 5 to Vite
  • Writing a Custom Vite Plugin for Asset Transformation

Environment Variables and Build Modes in Vite

This guide isolates the lifecycle of environment variables, mode resolution, and the secure injection boundary in Vite. For how the build pi…

  • Fixing import.meta.env Undefined in Production Builds
  • Managing Multiple .env Files Across Vite Environments

Optimizing Vite Dev Server and HMR

Development velocity in a Vite project is bounded by two numbers: cold-start time before the first byte renders, and the round-trip latency …

  • Fixing Slow Vite HMR in Large Monorepos
  • Fixing Vite HMR Full Reloads from Circular Barrel Imports

Vite Library Mode and Package Bundling

Shipping a reusable component or utility package is a different problem from shipping an application: the output is consumed by another bund…

  • Externalizing Peer Dependencies in Vite Library Mode

Vite SSR and SSG Integration

Vite’s server-side rendering (SSR) and static site generation (SSG) workflows split a single application into two bundles compiled from one …

  • Configuring Vite SSR with Express and Node.js
  • Fixing Hydration Mismatch Errors in Vite SSR

Vite Version Compatibility Reference

This page pins which Vite major works with which Node runtime, which Rollup version it vendors, and which @vitejs/plugin-react/@vitejs/plugi…