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.

Why does this problem exist at all? The historical bundlers — Webpack, Parcel, Browserify — were built for a browser that could not load ES modules natively, so they had to concatenate the entire graph into one or more bundles before the first byte reached the browser, in both development and production. That upfront concatenation is the cost that grew linearly with codebase size and turned a 400-module app into a fifteen-second dev start. Vite’s founding bet was that modern browsers ship native <script type="module"> support, so in development the browser itself can be the module loader and the tool only has to transform each file the browser actually requests. That bet pays off enormously on cold start, but it means the code you run in development was never bundled the way it will be in production, and any behaviour that only emerges from bundling — tree-shaking, chunk boundaries, scope hoisting, minifier assumptions — is invisible until vite build. The two-engine design is the direct consequence: esbuild is fast enough to convert CommonJS dependencies on the fly for the unbundled dev server, but its output is not production-grade, so Rollup owns the shipped artifact.

What breaks when you ignore the split? The failure mode is always the same shape — something works in vite serve and fails in vite build, or vice versa — because the two engines disagree on module resolution, on how aggressively dead code is eliminated, on how import.meta is rewritten, and on which files even enter the graph. A component that renders in dev can throw undefined is not a function in the built bundle because esbuild kept a side-effectful import that Rollup tree-shook away. A plugin that transforms fine on the dev server can be skipped entirely in the build because it was implicitly ordered by request timing rather than by an explicit enforce. Treat every “works in dev, breaks in build” report as a two-engine mismatch until proven otherwise; the sections below name the concrete engine and cache behind each surface so you can localize the fault instead of guessing.

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.

There are two independent reasons pre-bundling exists, and conflating them causes most of the mistakes. The first is correctness: the browser cannot execute module.exports, so any CommonJS or UMD dependency has to be rewritten to ESM before it can be served as a native module — this is non-negotiable and applies to a single package. The second is performance: a package like lodash-es is authored as hundreds of tiny internal ES modules, and serving each one as a separate HTTP request would flood the browser with waterfalled round-trips, so esbuild flattens the whole package into one file to collapse those requests. This is why a valid-ESM but internally-fragmented dependency still benefits from pre-bundling even though it needs no format conversion. The cache key is a hash derived from your lockfile, the relevant optimizeDeps options, and a handful of config fields; when any of them changes Vite invalidates .vite/deps and re-optimizes, which is the mechanism behind the mid-session “new dependencies optimized, reloading” page reload. If that reload fires repeatedly during a normal edit loop, something is mutating an input to the hash — a post-install script rewriting a dependency, a lockfile churning in a container mount, or a glob in optimizeDeps.entries matching files that change on every save.

The scanner itself is worth understanding because it decides what gets pre-bundled and what is missed. Vite crawls from your HTML entry points, follows static import statements, and records every bare specifier. What it cannot see are dynamic imports built from runtime strings, dependencies only reached through a package that is itself excluded, and imports inside files the crawl never visits. Those are exactly the cases where you get a mid-session reload the first time the code path executes, because the dependency was discovered late. The fix is to name the missed specifier explicitly in optimizeDeps.include so it is bundled on the initial pass. A minimal but production-shaped config that exercises all three levers looks like this:

// vite.config.ts — Vite 6
import { defineConfig } from 'vite'

export default defineConfig(({ command, mode }) => ({
  optimizeDeps: {
    // force-bundle a deep import the static scan can't reach
    include: ['some-lib/dist/esm/submodule', 'react-dom/client'],
    // leave an already-optimized ESM package alone
    exclude: ['@my-scope/design-system'],
    // help the scanner find non-HTML entry points (e.g. a worker)
    entries: ['index.html', 'src/worker.ts'],
  },
  // branch real behaviour on the two engines instead of duplicating the file
  build: command === 'build' ? { sourcemap: mode !== 'production' } : {},
}))

Getting the cache wrong is not cosmetic. If a dependency is force-excluded but actually ships CommonJS, the browser receives raw require calls and throws require is not defined at load; if a large fragmented package is excluded, the dev server slows to a crawl under hundreds of module requests. The way to confirm which is happening is to open the Network tab and count requests into node_modules/.vite/deps versus raw node_modules paths — pre-bundled dependencies resolve to a single hashed file under .vite/deps, while an excluded package shows up as many individual source files.

The config as a function branching on command and mode defineConfig can take a function of command and mode; the serve branch drives the esbuild dev server and the build branch drives the Rollup build, while dependency pre-bundling caches CJS-to-ESM conversions in .vite/deps. defineConfig(({command,mode}) => …) command: serveesbuild dev server command: buildRollup build .vite/deps cacheCJS → ESM, keyed on lockfile
Figure: one function config branches by command; the pre-bundle cache is keyed on the lockfile hash.

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.

Vite plugins are a superset of Rollup plugins Every standard Rollup hook runs in both dev and build; Vite adds its own dev-oriented hooks such as config, configResolved, transformIndexHtml and handleHotUpdate on top. Rollup hooks (dev + build) resolveId · load · transform · renderChunk ordered by enforce, gated by apply Vite-only hooks config · configResolved transformIndexHtml · handleHotUpdate A Vite plugin = Rollup plugin + dev-server hooks
Figure: the Rollup hook set runs everywhere; Vite layers its dev-server hooks on top of it.

The reason enforce exists rather than plain array order is that Vite interleaves your plugins with its own internal ones. Regardless of where you place a plugin in the plugins array, the resolved order is: all enforce: 'pre' plugins in array order, then Vite’s core plugins, then plugins with no enforce, then all enforce: 'post' plugins. Within a single hook, resolveId and load are first-wins — the first plugin to return a non-null value ends the chain — while transform is sequential, every plugin receiving the previous one’s output. That asymmetry is the root of the classic bug: a transform that needs to run against raw source must be enforce: 'pre', because by default it runs after core plugins have already compiled the file into something it no longer recognizes. Conversely a transform that must see the fully-compiled output — a license-banner injector, a final minification pass — belongs at enforce: 'post'.

Virtual modules are the other mechanism worth internalizing, because they are how plugins inject code that has no file on disk. A plugin returns a synthetic id from resolveId (conventionally prefixed with virtual:), then serves its contents from load. Vite requires that the resolved id be prefixed with a null byte (\0) so that other plugins and the dev server know not to treat it as a real path and try to read it from the filesystem. Forgetting the \0 is a common failure: the module works until some other plugin or the file watcher tries to fs.stat the fake path and crashes. A minimal, correct virtual-module plugin looks like this:

// build-info-plugin.ts — Vite 6 / Rollup 4 plugin interface
import type { Plugin } from 'vite'

export function buildInfo(): Plugin {
  const virtualId = 'virtual:build-info'
  const resolvedId = '\0' + virtualId // null-byte prefix marks it synthetic
  return {
    name: 'build-info',
    enforce: 'pre', // resolve before core plugins claim the bare id
    resolveId(id) {
      return id === virtualId ? resolvedId : null
    },
    load(id) {
      if (id !== resolvedId) return null
      return `export const builtAt = ${JSON.stringify(new Date().toISOString())}`
    },
  }
}

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.

The consequence of getting hook order wrong is subtle because it usually does not crash — it silently no-ops. A transform that expected to rewrite import.meta.env.MY_FLAG but ran before the define-replacement plugin sees the original text and matches nothing, so the flag ships unreplaced and the feature never toggles. The way to confirm hook order at runtime is to add a console.log(this.getModuleInfo(id)) inside your transform, or run vite build --debug plugin-transform, which prints each plugin’s transform in the order it actually executed. If your plugin is not in the position you expect, set enforce explicitly rather than reordering the array, because array position does not survive Vite’s internal reshuffling.

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 VITE_ prefix gate Resolved env variables pass through a prefix gate: only VITE_-prefixed keys are statically inlined into client code via import.meta.env, while everything else stays server-only. resolved env set VITE_ prefix gate import.meta.envinlined into client server-onlynever shipped
Figure: the prefix is the whole security boundary — unprefixed keys never reach the client bundle.

The static-replacement mechanism is where the sharp edges are. import.meta.env.VITE_API_URL is not a property read at runtime; it is a textual token that the build replaces with the literal string value, the same way DefinePlugin worked in Webpack. That has three consequences that trip people up. First, you cannot compute the key — import.meta.env['VITE_' + name] defeats the static match and yields undefined in the build, even though the object-style access happens to work on the dev server where import.meta.env is a real populated object. Second, because the value is inlined as a string literal, a VITE_FEATURE_FLAG=false becomes the string "false", which is truthy; boolean env values must be compared explicitly (=== 'true') or coerced. Third, anything you did not prefix with VITE_ is simply absent from the replacement table, so it silently becomes undefined rather than throwing — which is why the missing-prefix bug survives all the way to production before anyone notices.

A type-safe, boolean-correct env access module makes both failure modes impossible to reintroduce:

// src/env.ts — Vite 6, requires vite/client types
/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_API_URL: string
  readonly VITE_ENABLE_ANALYTICS: string // env values are always strings
}
interface ImportMeta {
  readonly env: ImportMetaEnv
}

// coerce at one boundary so the rest of the app sees real types
export const env = {
  apiUrl: import.meta.env.VITE_API_URL,
  analytics: import.meta.env.VITE_ENABLE_ANALYTICS === 'true',
} as const

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.

The security boundary is the prefix, and it is easy to breach by accident. Because the entire VITE_-prefixed set is inlined into client JavaScript, any secret that picks up a VITE_ prefix — a database URL, an admin token, a private API key — is shipped verbatim in a .js file that anyone can view. The rule is mechanical: server-only secrets must never carry the prefix, and they should be read through process.env in server code (SSR entry, build scripts) where they stay in the Node process. To confirm a secret did not leak, grep the built output: grep -r "your-secret-value" dist/ should return nothing. Running that grep in CI as a hard gate is cheaper than rotating a credential after it ships in a source map.

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.

Inverted cost model: bundle-first versus on-demand A bundle-first dev server pays a large upfront build that grows with app size; Vite pays near-zero startup and instead transforms each module on first access, so a deep cold route warms up on demand. bundle-first large upfront build startup grows with app size then fast navigation Vite on-demand near-zero startup transform per module on first hit deep cold route warms up
Figure: the cost moves from a big upfront build to a per-module first-access transform.

Under the hood, the request path is straightforward once you see it. The browser requests a module by URL; Vite’s dev middleware runs the plugin transform chain for that one file, rewrites its bare imports to resolvable URLs (import x from 'react' becomes import x from '/node_modules/.vite/deps/react.js'), sets long-lived cache headers keyed on the file’s content, and streams it back. On a subsequent edit, chokidar fires a change event, Vite invalidates that module in its graph, computes the set of affected modules by walking importers upward until it reaches an accept boundary, and pushes a small JSON payload over the WebSocket telling the client exactly which module URLs to re-fetch. Nothing rebuilds; the client re-imports the changed module with a new query string to bust the browser cache. This is why HMR latency is a function of transform time for the changed file plus the depth to the nearest boundary, not of total app size.

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. Framework plugins install these boundaries for you — @vitejs/plugin-react makes every file exporting only components self-accepting via React Fast Refresh — which is why a file that also exports a non-component value (a constant, a hook factory, a context object) silently loses Fast Refresh and starts triggering full reloads. Two structural patterns force full reloads more than anything else: circular imports and barrel files that re-export an entire directory. A barrel (index.ts re-exporting fifty modules) makes every consumer depend transitively on all fifty, so editing any one of them invalidates the barrel and everything downstream of it, collapsing the accept-boundary calculation into a whole-subtree reload. 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.

To confirm you have a full-reload problem rather than a slow-transform problem, open the browser console: an in-place HMR update logs [vite] hot updated: /src/Foo.tsx, whereas a bubbled failure logs [vite] page reload with the file that could not be accepted. If you see the reload line on a component edit, the fix is almost always to split the offending file so components live in one module and non-component exports in another, or to warm the critical entries so the reload at least re-transforms quickly. For a self-managed accept boundary in a non-framework module, the pattern is explicit:

// store.ts — Vite 6 HMR API, accept your own updates to preserve state
export const store = createStore()

if (import.meta.hot) {
  import.meta.hot.accept((newModule) => {
    // migrate live state into the replacement module instead of reloading
    if (newModule) newModule.store.hydrate(store.snapshot())
  })
}

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.

One graph compiled for two targets SSR compiles the same module graph once for the browser and once for Node; ssr.external and ssr.noExternal decide which dependencies stay as runtime imports versus get bundled into the server build. one module graphcompiled twice browser buildhydrated client Node buildexternal / noExternal ssrManifestinline critical CSS
Figure: the same source becomes two builds; the manifest links them so critical CSS ships before hydration.

The mental model for external versus noExternal is worth stating precisely because the defaults invert between client and server. In a normal client build everything is bundled. In an SSR build the default flips: dependencies in node_modules are left external — kept as runtime import/require calls that Node resolves from disk — because Node can load them natively and bundling them wastes time and risks running the same module twice. You add a package to ssr.noExternal when it must be transformed by Vite’s pipeline before Node runs it: anything shipping raw .vue/.jsx/.ts, anything importing CSS or other assets Node cannot parse, and packages published only as ESM that a CommonJS server context cannot require. You add a package to ssr.external in the rarer case where a dependency is being bundled but should not be — usually to break a duplicate-instance bug. Getting noExternal wrong produces the two signature failures: a raw SyntaxError: Cannot use import statement outside a module when an ESM-only package was left external, or ERR_UNKNOWN_FILE_EXTENSION .vue when a component library was left external and Node tried to import a source file it cannot parse.

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.

Hydration mismatches deserve a mechanism note because the error message is famously unhelpful. Hydration is React (or Vue) attaching event listeners to server-rendered DOM while assuming the DOM it renders on the client is byte-identical to what the server produced. Any value that differs between the two environments at render time — Date.now(), Math.random(), a locale-formatted number, window-gated conditional rendering, a user’s timezone — makes the trees diverge, and the framework discards the server markup and re-renders from scratch, throwing away the SSR performance benefit and often flashing incorrect content. The root cause is always non-determinism at render time, and the fix is to make the first client render deterministic: render the server-safe value first and move the environment-specific value into an effect that runs only after hydration. Confirm the fix by rendering with JavaScript disabled — the server HTML you see should match the first paint with JavaScript enabled.

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.

build.lib turns off app-oriented defaults Library mode flips application defaults: no HTML entry, peers externalized rather than bundled, stable filenames instead of hashing, and a single emitted CSS file, with type declarations added by a separate tool. no HTML entryentry is a module peers externalizedno duplicate React stable filenamesno hashing single CSS fileno code-split types via vite-plugin-dtsseparate pass
Figure: five defaults flip for packages; type declarations are the one thing Vite core never emits.

The externalization mechanism is the part people get wrong, so it is worth spelling out how Rollup decides. Anything listed in rollupOptions.external is left as a bare import in the output instead of being inlined; the consumer’s own bundler resolves it against their node_modules. If you forget to externalize react, Rollup copies React’s source into your bundle, and when the consuming app also imports React you end up with two React instances that do not share the same module-level useState dispatcher — the runtime throws Invalid hook call or hooks silently misbehave. The rule is that every entry in your package’s peerDependencies must appear in external, and for UMD output you additionally map each external to a global variable name so the browser build can find it on window. A regex-based external matcher also catches deep imports like react-dom/client, which a plain string list misses:

// vite.config.ts — Vite 6 library mode
import { defineConfig } from 'vite'
import dts from 'vite-plugin-dts'

export default defineConfig({
  build: {
    lib: {
      entry: 'src/index.ts',
      name: 'MyKit',            // UMD global name
      formats: ['es', 'cjs', 'umd'],
      fileName: (format) => `my-kit.${format}.js`,
    },
    rollupOptions: {
      // match react, react-dom, and any deep import into them
      external: [/^react($|\/)/, /^react-dom($|\/)/],
      output: {
        globals: { react: 'React', 'react-dom': 'ReactDOM' },
      },
    },
  },
  plugins: [dts({ rollupTypes: true })], // the one thing Vite core won't emit
})

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.

Four bundlers by dev server and output control Vite offers a dev server with HMR plus Rollup-level output; Rollup offers the highest output control but no dev server; esbuild is the fastest single pass with limited output control; and Turbopack offers incremental rebuilds coupled to Next.js. Vite dev server + HMR, Rollup output SPAs, SSR apps, most libraries instant start, on-demand transform Rollup highest output control, no dev server libraries, custom build graphs slower, fully optimized output esbuild fastest single pass, basic serve build scripts, CLIs, transpile weaker tree-shaking Turbopack incremental, Next-integrated Next.js apps fast rebuilds, framework-coupled
Figure: the choice is dev-server-plus-DX (Vite) versus raw output control (Rollup) versus one-pass speed (esbuild) versus Next coupling (Turbopack).
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.

Two halves of Vite performance Dev-server responsiveness is measured by cold-start, warm-start and HMR latency; production bundle quality is measured by total size, largest chunk, chunk count and gzip numbers from the Rollup build. dev responsiveness cold-start (first esbuild scan) warm-start (near-instant) HMR update latency production bundle quality total size + largest chunk chunk count (requests vs caching) gzip/brotli via visualizer + CI budgets
Figure: two independent budgets — one for the dev loop, one for the shipped bundle.

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.

Chunking is the lever with the largest effect on real-world load performance, and it is worth a concrete note. By default Rollup produces one chunk per dynamic import plus a shared vendor chunk, and hashes each filename on its content so unchanged chunks stay cached across deploys. The failure mode is a poorly-placed manualChunks split that lumps a rarely-changing vendor library into the same chunk as your frequently-edited app code — every app edit then changes the vendor chunk’s hash and busts a cache that should have survived for months. The inverse failure is splitting too aggressively, so a single route pulls twenty small chunks and pays twenty round-trips before it can render. A pragmatic manual-chunk boundary isolates the genuinely stable, large dependencies and lets Rollup handle the rest:

// vite.config.ts — Vite 6, cache-stable vendor splitting
export default {
  build: {
    rollupOptions: {
      output: {
        manualChunks(id) {
          // keep the big, rarely-updated framework in its own long-lived chunk
          if (id.includes('node_modules/react')) return 'react-vendor'
          if (id.includes('node_modules/chart.js')) return 'charts'
          // everything else falls back to Rollup's automatic splitting
        },
      },
    },
  },
}

To confirm a chunking change actually helped, build twice — once before and once after — and diff the emitted filenames: chunks whose hash is unchanged between an app-only edit and a rebuild are the ones staying in the browser cache. A size regression that appears only in production and not in the dev Network tab is the tell-tale of esbuild’s shallower tree-shaking hiding bloat that Rollup would have surfaced, so always benchmark against the built output, never the dev server.

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.

From two engines to one: the Rolldown trajectory Today Vite uses esbuild in dev and Rollup in build; the roadmap collapses that into a single Rolldown engine on the Oxc toolchain, with the Environment API generalizing the client/SSR split into named environments. today: two enginesesbuild dev + Rollup build rolldown-vite (opt-in)same plugin interface one engineRolldown / Oxc The two-engine model is a snapshot of the current major, not a permanent law The Environment API generalizes the client/SSR split into named environments.
Figure: the whole "two engines, one config" model this page uses is transitional — Rolldown collapses it.

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

Production-readiness checklist grouped by area The checklist spans five areas: config and pre-bundle cache, plugin enforce and apply, env prefixing and build survival, HMR accept boundaries, and SSR or library externalization plus CI size budgets. config + cachedefineConfig, optimizeDeps pluginsenforce + apply explicit envVITE_ prefix, survives build HMRaccept boundaries, no barrels SSR / libraryexternal + CI size budgets
Figure: the checklist below grouped into five areas — each maps to one of the sections above.
  • 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 …

  • Creating Virtual Modules in a Vite Plugin
  • Debugging Vite Plugin Hook Order with enforce and apply
  • Injecting Tags with the transformIndexHtml Hook
  • 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
  • Typing import.meta.env with a Vite env.d.ts

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
  • Warming Vite Transforms with the warmup Option

Vite Build Performance and Caching

Vite is fast by default, but a real project accumulates cold-start latency, memory pressure during production rollup builds, and CI runs tha…

  • Caching Vite optimizeDeps Across CI Runs
  • Parallelizing Vite Builds in a Monorepo with Turborepo
  • Reducing Vite Build Memory with manualChunks
  • Speeding Up Vite Cold Starts with Dependency Pre-Bundling

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
  • Generating TypeScript Declarations with vite-plugin-dts

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
  • Streaming SSR with renderToPipeableStream in Vite

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…